UI is largely working
All checks were successful
Build / build (push) Successful in 3m11s

This commit is contained in:
Sebastiaan de Schaetzen 2025-03-16 18:11:34 +01:00
parent c36a533d17
commit 91be8274c4
10 changed files with 298 additions and 171 deletions

3
go.mod
View File

@ -5,7 +5,8 @@ go 1.24
toolchain go1.24.0 toolchain go1.24.0
require ( require (
gitea.seeseepuff.be/seeseemelk/mysqlite v0.6.0 gitea.seeseepuff.be/seeseemelk/mysqlite v0.7.0
github.com/donseba/go-htmx v1.12.0
github.com/playwright-community/playwright-go v0.4902.0 github.com/playwright-community/playwright-go v0.4902.0
) )

6
go.sum
View File

@ -1,10 +1,12 @@
gitea.seeseepuff.be/seeseemelk/mysqlite v0.6.0 h1:Tqo9jnPXBDzIiMh+CrrFd5epITbG8UWqVfpLk21R/C0= gitea.seeseepuff.be/seeseemelk/mysqlite v0.7.0 h1:gq75Ce7QTQ5Rj5fzS/6eeOA/enyV0oDMVt5mejwX14Y=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.6.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= gitea.seeseepuff.be/seeseemelk/mysqlite v0.7.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set/v2 v2.7.0 h1:gIloKvD7yH2oip4VLhsv3JyLLFnC0Y2mlusgcvJYW5k= github.com/deckarep/golang-set/v2 v2.7.0 h1:gIloKvD7yH2oip4VLhsv3JyLLFnC0Y2mlusgcvJYW5k=
github.com/deckarep/golang-set/v2 v2.7.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/deckarep/golang-set/v2 v2.7.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/donseba/go-htmx v1.12.0 h1:7tESER0uxaqsuGMv3yP3pK1drfBUXM6apG4H7/3+IgE=
github.com/donseba/go-htmx v1.12.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=

View File

@ -12,9 +12,9 @@ func staticFileServer() http.Handler {
} }
func renderTemplate(wr io.Writer, templateName string, viewModel any) error { func renderTemplate(wr io.Writer, templateName string, viewModel any) error {
templates, err := template.ParseGlob("web/*.gohtml") templates, err := template.ParseGlob("web/*.go.html")
if err != nil { if err != nil {
return err return err
} }
return templates.ExecuteTemplate(wr, templateName+".gohtml", viewModel) return templates.ExecuteTemplate(wr, templateName+".go.html", viewModel)
} }

View File

@ -2,9 +2,11 @@ package main
type VideoInfoVM struct { type VideoInfoVM struct {
ID int ID int
PreviousID int
Title string Title string
Description string Description string
Thumbnail string Thumbnail string
IsWatched bool
} }
type VideoPlayerVM struct { type VideoPlayerVM struct {

8
web/frag-header.go.html Normal file
View File

@ -0,0 +1,8 @@
{{define "frag-header"}}
<head>
<meta charset="UTF-8">
<title>Viva++</title>
<link href="/static/style.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
{{end}}

32
web/frag-index.go.html Normal file
View File

@ -0,0 +1,32 @@
{{- /*gotype: vivaplusdl.VideoInfoVM*/}}
{{define "frag-index"}}
<div class="video-box">
<div class="thumbnail-container">
<a href="/video/{{.ID}}">
<img src="{{.Thumbnail}}" alt="Thumbnail" class="video-thumbnail" />
<div class="play-button">
<span class="play-symbol"></span>
</div>
</a>
</div>
<div class="video-info">
<h2 class="video-title">{{.Title}}</h2>
<p class="video-description">{{.Description}}</p>
<div class="video-actions">
<a href="/video/{{.ID}}" class="watch-button">Watch Now</a>
<form hx-boost="true" method="post" action="{{if .IsWatched}}/mark-unwatched/{{.ID}}{{else}}/mark-watched/{{.ID}}{{end}}">
<button type="submit" class="mark-watched">
{{if .IsWatched}}
Mark as Unwatched
{{else}}
Mark as Watched
{{end}}
</button>
</form>
{{if ne .PreviousID 0}}
<a href="/?video={{.PreviousID}}" class="mark-watched">Show Previous</a>
{{end}}
</div>
</div>
</div>
{{end}}

11
web/index.go.html Normal file
View File

@ -0,0 +1,11 @@
{{- /*gotype: vivaplusdl.VideoInfoVM*/}}
<!DOCTYPE html>
<html lang="en">
{{template "frag-header" .}}
<body>
<h1>Viva++</h1>
<div class="video-container">
{{template "frag-index" .}}
</div>
</body>
</html>

View File

@ -1,28 +0,0 @@
{{- /*gotype: vivaplusdl.VideoInfoVM*/}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Viva++</title>
<link href="/static/style.css" rel="stylesheet">
</head>
<body>
<h1>Viva++</h1>
<div class="video-container">
<div class="video-box">
<div class="thumbnail-container">
<a href="/video/{{.ID}}">
<img src="{{.Thumbnail}}" alt="Thumbnail" class="video-thumbnail" />
<div class="play-button">
<span class="play-symbol">▶</span>
</div>
</a>
</div>
<div class="video-info">
<h2 class="video-title">{{.Title}}</h2>
<p class="video-description">{{.Description}}</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,11 +1,7 @@
{{- /*gotype: vivaplusdl.VideoPlayerVM*/}} {{- /*gotype: vivaplusdl.VideoPlayerVM*/}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> {{template "frag-header" .}}
<meta charset="UTF-8">
<title>{{.Title}} - Viva++</title>
<link href="/static/style.css" rel="stylesheet">
</head>
<body> <body>
<h1>Viva++</h1> <h1>Viva++</h1>
<div class="back-button-container"> <div class="back-button-container">

View File

@ -10,17 +10,29 @@ import (
"strings" "strings"
"gitea.seeseepuff.be/seeseemelk/mysqlite" "gitea.seeseepuff.be/seeseemelk/mysqlite"
"github.com/donseba/go-htmx"
) )
type App struct {
db *mysqlite.Db
htmx *htmx.HTMX
}
func serveWebview(db *mysqlite.Db) { func serveWebview(db *mysqlite.Db) {
addr := "localhost:8081" addr := "localhost:8081"
app := &App{
db: db,
htmx: htmx.New(),
}
log.Printf("Listening on %s", addr) log.Printf("Listening on %s", addr)
http.Handle("/static/", http.StripPrefix("/static/", staticFileServer())) http.Handle("/static/", http.StripPrefix("/static/", staticFileServer()))
http.HandleFunc("/video/", serveVideo(db)) http.HandleFunc("/video/", app.serveVideo)
http.HandleFunc("/stream/", serveStream(db)) http.HandleFunc("/stream/", app.serveStream)
http.HandleFunc("/", serveIndex(db)) http.HandleFunc("/mark-watched/", app.handleMarkWatched)
// http.HandleFunc("/video/", handleVideoActions(db)) http.HandleFunc("/mark-unwatched/", app.handleMarkUnwatched)
http.HandleFunc("/", app.serveIndex)
err := http.ListenAndServe(addr, nil) err := http.ListenAndServe(addr, nil)
if err != nil { if err != nil {
@ -28,37 +40,87 @@ func serveWebview(db *mysqlite.Db) {
} }
} }
func serveIndex(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) { func (a *App) renderTemplate(w http.ResponseWriter, name string, viewmodel interface{}) {
return func(w http.ResponseWriter, r *http.Request) { err := renderTemplate(w, "index", viewmodel)
// Query the database for the oldest unwatched episode
var id int
var title, description, thumbnail string
err := db.Query(`SELECT id, title, description, thumbnail FROM videos WHERE (watch_state IS NULL OR watch_state != 'watched') ORDER BY upload_date, episode LIMIT 1`).ScanSingle(&id, &title, &description, &thumbnail)
if err != nil {
http.Error(w, "No unwatched episodes found", http.StatusNotFound)
return
}
vm := VideoInfoVM{
ID: id,
Title: title,
Description: description,
Thumbnail: thumbnail,
}
err = renderTemplate(w, "index", vm)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
func (a *App) getViewModelForVideo(id int) (*VideoInfoVM, error) {
// Query the database for the requested video
var title, description, thumbnail string
var watchState *string
err := a.db.Query(`SELECT title, description, thumbnail, watch_state FROM videos WHERE id = ? LIMIT 1`).Bind(id).ScanSingle(&title, &description, &thumbnail, &watchState)
if err != nil {
return nil, err
} }
func serveVideo(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) { // Find the previous video ID based on upload date and episode number
return func(w http.ResponseWriter, r *http.Request) { var previousID int
if r.Method != http.MethodGet { err = a.db.Query(`
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) SELECT id FROM videos
WHERE (upload_date < (SELECT upload_date FROM videos WHERE id = ?))
OR (upload_date = (SELECT upload_date FROM videos WHERE id = ?) AND episode < (SELECT episode FROM videos WHERE id = ?))
ORDER BY upload_date DESC, episode DESC
LIMIT 1
`).Bind(id, id, id).ScanSingle(&previousID)
if err != nil {
// If there's no previous video, set previousID to 0 or -1 to indicate no previous video
previousID = 0
}
// Determine if the video has been watched
isWatched := watchState != nil && *watchState == "watched"
return &VideoInfoVM{
ID: id,
PreviousID: previousID,
Title: title,
Description: description,
Thumbnail: thumbnail,
IsWatched: isWatched,
}, nil
}
func (a *App) getViewModelForUnwachtedVideo() (*VideoInfoVM, error) {
// Query the database for the oldest unwatched episode
var id int
err := a.db.Query(`SELECT id FROM videos WHERE (watch_state IS NULL OR watch_state != 'watched') ORDER BY upload_date, episode LIMIT 1`).ScanSingle(&id)
if err != nil {
return nil, err
}
return a.getViewModelForVideo(id)
}
func (a *App) serveIndex(w http.ResponseWriter, r *http.Request) {
var vm *VideoInfoVM
var err error
// Check if a specific video ID was requested
videoIDParam := r.URL.Query().Get("video")
if videoIDParam != "" {
videoID, parseErr := strconv.Atoi(videoIDParam)
if parseErr == nil {
vm, err = a.getViewModelForVideo(videoID)
} else {
http.Error(w, "Invalid video ID", http.StatusBadRequest)
return return
} }
} else {
// Fall back to the default behavior - get the oldest unwatched video
vm, err = a.getViewModelForUnwachtedVideo()
}
if err != nil {
log.Printf("Failed to find video: %v", err)
http.Error(w, "Failed to find video", http.StatusInternalServerError)
return
}
a.renderTemplate(w, "index", vm)
}
func (a *App) serveVideo(w http.ResponseWriter, r *http.Request) {
// Parse the video ID from the URL // Parse the video ID from the URL
pathParts := strings.Split(r.URL.Path, "/") pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 3 { if len(pathParts) < 3 {
@ -75,8 +137,9 @@ func serveVideo(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) {
// Query the database for the video with the given ID // Query the database for the video with the given ID
var title, description string var title, description string
err = db.Query(`SELECT title, description FROM videos WHERE id = ?`).Bind(videoID).ScanSingle(&title, &description) err = a.db.Query(`SELECT title, description FROM videos WHERE id = ?`).Bind(videoID).ScanSingle(&title, &description)
if err != nil { if err != nil {
log.Printf("Failed to find video: %v", err)
http.Error(w, "Video not found", http.StatusNotFound) http.Error(w, "Video not found", http.StatusNotFound)
return return
} }
@ -86,15 +149,10 @@ func serveVideo(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) {
Title: title, Title: title,
Description: description, Description: description,
} }
err = renderTemplate(w, "video", vm) a.renderTemplate(w, "video", vm)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
} }
func serveStream(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) { func (a *App) serveStream(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// Parse the video ID from the URL // Parse the video ID from the URL
pathParts := strings.Split(r.URL.Path, "/") pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 3 { if len(pathParts) < 3 {
@ -111,7 +169,7 @@ func serveStream(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) {
// Get video metadata from database // Get video metadata from database
var year, episode int var year, episode int
err = db.Query(`SELECT year, episode FROM videos WHERE id = ?`).Bind(videoID).ScanSingle(&year, &episode) err = a.db.Query(`SELECT year, episode FROM videos WHERE id = ?`).Bind(videoID).ScanSingle(&year, &episode)
if err != nil { if err != nil {
http.Error(w, "Video not found", http.StatusNotFound) http.Error(w, "Video not found", http.StatusNotFound)
return return
@ -138,42 +196,87 @@ func serveStream(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) {
// Serve the file // Serve the file
http.ServeFile(w, r, videoPath) http.ServeFile(w, r, videoPath)
} }
func (a *App) handleMarkWatched(w http.ResponseWriter, r *http.Request) {
// Only handle POST requests
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
} }
// func handleVideoActions(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) { // Parse the video ID from the URL
// return func(w http.ResponseWriter, r *http.Request) { pathParts := strings.Split(r.URL.Path, "/")
// // Only handle POST requests for actions if len(pathParts) < 3 {
// if r.Method != http.MethodPost { http.Error(w, "Invalid video ID", http.StatusBadRequest)
// http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return
// return }
// }
// pathParts := strings.Split(r.URL.Path, "/") videoIDStr := pathParts[2]
// if len(pathParts) < 4 { videoID, err := strconv.Atoi(videoIDStr)
// http.Error(w, "Invalid URL format", http.StatusBadRequest) if err != nil {
// return http.Error(w, "Invalid video ID", http.StatusBadRequest)
// } return
}
// videoIDStr := pathParts[2] // Update the watch state in the database
// action := pathParts[3] err = a.db.Query(`UPDATE videos SET watch_state = NULL WHERE id = ?`).Bind(videoID).Exec()
// videoID, err := strconv.Atoi(videoIDStr) if err != nil {
// if err != nil { http.Error(w, "Failed to update watch state", http.StatusInternalServerError)
// http.Error(w, "Invalid video ID", http.StatusBadRequest) return
// return }
// }
// switch action { h := a.htmx.NewHandler(w, r)
// case "mark-watched": if h.RenderPartial() {
// err = db.Query(`UPDATE videos SET watch_state = 'watched' WHERE id = ?`).Bind(videoID).Exec() vm, err := a.getViewModelForVideo(videoID)
// if err != nil { if err != nil {
// http.Error(w, "Failed to update watch state", http.StatusInternalServerError) http.Error(w, "Failed to find video", http.StatusInternalServerError)
// return return
// } }
// // Redirect back to the homepage a.renderTemplate(w, "frag-index", vm)
// http.Redirect(w, r, "/", http.StatusSeeOther) } else {
// default: // Redirect back to the homepage
// http.Error(w, "Unknown action", http.StatusBadRequest) http.Redirect(w, r, "/", http.StatusSeeOther)
// return }
// } }
// }
// } func (a *App) handleMarkUnwatched(w http.ResponseWriter, r *http.Request) {
// Only handle POST requests
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse the video ID from the URL
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 3 {
http.Error(w, "Invalid video ID", http.StatusBadRequest)
return
}
videoIDStr := pathParts[2]
videoID, err := strconv.Atoi(videoIDStr)
if err != nil {
http.Error(w, "Invalid video ID", http.StatusBadRequest)
return
}
// Update the watch state in the database
err = a.db.Query(`UPDATE videos SET watch_state = 'unwatched' WHERE id = ?`).Bind(videoID).Exec()
if err != nil {
http.Error(w, "Failed to update watch state", http.StatusInternalServerError)
return
}
h := a.htmx.NewHandler(w, r)
if h.RenderPartial() {
vm, err := a.getViewModelForVideo(videoID)
if err != nil {
http.Error(w, "Failed to find video", http.StatusInternalServerError)
return
}
a.renderTemplate(w, "frag-index", vm)
} else {
// Redirect back to the homepage
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}