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:
156
app/templates/admin.templ
Normal file
156
app/templates/admin.templ
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
×
|
||||
</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 {
|
||||
|
||||
@@ -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 & 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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
© { currentYear() } Technical Kiwi Limited. New Zealand.
|
||||
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">git.technical.kiwi</a>
|
||||
</p>
|
||||
<p>© { currentYear() } Technical Kiwi Limited. New Zealand.</p>
|
||||
</footer>
|
||||
<div id="modal-root"></div>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user