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