Add Technical Kiwi website with Go, templ, and HTMX.
Single-page site with gallery by album and event, contact form over SMTP, Docker dev/prod setup, and on-server image derivatives. Gallery photos stay local (app/images/ is gitignored). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
76
app/templates/contact.templ
Normal file
76
app/templates/contact.templ
Normal file
@@ -0,0 +1,76 @@
|
||||
package templates
|
||||
|
||||
templ ContactForm(enabled bool) {
|
||||
<section id="contact" class="contact">
|
||||
<h2>Contact</h2>
|
||||
<p class="contact-intro">
|
||||
Interested in a project or collaboration? Send a message below.
|
||||
</p>
|
||||
if !enabled {
|
||||
<p class="contact-unavailable">
|
||||
The contact form is not configured yet. Email
|
||||
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>
|
||||
directly.
|
||||
</p>
|
||||
} else {
|
||||
<form
|
||||
class="contact-form"
|
||||
method="post"
|
||||
action="/contact"
|
||||
hx-post="/contact"
|
||||
hx-target="#contact-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-boost="false"
|
||||
hx-disabled-elt="find button[type=submit]"
|
||||
>
|
||||
<div class="form-row">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required autocomplete="name" maxlength="120"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" maxlength="254"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" required rows="6" maxlength="8000"></textarea>
|
||||
</div>
|
||||
<input
|
||||
class="hp-field"
|
||||
type="text"
|
||||
name="website"
|
||||
id="website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary">Send message</button>
|
||||
</form>
|
||||
}
|
||||
<div id="contact-result" class="contact-result"></div>
|
||||
</section>
|
||||
}
|
||||
|
||||
templ ContactSuccess() {
|
||||
<div class="alert alert-success" role="status">
|
||||
<p>Thanks — your message has been sent. We will get back to you soon.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ ContactValidationAlert(errs map[string]string) {
|
||||
<div class="alert alert-error" role="alert">
|
||||
<p>Please fix the following:</p>
|
||||
<ul>
|
||||
for _, msg := range errs {
|
||||
<li>{ msg }</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ ContactSendError() {
|
||||
<div class="alert alert-error" role="alert">
|
||||
<p>Something went wrong sending your message. Please try again later or email
|
||||
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>.</p>
|
||||
</div>
|
||||
}
|
||||
112
app/templates/gallery.templ
Normal file
112
app/templates/gallery.templ
Normal file
@@ -0,0 +1,112 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"technical.kiwi/website/internal/gallery"
|
||||
)
|
||||
|
||||
templ GalleryGrid(images []gallery.Image) {
|
||||
if len(images) == 0 {
|
||||
<p class="gallery-empty">No images for this filter.</p>
|
||||
} else {
|
||||
for _, img := range images {
|
||||
<button
|
||||
type="button"
|
||||
class="gallery-item"
|
||||
hx-get={ fmt.Sprintf("/gallery/%s", gallery.URLPath(img.RelPath)) }
|
||||
hx-target="#modal-root"
|
||||
hx-swap="innerHTML"
|
||||
aria-label={ fmt.Sprintf("Open %s", gallery.AlbumLabel(img.Album)) }
|
||||
>
|
||||
if img.ThumbURL != "" {
|
||||
<img
|
||||
src={ img.ThumbURL }
|
||||
alt={ fmt.Sprintf("%s — %s", gallery.AlbumLabel(img.Album), formatDate(img.Date)) }
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
sizes="(max-width: 480px) 45vw, 160px"
|
||||
/>
|
||||
} else {
|
||||
<span class="gallery-placeholder" aria-hidden="true"></span>
|
||||
}
|
||||
if img.Collection != "" {
|
||||
<span class="gallery-album">{ img.Collection }</span>
|
||||
} else if img.Album != "" {
|
||||
<span class="gallery-album">{ gallery.AlbumLabel(img.Album) }</span>
|
||||
}
|
||||
if !img.Date.IsZero() {
|
||||
<span class="gallery-date">{ formatDate(img.Date) }</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ ImageModal(img gallery.Image, prevPath, nextPath string) {
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
hx-on:click="if (event.target === this) this.remove()"
|
||||
>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Image viewer">
|
||||
<button
|
||||
type="button"
|
||||
class="modal-close"
|
||||
onclick="document.getElementById('modal-root').innerHTML = ''"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<figure class="modal-figure">
|
||||
<img src={ img.URL } alt={ img.Filename }/>
|
||||
if !img.Date.IsZero() {
|
||||
<figcaption>{ modalCaption(img) }</figcaption>
|
||||
}
|
||||
</figure>
|
||||
<nav class="modal-nav" aria-label="Gallery navigation">
|
||||
if prevPath != "" {
|
||||
<button
|
||||
type="button"
|
||||
class="modal-nav-btn"
|
||||
hx-get={ fmt.Sprintf("/gallery/%s", gallery.URLPath(prevPath)) }
|
||||
hx-target="#modal-root"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
} else {
|
||||
<span></span>
|
||||
}
|
||||
if nextPath != "" {
|
||||
<button
|
||||
type="button"
|
||||
class="modal-nav-btn"
|
||||
hx-get={ fmt.Sprintf("/gallery/%s", gallery.URLPath(nextPath)) }
|
||||
hx-target="#modal-root"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
func formatDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2 Jan 2006")
|
||||
}
|
||||
|
||||
func modalCaption(img gallery.Image) string {
|
||||
if img.Collection != "" {
|
||||
return img.Collection + " — " + formatDate(img.Date)
|
||||
}
|
||||
if img.Album != "" {
|
||||
return gallery.AlbumLabel(img.Album) + " — " + formatDate(img.Date)
|
||||
}
|
||||
return formatDate(img.Date)
|
||||
}
|
||||
7
app/templates/helpers.go
Normal file
7
app/templates/helpers.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package templates
|
||||
|
||||
import "time"
|
||||
|
||||
func currentYear() string {
|
||||
return time.Now().Format("2006")
|
||||
}
|
||||
153
app/templates/home.templ
Normal file
153
app/templates/home.templ
Normal file
@@ -0,0 +1,153 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"technical.kiwi/website/internal/gallery"
|
||||
)
|
||||
|
||||
templ Home(images []gallery.Image, hero gallery.Image, hasHero bool, contactEnabled bool) {
|
||||
@Layout("Technical Kiwi Limited", heroPreload(hero, hasHero), homeContent(images, hero, hasHero, contactEnabled))
|
||||
}
|
||||
|
||||
func heroPreload(hero gallery.Image, hasHero bool) string {
|
||||
if !hasHero {
|
||||
return ""
|
||||
}
|
||||
return hero.HeroURL
|
||||
}
|
||||
|
||||
templ homeContent(images []gallery.Image, hero gallery.Image, hasHero bool, contactEnabled bool) {
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Technical Kiwi Limited</p>
|
||||
<h1>Electronic engineering, DevOps, and interactive art</h1>
|
||||
<p class="lead">
|
||||
We design and build lighting installations, embedded systems, and the infrastructure
|
||||
that keeps creative technology running — from workshop bench to stage.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="#gallery" class="btn btn-primary">View our work</a>
|
||||
<a href="#contact" class="btn btn-ghost">Get in touch</a>
|
||||
</div>
|
||||
</div>
|
||||
if hasHero && hero.HeroURL != "" {
|
||||
<figure class="hero-visual">
|
||||
<img
|
||||
src={ hero.HeroURL }
|
||||
alt="Technical Kiwi — Connection machine"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</figure>
|
||||
}
|
||||
</section>
|
||||
<section id="services" class="services">
|
||||
<h2>What we do</h2>
|
||||
<div class="service-grid">
|
||||
<article class="service-card">
|
||||
<h3>Electronic engineering</h3>
|
||||
<p>Custom LED systems, control electronics, PCB design, and fabrication for installations and products.</p>
|
||||
</article>
|
||||
<article class="service-card">
|
||||
<h3>DevOps & infrastructure</h3>
|
||||
<p>Servers, CI/CD, monitoring, and reliable deployments — so your systems stay up when the show goes live.</p>
|
||||
</article>
|
||||
<article class="service-card">
|
||||
<h3>Interactive art</h3>
|
||||
<p>Lighting, sound-reactive visuals, and experiential tech for events, venues, and public spaces.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section id="code" class="code-section">
|
||||
<div class="section-head">
|
||||
<h2>Code</h2>
|
||||
<p>
|
||||
All repositories on
|
||||
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">git.technical.kiwi</a>
|
||||
are written and maintained by Technical Kiwi — firmware, lighting control, web services,
|
||||
and the infrastructure behind them.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://git.technical.kiwi/"
|
||||
class="code-card"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div class="code-card-copy">
|
||||
<h3>git.technical.kiwi</h3>
|
||||
<p>
|
||||
Source for installations, internal tools, and open components we ship or deploy
|
||||
{ "for" } clients. Browse projects, history, and releases in one place.
|
||||
</p>
|
||||
</div>
|
||||
<span class="btn btn-primary">Browse repositories</span>
|
||||
</a>
|
||||
</section>
|
||||
<section id="gallery" class="gallery-section">
|
||||
<div class="section-head">
|
||||
<h2>Gallery</h2>
|
||||
<p>Installations, prototypes, and behind-the-scenes work.</p>
|
||||
</div>
|
||||
@GalleryControls(images)
|
||||
@GalleryCollectionControls(images)
|
||||
<div id="gallery-grid" class="gallery-grid">
|
||||
@GalleryGrid(images)
|
||||
</div>
|
||||
</section>
|
||||
@ContactForm(contactEnabled)
|
||||
}
|
||||
|
||||
templ GalleryControls(images []gallery.Image) {
|
||||
<div class="gallery-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn active"
|
||||
hx-get="/gallery"
|
||||
hx-target="#gallery-grid"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
for _, album := range gallery.Albums(images) {
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
hx-get={ fmt.Sprintf("/gallery?album=%s", album) }
|
||||
hx-target="#gallery-grid"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
{ gallery.AlbumLabel(album) }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ GalleryCollectionControls(images []gallery.Image) {
|
||||
if len(gallery.Collections(images, "templeoftechno")) > 0 {
|
||||
<div class="gallery-controls gallery-controls-collections">
|
||||
<span class="gallery-controls-label">Temple of techno</span>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
hx-get="/gallery?album=templeoftechno"
|
||||
hx-target="#gallery-grid"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
All events
|
||||
</button>
|
||||
for _, key := range gallery.Collections(images, "templeoftechno") {
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
hx-get={ fmt.Sprintf("/gallery?album=templeoftechno&collection=%s", key) }
|
||||
hx-target="#gallery-grid"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
{ gallery.CollectionLabel(key, images) }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
43
app/templates/layout.templ
Normal file
43
app/templates/layout.templ
Normal file
@@ -0,0 +1,43 @@
|
||||
package templates
|
||||
|
||||
templ Layout(title string, preloadImage string, content templ.Component) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<meta name="theme-color" content="#14100c"/>
|
||||
<title>{ title }</title>
|
||||
<meta name="description" content="Technical Kiwi Limited — electronic engineering, DevOps, and interactive art from New Zealand."/>
|
||||
<link rel="stylesheet" href="/static/style.css"/>
|
||||
if preloadImage != "" {
|
||||
<link rel="preload" as="image" href={ preloadImage } fetchpriority="high"/>
|
||||
}
|
||||
</head>
|
||||
<body hx-boost="true">
|
||||
<header class="site-header">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-mark" aria-hidden="true">T</span>
|
||||
<span class="logo-text">Technical Kiwi</span>
|
||||
</a>
|
||||
<nav class="site-nav">
|
||||
<a href="#services">Services</a>
|
||||
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">Code</a>
|
||||
<a href="#gallery">Gallery</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
@content
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>
|
||||
© { currentYear() } Technical Kiwi Limited. New Zealand.
|
||||
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">git.technical.kiwi</a>
|
||||
</p>
|
||||
</footer>
|
||||
<div id="modal-root"></div>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
Reference in New Issue
Block a user