10 changed files with 439 additions and 6 deletions
@ -0,0 +1,137 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
"sort" |
|||
"time" |
|||
|
|||
"github.com/domagojzecevic/cammonitor/internal/auth" |
|||
"github.com/domagojzecevic/cammonitor/internal/footage" |
|||
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates" |
|||
"github.com/go-chi/chi/v5" |
|||
) |
|||
|
|||
type Handler struct { |
|||
index *footage.Index |
|||
} |
|||
|
|||
type ShellData struct { |
|||
Title string |
|||
User *auth.User |
|||
Months []MonthGroup |
|||
CurrentDate string |
|||
ActiveTab string |
|||
Content any |
|||
} |
|||
|
|||
type MonthGroup struct { |
|||
Label string |
|||
Days []string |
|||
} |
|||
|
|||
type DayPageData struct { |
|||
Date string |
|||
ImageCount int |
|||
VideoCount int |
|||
} |
|||
|
|||
func NewHandler(index *footage.Index) *Handler { |
|||
return &Handler{index: index} |
|||
} |
|||
|
|||
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { |
|||
days := h.dayList() |
|||
if len(days) == 0 { |
|||
h.render(w, r, ShellData{ |
|||
Title: "CamMonitor", |
|||
Content: DayPageData{ |
|||
Date: "No footage", |
|||
}, |
|||
}) |
|||
return |
|||
} |
|||
|
|||
http.Redirect(w, r, "/day/"+days[0]+"/images", http.StatusFound) |
|||
} |
|||
|
|||
func (h *Handler) DayOverview(w http.ResponseWriter, r *http.Request) { |
|||
date := chi.URLParam(r, "date") |
|||
day, ok := h.day(date) |
|||
if !ok { |
|||
http.NotFound(w, r) |
|||
return |
|||
} |
|||
|
|||
h.render(w, r, ShellData{ |
|||
Title: "CamMonitor " + date, |
|||
CurrentDate: date, |
|||
Content: DayPageData{ |
|||
Date: date, |
|||
ImageCount: len(day.Images), |
|||
VideoCount: len(day.Videos), |
|||
}, |
|||
}) |
|||
} |
|||
|
|||
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 := shellTemplate.ExecuteTemplate(w, "base", data); err != nil { |
|||
http.Error(w, "render page failed", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
func (h *Handler) dayList() []string { |
|||
if h.index == nil { |
|||
return nil |
|||
} |
|||
return h.index.DayList() |
|||
} |
|||
|
|||
func (h *Handler) day(date string) (*footage.DayEntry, bool) { |
|||
if h.index == nil { |
|||
return nil, false |
|||
} |
|||
return h.index.Day(date) |
|||
} |
|||
|
|||
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 shellTemplate = template.Must(template.ParseFS( |
|||
webtemplates.FS, |
|||
webtemplates.Base, |
|||
webtemplates.Day, |
|||
)) |
|||
@ -0,0 +1,72 @@ |
|||
{{define "base"}} |
|||
<!doctype html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|||
<title>{{.Title}}</title> |
|||
<script src="https://cdn.tailwindcss.com"></script> |
|||
</head> |
|||
<body class="min-h-screen bg-slate-900 text-slate-100"> |
|||
<div class="flex min-h-screen"> |
|||
<aside class="hidden w-56 shrink-0 border-r border-slate-800 bg-slate-950 md:block"> |
|||
<div class="px-4 py-4"> |
|||
<a href="/" class="text-base font-semibold text-slate-100">CamMonitor</a> |
|||
</div> |
|||
<nav class="px-3 pb-6"> |
|||
{{range .Months}} |
|||
<div class="mt-5 first:mt-0"> |
|||
<div class="px-2 text-xs font-semibold uppercase tracking-wide text-slate-500">{{.Label}}</div> |
|||
<div class="mt-2 space-y-1"> |
|||
{{range .Days}} |
|||
<a href="/day/{{.}}" class="block rounded-md px-2 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white">{{.}}</a> |
|||
{{end}} |
|||
</div> |
|||
</div> |
|||
{{end}} |
|||
</nav> |
|||
</aside> |
|||
|
|||
<main class="flex min-h-screen flex-1 flex-col pb-16 md:pb-0"> |
|||
<header class="sticky top-0 z-10 border-b border-slate-800 bg-slate-900/95"> |
|||
<div class="flex h-14 items-center justify-between px-4 md:px-6"> |
|||
<div class="flex items-center gap-3"> |
|||
<details data-mobile-drawer class="relative md:hidden"> |
|||
<summary class="flex h-9 w-9 cursor-pointer list-none items-center justify-center rounded-md border border-slate-700 text-slate-100">☰</summary> |
|||
<div class="absolute left-0 top-11 z-30 w-72 border border-slate-800 bg-slate-950 p-4 shadow-xl"> |
|||
{{range .Months}} |
|||
<div class="mt-5 first:mt-0"> |
|||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{.Label}}</div> |
|||
<div class="mt-2 space-y-1"> |
|||
{{range .Days}} |
|||
<a href="/day/{{.}}" class="block rounded-md px-2 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white">{{.}}</a> |
|||
{{end}} |
|||
</div> |
|||
</div> |
|||
{{end}} |
|||
</div> |
|||
</details> |
|||
<a href="/" class="text-base font-semibold text-slate-100 md:hidden">CamMonitor</a> |
|||
</div> |
|||
<div class="flex items-center gap-3 text-sm text-slate-300"> |
|||
{{with .User}}<span>{{.Username}}</span>{{end}} |
|||
<form method="post" action="/logout"> |
|||
<button type="submit" class="rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-100 hover:bg-slate-800">Logout</button> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</header> |
|||
|
|||
<section class="flex-1 px-4 py-6 md:px-8"> |
|||
{{template "content" .}} |
|||
</section> |
|||
</main> |
|||
</div> |
|||
|
|||
<nav class="fixed inset-x-0 bottom-0 z-20 grid grid-cols-2 border-t border-slate-800 bg-slate-950/95 md:hidden"> |
|||
<a href="{{if .CurrentDate}}/day/{{.CurrentDate}}/images{{else}}/{{end}}" class="px-4 py-3 text-center text-sm font-medium text-slate-200">Images</a> |
|||
<a href="{{if .CurrentDate}}/day/{{.CurrentDate}}/videos{{else}}/{{end}}" class="px-4 py-3 text-center text-sm font-medium text-slate-200">Videos</a> |
|||
</nav> |
|||
</body> |
|||
</html> |
|||
{{end}} |
|||
@ -0,0 +1,16 @@ |
|||
{{define "content"}} |
|||
{{with .Content}} |
|||
<div class="mx-auto max-w-5xl"> |
|||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> |
|||
<div> |
|||
<h1 class="text-2xl font-semibold text-slate-50">{{.Date}}</h1> |
|||
<p class="mt-1 text-sm text-slate-400">Footage overview</p> |
|||
</div> |
|||
<div class="flex rounded-md border border-slate-800 bg-slate-950 p-1"> |
|||
<a href="/day/{{.Date}}/images" class="rounded px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Images ({{.ImageCount}})</a> |
|||
<a href="/day/{{.Date}}/videos" class="rounded px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Videos ({{.VideoCount}})</a> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
{{end}} |
|||
{{end}} |
|||
Loading…
Reference in new issue