13 changed files with 738 additions and 3 deletions
@ -0,0 +1,257 @@ |
|||||
|
package image |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"html/template" |
||||
|
"net/http" |
||||
|
"net/url" |
||||
|
"path/filepath" |
||||
|
"sort" |
||||
|
"strconv" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/domagojzecevic/cammonitor/internal/auth" |
||||
|
"github.com/domagojzecevic/cammonitor/internal/config" |
||||
|
"github.com/domagojzecevic/cammonitor/internal/footage" |
||||
|
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates" |
||||
|
"github.com/go-chi/chi/v5" |
||||
|
) |
||||
|
|
||||
|
type Handler struct { |
||||
|
cfg *config.Config |
||||
|
idx *footage.Index |
||||
|
thumbs *Cache |
||||
|
} |
||||
|
|
||||
|
type ShellData struct { |
||||
|
Title string |
||||
|
User *auth.User |
||||
|
Months []MonthGroup |
||||
|
CurrentDate string |
||||
|
ActiveTab string |
||||
|
Content any |
||||
|
} |
||||
|
|
||||
|
type MonthGroup struct { |
||||
|
Label string |
||||
|
Days []string |
||||
|
} |
||||
|
|
||||
|
type PageData struct { |
||||
|
Date string |
||||
|
Images []ImageItem |
||||
|
ActiveIndex int |
||||
|
PrevIndex int |
||||
|
NextIndex int |
||||
|
Current ImageItem |
||||
|
} |
||||
|
|
||||
|
type ImageItem struct { |
||||
|
Index int |
||||
|
Filename string |
||||
|
RelPath string |
||||
|
RawURL string |
||||
|
ThumbURL string |
||||
|
Active bool |
||||
|
} |
||||
|
|
||||
|
func NewHandler(cfg *config.Config, idx *footage.Index) *Handler { |
||||
|
return &Handler{ |
||||
|
cfg: cfg, |
||||
|
idx: idx, |
||||
|
thumbs: NewCache(defaultCacheEntries), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) ServeRaw(w http.ResponseWriter, r *http.Request) { |
||||
|
absPath, ok := h.resolveRequestPath(w, r) |
||||
|
if !ok { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.ServeFile(w, r, absPath) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) ServeThumb(w http.ResponseWriter, r *http.Request) { |
||||
|
absPath, ok := h.resolveRequestPath(w, r) |
||||
|
if !ok { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
thumb, err := h.thumbs.Thumbnail(absPath) |
||||
|
if err != nil { |
||||
|
http.NotFound(w, r) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
w.Header().Set("Cache-Control", "max-age=3600") |
||||
|
w.Header().Set("Content-Type", "image/jpeg") |
||||
|
w.WriteHeader(http.StatusOK) |
||||
|
_, _ = w.Write(thumb) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) ServePage(w http.ResponseWriter, r *http.Request) { |
||||
|
date := chi.URLParam(r, "date") |
||||
|
day, ok := h.day(date) |
||||
|
if !ok { |
||||
|
http.NotFound(w, r) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
data := h.pageData(date, day.Images, parseIndex(r.URL.Query().Get("idx"), len(day.Images))) |
||||
|
h.render(w, r, ShellData{ |
||||
|
Title: "CamMonitor " + date + " images", |
||||
|
CurrentDate: date, |
||||
|
ActiveTab: "images", |
||||
|
Content: data, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) pageData(date string, files []footage.ImageFile, active int) PageData { |
||||
|
items := make([]ImageItem, 0, len(files)) |
||||
|
for i, file := range files { |
||||
|
items = append(items, ImageItem{ |
||||
|
Index: i, |
||||
|
Filename: file.Filename, |
||||
|
RelPath: filepath.ToSlash(file.RelPath), |
||||
|
RawURL: "/raw/image/" + pathEscape(file.RelPath), |
||||
|
ThumbURL: "/thumb/image/" + pathEscape(file.RelPath), |
||||
|
Active: i == active, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
page := PageData{ |
||||
|
Date: date, |
||||
|
Images: items, |
||||
|
ActiveIndex: active, |
||||
|
} |
||||
|
if len(items) > 0 { |
||||
|
page.PrevIndex = (active - 1 + len(items)) % len(items) |
||||
|
page.NextIndex = (active + 1) % len(items) |
||||
|
page.Current = items[active] |
||||
|
} |
||||
|
return page |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) resolveRequestPath(w http.ResponseWriter, r *http.Request) (string, bool) { |
||||
|
absPath, err := h.resolveRelPath(chi.URLParam(r, "*")) |
||||
|
if err != nil { |
||||
|
http.Error(w, "invalid image path", http.StatusBadRequest) |
||||
|
return "", false |
||||
|
} |
||||
|
return absPath, true |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) resolveRelPath(relPath string) (string, error) { |
||||
|
if h.cfg == nil || h.cfg.FootageRoot == "" { |
||||
|
return "", fmt.Errorf("missing footage root") |
||||
|
} |
||||
|
if relPath == "" { |
||||
|
return "", fmt.Errorf("missing path") |
||||
|
} |
||||
|
|
||||
|
clean := filepath.Clean(filepath.FromSlash(relPath)) |
||||
|
if clean == "." || clean == ".." || filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { |
||||
|
return "", fmt.Errorf("unsafe path") |
||||
|
} |
||||
|
|
||||
|
root, err := filepath.Abs(h.cfg.FootageRoot) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
target, err := filepath.Abs(filepath.Join(root, clean)) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
|
||||
|
rel, err := filepath.Rel(root, target) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { |
||||
|
return "", fmt.Errorf("path escapes footage root") |
||||
|
} |
||||
|
return target, nil |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) render(w http.ResponseWriter, r *http.Request, data ShellData) { |
||||
|
data.User, _ = auth.UserFromContext(r.Context()) |
||||
|
data.Months = groupDaysByMonth(h.dayList()) |
||||
|
|
||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
if err := imageTemplate.ExecuteTemplate(w, "base", data); err != nil { |
||||
|
http.Error(w, "render image page failed", http.StatusInternalServerError) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) dayList() []string { |
||||
|
if h.idx == nil { |
||||
|
return nil |
||||
|
} |
||||
|
return h.idx.DayList() |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) day(date string) (*footage.DayEntry, bool) { |
||||
|
if h.idx == nil { |
||||
|
return nil, false |
||||
|
} |
||||
|
return h.idx.Day(date) |
||||
|
} |
||||
|
|
||||
|
func parseIndex(value string, length int) int { |
||||
|
if length <= 0 { |
||||
|
return 0 |
||||
|
} |
||||
|
index, err := strconv.Atoi(value) |
||||
|
if err != nil || index < 0 || index >= length { |
||||
|
return 0 |
||||
|
} |
||||
|
return index |
||||
|
} |
||||
|
|
||||
|
func pathEscape(relPath string) string { |
||||
|
parts := strings.Split(filepath.ToSlash(relPath), "/") |
||||
|
for i, part := range parts { |
||||
|
parts[i] = url.PathEscape(part) |
||||
|
} |
||||
|
return strings.Join(parts, "/") |
||||
|
} |
||||
|
|
||||
|
func groupDaysByMonth(days []string) []MonthGroup { |
||||
|
groups := make(map[string][]string) |
||||
|
labels := make([]string, 0) |
||||
|
|
||||
|
for _, day := range days { |
||||
|
if len(day) != len("20060102") { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
parsed, err := time.Parse("20060102", day) |
||||
|
if err != nil { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
label := parsed.Format("2006-01") |
||||
|
if _, ok := groups[label]; !ok { |
||||
|
labels = append(labels, label) |
||||
|
} |
||||
|
groups[label] = append(groups[label], day) |
||||
|
} |
||||
|
|
||||
|
sort.Sort(sort.Reverse(sort.StringSlice(labels))) |
||||
|
monthGroups := make([]MonthGroup, 0, len(labels)) |
||||
|
for _, label := range labels { |
||||
|
monthGroups = append(monthGroups, MonthGroup{ |
||||
|
Label: label, |
||||
|
Days: groups[label], |
||||
|
}) |
||||
|
} |
||||
|
return monthGroups |
||||
|
} |
||||
|
|
||||
|
var imageTemplate = template.Must(template.ParseFS( |
||||
|
webtemplates.FS, |
||||
|
webtemplates.Base, |
||||
|
webtemplates.Images, |
||||
|
)) |
||||
@ -0,0 +1,175 @@ |
|||||
|
package image |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
stdimage "image" |
||||
|
"image/color" |
||||
|
"image/jpeg" |
||||
|
"io" |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"testing" |
||||
|
|
||||
|
"github.com/domagojzecevic/cammonitor/internal/config" |
||||
|
"github.com/domagojzecevic/cammonitor/internal/footage" |
||||
|
"github.com/go-chi/chi/v5" |
||||
|
) |
||||
|
|
||||
|
func TestThumbnailEndpointReturnsSmallJPEG(t *testing.T) { |
||||
|
root := createImageFixture(t) |
||||
|
index := footage.NewIndex(root, 0) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
router := imageRouter(root, index) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/thumb/image/20260101/images/A26010112000001.jpg", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
router.ServeHTTP(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String()) |
||||
|
} |
||||
|
if contentType := response.Header().Get("Content-Type"); contentType != "image/jpeg" { |
||||
|
t.Fatalf("expected image/jpeg content type, got %q", contentType) |
||||
|
} |
||||
|
if response.Body.Len() > 30*1024 { |
||||
|
t.Fatalf("expected thumbnail <= 30 KB, got %d bytes", response.Body.Len()) |
||||
|
} |
||||
|
|
||||
|
thumbnail, err := jpeg.Decode(bytes.NewReader(response.Body.Bytes())) |
||||
|
if err != nil { |
||||
|
t.Fatalf("decode thumbnail: %v", err) |
||||
|
} |
||||
|
bounds := thumbnail.Bounds() |
||||
|
if bounds.Dx() > 160 || bounds.Dy() > 90 { |
||||
|
t.Fatalf("expected thumbnail within 160x90, got %dx%d", bounds.Dx(), bounds.Dy()) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestRawEndpointServesOriginalFile(t *testing.T) { |
||||
|
root := createImageFixture(t) |
||||
|
originalPath := filepath.Join(root, "20260101", "images", "A26010112000001.jpg") |
||||
|
original, err := os.ReadFile(originalPath) |
||||
|
if err != nil { |
||||
|
t.Fatalf("read original: %v", err) |
||||
|
} |
||||
|
index := footage.NewIndex(root, 0) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
router := imageRouter(root, index) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/raw/image/20260101/images/A26010112000001.jpg", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
router.ServeHTTP(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String()) |
||||
|
} |
||||
|
served, err := io.ReadAll(response.Body) |
||||
|
if err != nil { |
||||
|
t.Fatalf("read response: %v", err) |
||||
|
} |
||||
|
if !bytes.Equal(served, original) { |
||||
|
t.Fatalf("expected raw endpoint to serve original bytes unchanged") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestRawEndpointRejectsTraversal(t *testing.T) { |
||||
|
root := createImageFixture(t) |
||||
|
index := footage.NewIndex(root, 0) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
router := imageRouter(root, index) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/raw/image/%2e%2e/secret.jpg", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
router.ServeHTTP(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusBadRequest { |
||||
|
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, response.Code) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestImagePageRendersViewerNavigation(t *testing.T) { |
||||
|
root := createImageFixture(t) |
||||
|
index := footage.NewIndex(root, 0) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
router := imageRouter(root, index) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/day/20260101/images?idx=1", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
router.ServeHTTP(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String()) |
||||
|
} |
||||
|
|
||||
|
body := response.Body.String() |
||||
|
for _, want := range []string{ |
||||
|
`data-image-browser`, |
||||
|
`data-active-index="1"`, |
||||
|
`/thumb/image/20260101/images/A26010112000001.jpg`, |
||||
|
`/raw/image/20260101/images/A26010112050001.jpg`, |
||||
|
`data-prev-index="0"`, |
||||
|
`data-next-index="0"`, |
||||
|
`keydown`, |
||||
|
`ArrowLeft`, |
||||
|
`ArrowRight`, |
||||
|
`ring-2 ring-indigo-400`, |
||||
|
} { |
||||
|
if !bytes.Contains(response.Body.Bytes(), []byte(want)) { |
||||
|
t.Fatalf("expected response to contain %q\nbody:\n%s", want, body) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func imageRouter(root string, index *footage.Index) chi.Router { |
||||
|
handler := NewHandler(&config.Config{FootageRoot: root}, index) |
||||
|
router := chi.NewRouter() |
||||
|
router.Get("/raw/image/*", handler.ServeRaw) |
||||
|
router.Get("/thumb/image/*", handler.ServeThumb) |
||||
|
router.Get("/day/{date}/images", handler.ServePage) |
||||
|
return router |
||||
|
} |
||||
|
|
||||
|
func createImageFixture(t *testing.T) string { |
||||
|
t.Helper() |
||||
|
|
||||
|
root := t.TempDir() |
||||
|
imageDir := filepath.Join(root, "20260101", "images") |
||||
|
if err := os.MkdirAll(imageDir, 0o755); err != nil { |
||||
|
t.Fatalf("create image dir: %v", err) |
||||
|
} |
||||
|
|
||||
|
writeJPEG(t, filepath.Join(imageDir, "A26010112000001.jpg"), color.RGBA{R: 220, G: 40, B: 40, A: 255}) |
||||
|
writeJPEG(t, filepath.Join(imageDir, "A26010112050001.jpg"), color.RGBA{R: 40, G: 90, B: 220, A: 255}) |
||||
|
return root |
||||
|
} |
||||
|
|
||||
|
func writeJPEG(t *testing.T, path string, fill color.Color) { |
||||
|
t.Helper() |
||||
|
|
||||
|
img := stdimage.NewRGBA(stdimage.Rect(0, 0, 320, 180)) |
||||
|
for y := 0; y < img.Bounds().Dy(); y++ { |
||||
|
for x := 0; x < img.Bounds().Dx(); x++ { |
||||
|
img.Set(x, y, fill) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
file, err := os.Create(path) |
||||
|
if err != nil { |
||||
|
t.Fatalf("create jpeg %s: %v", path, err) |
||||
|
} |
||||
|
defer file.Close() |
||||
|
|
||||
|
if err := jpeg.Encode(file, img, &jpeg.Options{Quality: 90}); err != nil { |
||||
|
t.Fatalf("encode jpeg %s: %v", path, err) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,114 @@ |
|||||
|
package image |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
stdimage "image" |
||||
|
"image/jpeg" |
||||
|
"os" |
||||
|
"sync" |
||||
|
|
||||
|
xdraw "golang.org/x/image/draw" |
||||
|
) |
||||
|
|
||||
|
const ( |
||||
|
defaultCacheEntries = 500 |
||||
|
thumbnailWidth = 160 |
||||
|
thumbnailHeight = 90 |
||||
|
) |
||||
|
|
||||
|
type Cache struct { |
||||
|
mu sync.Mutex |
||||
|
max int |
||||
|
entries map[string][]byte |
||||
|
order []string |
||||
|
} |
||||
|
|
||||
|
func NewCache(max int) *Cache { |
||||
|
if max <= 0 { |
||||
|
max = defaultCacheEntries |
||||
|
} |
||||
|
return &Cache{ |
||||
|
max: max, |
||||
|
entries: make(map[string][]byte), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (c *Cache) Thumbnail(absPath string) ([]byte, error) { |
||||
|
if cached, ok := c.get(absPath); ok { |
||||
|
return cached, nil |
||||
|
} |
||||
|
|
||||
|
file, err := os.Open(absPath) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer file.Close() |
||||
|
|
||||
|
src, err := jpeg.Decode(file) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
dst := resizeToFit(src, thumbnailWidth, thumbnailHeight) |
||||
|
var out bytes.Buffer |
||||
|
if err := jpeg.Encode(&out, dst, &jpeg.Options{Quality: 75}); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
thumb := out.Bytes() |
||||
|
c.add(absPath, thumb) |
||||
|
return append([]byte(nil), thumb...), nil |
||||
|
} |
||||
|
|
||||
|
func (c *Cache) get(key string) ([]byte, bool) { |
||||
|
c.mu.Lock() |
||||
|
defer c.mu.Unlock() |
||||
|
|
||||
|
value, ok := c.entries[key] |
||||
|
if !ok { |
||||
|
return nil, false |
||||
|
} |
||||
|
return append([]byte(nil), value...), true |
||||
|
} |
||||
|
|
||||
|
func (c *Cache) add(key string, value []byte) { |
||||
|
c.mu.Lock() |
||||
|
defer c.mu.Unlock() |
||||
|
|
||||
|
if _, ok := c.entries[key]; !ok { |
||||
|
c.order = append(c.order, key) |
||||
|
} |
||||
|
c.entries[key] = append([]byte(nil), value...) |
||||
|
|
||||
|
for len(c.order) > c.max { |
||||
|
oldest := c.order[0] |
||||
|
c.order = c.order[1:] |
||||
|
delete(c.entries, oldest) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func resizeToFit(src stdimage.Image, maxWidth, maxHeight int) stdimage.Image { |
||||
|
bounds := src.Bounds() |
||||
|
width := bounds.Dx() |
||||
|
height := bounds.Dy() |
||||
|
if width <= 0 || height <= 0 { |
||||
|
return stdimage.NewRGBA(stdimage.Rect(0, 0, 1, 1)) |
||||
|
} |
||||
|
|
||||
|
targetWidth := maxWidth |
||||
|
targetHeight := height * targetWidth / width |
||||
|
if targetHeight > maxHeight { |
||||
|
targetHeight = maxHeight |
||||
|
targetWidth = width * targetHeight / height |
||||
|
} |
||||
|
if targetWidth < 1 { |
||||
|
targetWidth = 1 |
||||
|
} |
||||
|
if targetHeight < 1 { |
||||
|
targetHeight = 1 |
||||
|
} |
||||
|
|
||||
|
dst := stdimage.NewRGBA(stdimage.Rect(0, 0, targetWidth, targetHeight)) |
||||
|
xdraw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, xdraw.Over, nil) |
||||
|
return dst |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
{{define "content"}} |
||||
|
{{with .Content}} |
||||
|
<div data-image-browser data-active-index="{{.ActiveIndex}}" class="flex h-full min-h-[calc(100vh-7rem)] flex-col gap-4"> |
||||
|
<div class="flex items-center justify-between gap-3"> |
||||
|
<div> |
||||
|
<h1 class="text-lg font-semibold text-slate-100">{{.Date}}</h1> |
||||
|
<p class="text-sm text-slate-400">Images</p> |
||||
|
</div> |
||||
|
{{if .Images}} |
||||
|
<div class="flex items-center gap-2"> |
||||
|
<a href="/day/{{.Date}}/images?idx={{.PrevIndex}}" data-prev-index="{{.PrevIndex}}" class="rounded-md border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Prev</a> |
||||
|
<a href="/day/{{.Date}}/images?idx={{.NextIndex}}" data-next-index="{{.NextIndex}}" class="rounded-md border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Next</a> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
|
||||
|
{{if .Images}} |
||||
|
<div class="h-28 shrink-0 overflow-x-auto border-y border-slate-800 py-3"> |
||||
|
<div class="flex h-full gap-3"> |
||||
|
{{range .Images}} |
||||
|
<a href="/day/{{$.Content.Date}}/images?idx={{.Index}}" class="h-full w-40 shrink-0 overflow-hidden rounded-md border border-slate-800 bg-slate-950 {{if .Active}}ring-2 ring-indigo-400{{end}}"> |
||||
|
<img src="{{.ThumbURL}}" alt="{{.Filename}}" class="h-full w-full object-cover"> |
||||
|
</a> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="relative min-h-0 flex-1 overflow-hidden bg-black"> |
||||
|
<img src="{{.Current.RawURL}}" alt="{{.Current.Filename}}" class="h-full max-h-[calc(100vh-15rem)] min-h-[18rem] w-full object-contain"> |
||||
|
<a href="/day/{{.Date}}/images?idx={{.PrevIndex}}" class="absolute left-3 top-1/2 -translate-y-1/2 rounded-md bg-slate-950/80 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900">Prev</a> |
||||
|
<a href="/day/{{.Date}}/images?idx={{.NextIndex}}" class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md bg-slate-950/80 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900">Next</a> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
(() => { |
||||
|
const root = document.querySelector("[data-image-browser]"); |
||||
|
if (!root) return; |
||||
|
const active = Number(root.dataset.activeIndex || "0"); |
||||
|
const prev = root.querySelector("[data-prev-index]")?.dataset.prevIndex || "0"; |
||||
|
const next = root.querySelector("[data-next-index]")?.dataset.nextIndex || "0"; |
||||
|
const go = (index) => { |
||||
|
const url = new URL(window.location.href); |
||||
|
url.searchParams.set("idx", index); |
||||
|
window.location.href = url.toString(); |
||||
|
}; |
||||
|
window.history.replaceState({ idx: active }, "", window.location.href); |
||||
|
window.addEventListener("keydown", (event) => { |
||||
|
if (event.key === "ArrowLeft") go(prev); |
||||
|
if (event.key === "ArrowRight") go(next); |
||||
|
}); |
||||
|
})(); |
||||
|
</script> |
||||
|
{{else}} |
||||
|
<div class="border border-slate-800 bg-slate-950 p-6 text-sm text-slate-400">No images for this day.</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
{{end}} |
||||
Loading…
Reference in new issue