Add gallery admin and video media support.

This updates gallery handling to support video playback with generated poster thumbnails, adds authenticated admin upload/delete flows, and improves dev/runtime behavior including reliable thumbnail generation and media-safe response handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 23:01:02 +12:00
parent 509e7ccb43
commit 45b31be9a7
22 changed files with 1002 additions and 217 deletions

156
app/templates/admin.templ Normal file
View File

@@ -0,0 +1,156 @@
package templates
import (
"fmt"
"technical.kiwi/website/internal/gallery"
)
templ AdminLogin(errMsg string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Admin login — Technical Kiwi</title>
<link rel="stylesheet" href="/static/style.css"/>
</head>
<body class="admin-body">
<main class="admin-login">
<h1>Gallery admin</h1>
if errMsg != "" {
<div class="alert alert-error" role="alert"><p>{ errMsg }</p></div>
}
<form method="post" action="/admin/login" class="contact-form">
<div class="form-row">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autocomplete="username"/>
</div>
<div class="form-row">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password"/>
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
<p class="admin-back"><a href="/">← Back to site</a></p>
</main>
</body>
</html>
}
templ AdminDashboard(images []gallery.Image, albums []string, flash string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Gallery admin — Technical Kiwi</title>
<link rel="stylesheet" href="/static/style.css"/>
<script src="/static/htmx.min.js" defer></script>
</head>
<body class="admin-body" hx-boost="false">
<header class="admin-header">
<h1>Gallery admin</h1>
<form method="post" action="/admin/logout">
<button type="submit" class="btn btn-ghost">Sign out</button>
</form>
</header>
<main class="admin-main">
<div id="admin-flash">
if flash != "" {
@AdminFlash(flash)
}
</div>
<section class="admin-panel">
<h2>Upload image</h2>
<form
class="contact-form"
method="post"
action="/admin/upload"
enctype="multipart/form-data"
hx-post="/admin/upload"
hx-target="#admin-images"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
>
<div class="form-row">
<label for="album">Album</label>
<select id="album" name="album" required>
for _, a := range albums {
<option value={ a }>{ gallery.AlbumLabel(a) }</option>
}
<option value="new">+ New album</option>
</select>
</div>
<div class="form-row">
<label for="album_new">New album name</label>
<input type="text" id="album_new" name="album_new" placeholder="e.g. my-project" pattern="[a-z0-9][a-z0-9_-]{0,63}"/>
</div>
<div class="form-row">
<label for="image">JPEG image</label>
<input type="file" id="image" name="image" accept="image/jpeg,.jpg,.jpeg" required/>
</div>
<button type="submit" class="btn btn-primary">Upload</button>
</form>
</section>
<section class="admin-panel">
<h2>Images ({ fmt.Sprintf("%d", len(images)) })</h2>
<div id="admin-images">
@AdminImageTable(images)
</div>
</section>
<p class="admin-back"><a href="/">← View public site</a></p>
</main>
</body>
</html>
}
templ AdminFlash(msg string) {
if msg != "" {
<div class="alert alert-error" role="alert"><p>{ msg }</p></div>
}
}
templ AdminImageTable(images []gallery.Image) {
if len(images) == 0 {
<p class="gallery-empty">No images yet.</p>
} else {
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Preview</th>
<th>Path</th>
<th>Album</th>
<th></th>
</tr>
</thead>
<tbody>
for _, img := range images {
<tr>
<td class="admin-thumb">
if img.ThumbURL != "" {
<img src={ img.ThumbURL } alt="" loading="lazy"/>
}
</td>
<td><code>{ img.RelPath }</code></td>
<td>{ gallery.AlbumLabel(img.Album) }</td>
<td>
<form
method="post"
action="/admin/delete"
hx-post="/admin/delete"
hx-target="#admin-images"
hx-swap="innerHTML"
hx-confirm="Delete this image and its thumbnails?"
>
<input type="hidden" name="path" value={ img.RelPath }/>
<button type="submit" class="btn btn-ghost admin-delete">Delete</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}

View File

@@ -31,6 +31,9 @@ templ GalleryGrid(images []gallery.Image) {
} else {
<span class="gallery-placeholder" aria-hidden="true"></span>
}
if img.IsVideo {
<span class="gallery-video-badge" aria-hidden="true">Video</span>
}
if img.Collection != "" {
<span class="gallery-album">{ img.Collection }</span>
} else if img.Album != "" {
@@ -49,7 +52,7 @@ templ ImageModal(img gallery.Image, prevPath, nextPath string) {
class="modal-backdrop"
hx-on:click="if (event.target === this) this.remove()"
>
<div class="modal" role="dialog" aria-modal="true" aria-label="Image viewer">
<div class="modal" role="dialog" aria-modal="true" aria-label={ modalAriaLabel(img) }>
<button
type="button"
class="modal-close"
@@ -59,7 +62,11 @@ templ ImageModal(img gallery.Image, prevPath, nextPath string) {
&times;
</button>
<figure class="modal-figure">
<img src={ img.URL } alt={ img.Filename }/>
if img.IsVideo {
<video src={ img.URL } controls playsinline preload="metadata"></video>
} else {
<img src={ img.URL } alt={ img.Filename }/>
}
if !img.Date.IsZero() {
<figcaption>{ modalCaption(img) }</figcaption>
}
@@ -98,7 +105,14 @@ func formatDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2 Jan 2006")
return t.Format("Jan 2006")
}
func modalAriaLabel(img gallery.Image) string {
if img.IsVideo {
return "Video viewer"
}
return "Image viewer"
}
func modalCaption(img gallery.Image) string {

View File

@@ -1,9 +1,6 @@
package templates
import (
"fmt"
"technical.kiwi/website/internal/gallery"
)
import "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))
@@ -20,10 +17,10 @@ templ homeContent(images []gallery.Image, hero gallery.Image, hasHero bool, cont
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">Technical Kiwi Limited</p>
<h1>Electronic engineering, DevOps, and interactive art</h1>
<h1>Electronic engineering 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.
We design and build lighting installations, embedded systems, and interactive
experiences — from workshop bench to stage.
</p>
<div class="hero-actions">
<a href="#gallery" class="btn btn-primary">View our work</a>
@@ -49,105 +46,20 @@ templ homeContent(images []gallery.Image, hero gallery.Image, hasHero bool, cont
<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 &amp; 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>
}
}

View File

@@ -8,7 +8,7 @@ templ Layout(title string, preloadImage string, content templ.Component) {
<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."/>
<meta name="description" content="Technical Kiwi Limited — electronic engineering and interactive art from New Zealand."/>
<link rel="stylesheet" href="/static/style.css"/>
if preloadImage != "" {
<link rel="preload" as="image" href={ preloadImage } fetchpriority="high"/>
@@ -23,7 +23,6 @@ templ Layout(title string, preloadImage string, content templ.Component) {
<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>
@@ -31,10 +30,7 @@ templ Layout(title string, preloadImage string, content templ.Component) {
@content
</main>
<footer class="site-footer">
<p>
&copy; { currentYear() } Technical Kiwi Limited. New Zealand.
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">git.technical.kiwi</a>
</p>
<p>&copy; { currentYear() } Technical Kiwi Limited. New Zealand.</p>
</footer>
<div id="modal-root"></div>
<script src="/static/htmx.min.js" defer></script>