mirror of
https://github.com/weyne85/chaturbate-dvr.git
synced 2025-10-29 16:58:56 +00:00
2.0.0 refactor
This commit is contained in:
81
router/router.go
Normal file
81
router/router.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/teacat/chaturbate-dvr/router/view"
|
||||
"github.com/teacat/chaturbate-dvr/server"
|
||||
)
|
||||
|
||||
// SetupRouter initializes and returns the Gin router.
|
||||
func SetupRouter() *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.Default()
|
||||
if err := LoadHTMLFromEmbedFS(r, view.FS, "templates/index.html", "templates/channel_info.html"); err != nil {
|
||||
log.Fatalf("failed to load HTML templates: %v", err)
|
||||
}
|
||||
|
||||
// Apply authentication if configured
|
||||
SetupAuth(r)
|
||||
// Serve static frontend files
|
||||
SetupStatic(r)
|
||||
// Register views
|
||||
SetupViews(r)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// SetupAuth applies basic authentication if credentials are provided.
|
||||
func SetupAuth(r *gin.Engine) {
|
||||
if server.Config.AdminUsername != "" && server.Config.AdminPassword != "" {
|
||||
auth := gin.BasicAuth(gin.Accounts{
|
||||
server.Config.AdminUsername: server.Config.AdminPassword,
|
||||
})
|
||||
r.Use(auth)
|
||||
}
|
||||
}
|
||||
|
||||
// SetupStatic serves static frontend files.
|
||||
func SetupStatic(r *gin.Engine) {
|
||||
fs, err := view.StaticFS()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize static files: %v", err)
|
||||
}
|
||||
r.StaticFS("/static", fs)
|
||||
}
|
||||
|
||||
// setupViews registers HTML templates and view handlers.
|
||||
func SetupViews(r *gin.Engine) {
|
||||
r.GET("/", Index)
|
||||
r.GET("/updates", Updates)
|
||||
r.POST("/update_config", UpdateConfig)
|
||||
r.POST("/create_channel", CreateChannel)
|
||||
r.POST("/stop_channel/:username", StopChannel)
|
||||
r.POST("/pause_channel/:username", PauseChannel)
|
||||
r.POST("/resume_channel/:username", ResumeChannel)
|
||||
|
||||
}
|
||||
|
||||
// LoadHTMLFromEmbedFS loads specific HTML templates from an embedded filesystem and registers them with Gin.
|
||||
func LoadHTMLFromEmbedFS(r *gin.Engine, embeddedFS embed.FS, files ...string) error {
|
||||
templ := template.New("")
|
||||
for _, file := range files {
|
||||
content, err := embeddedFS.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = templ.New(filepath.Base(file)).Parse(string(content))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Set the parsed templates as the HTML renderer for Gin
|
||||
r.SetHTMLTemplate(templ)
|
||||
return nil
|
||||
}
|
||||
104
router/router_handler.go
Normal file
104
router/router_handler.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/teacat/chaturbate-dvr/entity"
|
||||
"github.com/teacat/chaturbate-dvr/server"
|
||||
)
|
||||
|
||||
// IndexData represents the data structure for the index page.
|
||||
type IndexData struct {
|
||||
Config *entity.Config
|
||||
Channels []*entity.ChannelInfo
|
||||
}
|
||||
|
||||
// Index renders the index page with channel information.
|
||||
func Index(c *gin.Context) {
|
||||
c.HTML(200, "index.html", &IndexData{
|
||||
Config: server.Config,
|
||||
Channels: server.Manager.ChannelInfo(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateChannelRequest represents the request body for creating a channel.
|
||||
type CreateChannelRequest struct {
|
||||
Username string `form:"username" binding:"required"`
|
||||
Framerate int `form:"framerate" binding:"required"`
|
||||
Resolution int `form:"resolution" binding:"required"`
|
||||
Pattern string `form:"pattern" binding:"required"`
|
||||
MaxDuration int `form:"max_duration"`
|
||||
MaxFilesize int `form:"max_filesize"`
|
||||
}
|
||||
|
||||
// CreateChannel creates a new channel.
|
||||
func CreateChannel(c *gin.Context) {
|
||||
var req *CreateChannelRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("bind: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, username := range strings.Split(req.Username, ",") {
|
||||
server.Manager.CreateChannel(&entity.ChannelConfig{
|
||||
IsPaused: false,
|
||||
Username: username,
|
||||
Framerate: req.Framerate,
|
||||
Resolution: req.Resolution,
|
||||
Pattern: req.Pattern,
|
||||
MaxDuration: req.MaxDuration,
|
||||
MaxFilesize: req.MaxFilesize,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}, true)
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// StopChannel stops a channel.
|
||||
func StopChannel(c *gin.Context) {
|
||||
server.Manager.StopChannel(c.Param("username"))
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// PauseChannel pauses a channel.
|
||||
func PauseChannel(c *gin.Context) {
|
||||
server.Manager.PauseChannel(c.Param("username"))
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// ResumeChannel resumes a paused channel.
|
||||
func ResumeChannel(c *gin.Context) {
|
||||
server.Manager.ResumeChannel(c.Param("username"))
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// Updates handles the SSE connection for updates.
|
||||
func Updates(c *gin.Context) {
|
||||
server.Manager.Subscriber(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
// UpdateConfigRequest represents the request body for updating configuration.
|
||||
type UpdateConfigRequest struct {
|
||||
Cookies string `form:"cookies"`
|
||||
UserAgent string `form:"user_agent"`
|
||||
}
|
||||
|
||||
// UpdateConfig updates the server configuration.
|
||||
func UpdateConfig(c *gin.Context) {
|
||||
var req *UpdateConfigRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("bind: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
server.Config.Cookies = req.Cookies
|
||||
server.Config.UserAgent = req.UserAgent
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
115
router/view/templates/channel_info.html
Normal file
115
router/view/templates/channel_info.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{{ define "channel_info" }}
|
||||
|
||||
<!-- Header -->
|
||||
<div class="ts-grid is-middle-aligned">
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-header">{{ .Username }}</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
{{ if and .IsOnline (not .IsPaused) }}
|
||||
<span class="ts-badge is-small is-start-spaced">RECORDING</span>
|
||||
{{ else if and (not .IsOnline) (not .IsPaused) }}
|
||||
<span class="ts-badge is-secondary is-small is-start-spaced">OFFLINE</span>
|
||||
{{ else if .IsPaused }}
|
||||
<span class="ts-badge is-negative is-small is-start-spaced">PAUSED</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Header -->
|
||||
|
||||
<div class="ts-divider has-top-spaced"></div>
|
||||
|
||||
<!-- Info: Channel URL -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column">
|
||||
<span class="ts-icon is-link-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-small is-bold">Channel URL</div>
|
||||
<a class="ts-text is-small is-link is-external-link" href="{{ .GlobalConfig.Domain }}{{ .Username }}" target="_blank"> {{ .GlobalConfig.Domain }}{{ .Username }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Channel URL -->
|
||||
|
||||
<!-- Info: Filename -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column">
|
||||
<span class="ts-icon is-folder-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-small is-bold">Filename</div>
|
||||
{{ if .Filename }}
|
||||
<div class="ts-text is-description">{{ .Filename }}</div>
|
||||
{{ else }}
|
||||
<span>-</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Filename -->
|
||||
|
||||
<!-- Info: Last streamed at -->
|
||||
<div class="ts-grid ts-grid has-top-spaced">
|
||||
<div class="column">
|
||||
<span class="ts-icon is-tower-broadcast-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-small is-bold">Last streamed at</div>
|
||||
<div class="ts-text is-description">{{ if .StreamedAt }}{{ .StreamedAt }} {{ if and .IsOnline (not .IsPaused) }}(NOW){{ end }}{{ else }} - {{ end }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Last streamed at -->
|
||||
|
||||
<!-- Info: Segment duration -->
|
||||
<div class="ts-grid ts-grid has-top-spaced">
|
||||
<div class="column">
|
||||
<span class="ts-icon is-clock-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-small is-bold">Segment duration</div>
|
||||
<div class="ts-text is-description">{{ if .Duration }} {{ .Duration }} {{ if .MaxDuration }} / {{ .MaxDuration }} {{ end }} {{ else }} - {{ end }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Segment duration -->
|
||||
|
||||
<!-- Info: Segment filesize -->
|
||||
<div class="ts-grid has-top-spaced">
|
||||
<div class="column">
|
||||
<span class="ts-icon is-chart-pie-icon"></span>
|
||||
</div>
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-small is-bold">Segment filesize</div>
|
||||
<div class="ts-text is-description">{{ if .Filesize }} {{ .Filesize }} {{ if .MaxFilesize }} / {{ .MaxFilesize }} {{ end }} {{ else }} - {{ end }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Info: Segment filesize -->
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ts-grid is-2-columns has-top-spaced-large">
|
||||
<div class="column">
|
||||
{{ if .IsPaused }}
|
||||
<form>
|
||||
<button class="ts-button is-start-icon is-fluid" hx-post="/resume_channel/{{ .Username }}" hx-swap="none">
|
||||
<span class="ts-icon is-play-icon"></span>
|
||||
Resume
|
||||
</button>
|
||||
</form>
|
||||
{{ else }}
|
||||
<form>
|
||||
<button type="submit" class="ts-button is-start-icon is-secondary is-fluid" hx-post="/pause_channel/{{ .Username }}" hx-swap="none">
|
||||
<span class="ts-icon is-pause-icon"></span>
|
||||
Pause
|
||||
</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="column">
|
||||
<form action="/stop_channel/{{ .Username }}" method="POST" onsubmit="return confirm('Are you sure you want to delete `{{ .Username }}` channel?')">
|
||||
<button class="ts-button is-start-icon is-outlined is-negative is-fluid" >
|
||||
<span class="ts-icon is-trash-icon"></span>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Actions -->
|
||||
{{ end }}
|
||||
299
router/view/templates/index.html
Normal file
299
router/view/templates/index.html
Normal file
@@ -0,0 +1,299 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="is-secondary">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocas/5.0.1/tocas.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<script src="/static/scripts/htmx.min.js" crossorigin="anonymous"></script>
|
||||
<script src="/static/scripts/sse.min.js" crossorigin="anonymous"></script>
|
||||
<title>Chaturbate DVR</title>
|
||||
</head>
|
||||
|
||||
<body hx-ext="sse">
|
||||
<!-- Main Section -->
|
||||
<div class="ts-container has-vertically-padded-big" style="--width: 990px">
|
||||
<!-- Header -->
|
||||
<div class="ts-grid is-bottom-aligned">
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-header is-huge is-uppercased is-heavy has-leading-small">Chaturbate DVR</div>
|
||||
<div class="ts-text is-description is-bold">VERSION {{ .Config.Version }}</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button class="ts-button is-start-icon is-outlined" data-dialog="settings-dialog">
|
||||
<span class="ts-icon is-gear-icon"></span>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button class="ts-button is-start-icon" data-dialog="create-dialog">
|
||||
<span class="ts-icon is-plus-icon"></span>
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Header -->
|
||||
|
||||
{{ if not .Channels }}
|
||||
<!-- Blankslate -->
|
||||
<div class="ts-divider has-vertically-spaced-large"></div>
|
||||
<div class="ts-blankslate">
|
||||
<span class="ts-icon is-eye-low-vision-icon"></span>
|
||||
<div class="header">No channels are currently recording</div>
|
||||
<div class="description">Add a new Chaturbate channel to start recording.</div>
|
||||
<div class="action">
|
||||
<button class="ts-button is-start-icon" data-dialog="create-dialog">
|
||||
<span class="ts-icon is-plus-icon"></span>
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Blankslate -->
|
||||
{{ else }}
|
||||
|
||||
<!-- Channels -->
|
||||
<div class="ts-wrap is-vertical has-top-spaced-large" sse-connect="/updates?stream=updates">
|
||||
{{ range .Channels }}
|
||||
<div class="ts-box is-horizontal">
|
||||
<!-- Info Section -->
|
||||
<div sse-swap="{{ .Username }}-info" class="ts-content is-padded has-break-all" style="width: 400px; line-height: 1.45; padding-right: 0">
|
||||
{{ template "channel_info" . }}
|
||||
</div>
|
||||
<!-- / Info Section -->
|
||||
|
||||
<!-- Log Section -->
|
||||
<div class="ts-content is-padded" style="flex: 1; gap: 0.8rem; display: flex; flex-direction: column">
|
||||
<div class="ts-input" style="flex: 1">
|
||||
<textarea class="has-full-height" readonly sse-swap="{{ .Username }}-log" style="scrollbar-width: thin">{{ range .Logs }}{{ . }}
{{ end }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="ts-switch is-small" style="display: flex">
|
||||
<input type="checkbox" checked />
|
||||
Auto-Update & Scroll Logs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Log Section -->
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<!-- / Channels -->
|
||||
{{ end }}
|
||||
</div>
|
||||
<!-- / Main Section -->
|
||||
|
||||
<!-- Settings Dialog -->
|
||||
<dialog id="settings-dialog" class="ts-modal" style="--width: 680px">
|
||||
<div class="content">
|
||||
<form action="/update_config" method="POST">
|
||||
<div class="ts-content is-horizontally-padded is-secondary">
|
||||
<div class="ts-grid">
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-header">Settings</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button type="reset" class="ts-close is-rounded is-large is-secondary" data-dialog="settings-dialog"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ts-divider"></div>
|
||||
|
||||
<div class="ts-content is-vertically-padded">
|
||||
<!-- Cookies -->
|
||||
<div class="ts-control is-wide">
|
||||
<div class="label">Cookies</div>
|
||||
<div class="content">
|
||||
<div class="ts-input">
|
||||
<textarea name="cookies" rows="5">{{ .Config.Cookies }}</textarea>
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">Use semicolons to separate multiple cookies, e.g., "key1=value1; key2=value2". See <a class="ts-text is-link" href="https://github.com/teacat/chaturbate-dvr/?tab=readme-ov-file#-cookies--user-agent" target="_blank">README</a> for details.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Cookies -->
|
||||
|
||||
<!-- User Agent -->
|
||||
<div class="ts-control is-wide has-top-spaced-large">
|
||||
<div class="label">User Agent</div>
|
||||
<div class="content">
|
||||
<div class="ts-input">
|
||||
<textarea name="user_agent" rows="5">{{ .Config.UserAgent }}</textarea>
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">User-Agent can be found using tools like <a class="ts-text is-link" href="https://www.whatismybrowser.com/detect/what-is-my-user-agent/" target="_blank">WhatIsMyBrowser</a>.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / User Agent -->
|
||||
</div>
|
||||
|
||||
<div class="ts-divider"></div>
|
||||
|
||||
<div class="ts-content is-secondary is-horizontally-padded">
|
||||
<div class="ts-grid is-middle-aligned">
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-text is-description">
|
||||
<span class="ts-icon is-triangle-exclamation-icon is-end-spaced"></span>
|
||||
Changes will be reverted after the program restarts
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button type="reset" class="ts-button is-outlined is-secondary" data-dialog="settings-dialog">Cancel</button>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button type="submit" class="ts-button is-primary">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<!-- / Settings Dialog -->
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<dialog id="create-dialog" class="ts-modal" style="--width: 680px">
|
||||
<div class="content">
|
||||
<form action="/create_channel" method="POST">
|
||||
<div class="ts-content is-horizontally-padded is-secondary">
|
||||
<div class="ts-grid">
|
||||
<div class="column is-fluid">
|
||||
<div class="ts-header">Add Channel</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button type="reset" class="ts-close is-rounded is-large is-secondary" data-dialog="create-dialog"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ts-divider"></div>
|
||||
|
||||
<div class="ts-content is-vertically-padded">
|
||||
<!-- Channel Username -->
|
||||
<div class="ts-control is-wide">
|
||||
<div class="label">Channel Username</div>
|
||||
<div class="content">
|
||||
<div class="ts-input is-start-labeled">
|
||||
<div class="label">{{ .Config.Domain }}</div>
|
||||
<input type="text" name="username" autofocus required />
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">Use commas to separate multiple channel names, e.g. "channel1, channel2, channel3".</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Channel Username -->
|
||||
|
||||
<!-- Resolution -->
|
||||
<div class="ts-control is-wide has-top-spaced-large">
|
||||
<div class="label">Resolution</div>
|
||||
<div class="content">
|
||||
<div class="ts-select">
|
||||
<select name="resolution">
|
||||
<option value="2160" {{ if eq .Config.Resolution 2160 }}selected{{ end }}>4K</option>
|
||||
<option value="1440" {{ if eq .Config.Resolution 1440 }}selected{{ end }}>2K</option>
|
||||
<option value="1080" {{ if eq .Config.Resolution 1080 }}selected{{ end }}>1080p</option>
|
||||
<option value="720" {{ if eq .Config.Resolution 720 }}selected{{ end }}>720p</option>
|
||||
<option value="540" {{ if eq .Config.Resolution 540 }}selected{{ end }}>540p</option>
|
||||
<option value="480" {{ if eq .Config.Resolution 480 }}selected{{ end }}>480p</option>
|
||||
<option value="240" {{ if eq .Config.Resolution 240 }}selected{{ end }}>240p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">The lower resolution will be used if the selected resolution is not available.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Resolution -->
|
||||
|
||||
<!-- Framerate -->
|
||||
<div class="ts-control is-wide has-top-spaced-large">
|
||||
<div class="label">Framerate</div>
|
||||
<div class="content is-padded">
|
||||
<div class="ts-wrap is-compact is-vertical">
|
||||
<label class="ts-radio">
|
||||
<input type="radio" name="framerate" value="60" {{ if eq .Config.Framerate 60 }}checked{{ end }} />
|
||||
60 FPS (or lower)
|
||||
</label>
|
||||
<label class="ts-radio">
|
||||
<input type="radio" name="framerate" value="30" {{ if eq .Config.Framerate 30 }}checked{{ end }} />
|
||||
30 FPS
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Framerate -->
|
||||
|
||||
<!-- Filename Pattern -->
|
||||
<div class="ts-control is-wide has-top-spaced-large">
|
||||
<div class="label">Filename Pattern</div>
|
||||
<div class="content">
|
||||
<div class="ts-input">
|
||||
<input type="text" name="pattern" value="{{ .Config.Pattern }}" />
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced-small">
|
||||
See the <a class="ts-text is-external-link is-link" href="https://github.com/teacat/chaturbate-dvr" target="_blank">README</a> for details.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Filename Pattern -->
|
||||
|
||||
<div class="ts-divider has-vertically-spaced-large"></div>
|
||||
|
||||
<!-- Splitting Options -->
|
||||
<div class="ts-control is-wide has-top-spaced">
|
||||
<div class="label">Splitting Options</div>
|
||||
<div class="content">
|
||||
<div class="ts-content is-padded is-secondary">
|
||||
<div class="ts-grid is-relaxed is-2-columns">
|
||||
<div class="column">
|
||||
<div class="ts-text is-bold">Max Filesize</div>
|
||||
<div class="ts-input is-end-labeled has-top-spaced-small">
|
||||
<input type="number" name="max_filesize" value="{{ .Config.MaxFilesize }}" />
|
||||
<span class="label">MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ts-text is-bold">Max Duration</div>
|
||||
<div class="ts-input is-end-labeled has-top-spaced-small">
|
||||
<input type="number" name="max_duration" value="{{ .Config.MaxDuration }}" />
|
||||
<span class="label">Min(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-text is-description has-top-spaced">Splitting will be disabled if both options are 0.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- / Splitting Options -->
|
||||
</div>
|
||||
|
||||
<div class="ts-divider"></div>
|
||||
|
||||
<div class="ts-content is-secondary is-horizontally-padded">
|
||||
<div class="ts-wrap is-end-aligned">
|
||||
<button type="reset" class="ts-button is-outlined is-secondary" data-dialog="create-dialog">Cancel</button>
|
||||
<button type="submit" class="ts-button is-primary">Add Channel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<!-- / Create Dialog -->
|
||||
|
||||
<script>
|
||||
// before content was swapped by HTMX
|
||||
document.body.addEventListener("htmx:sseBeforeMessage", function (e) {
|
||||
// stop it if "auto-update" was unchecked
|
||||
if (!e.detail.elt.closest(".ts-box").querySelector("[type=checkbox]").checked) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
// else scroll the textarea to bottom with async trick
|
||||
setTimeout(() => {
|
||||
let textarea = e.detail.elt.closest(".ts-box").querySelector("textarea")
|
||||
textarea.scrollTop = textarea.scrollHeight
|
||||
}, 0)
|
||||
})
|
||||
document.body.querySelectorAll("textarea").forEach((textarea) => {
|
||||
textarea.scrollTop = textarea.scrollHeight
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
router/view/templates/scripts/htmx.min.js
vendored
Normal file
1
router/view/templates/scripts/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
router/view/templates/scripts/sse.min.js
vendored
Normal file
1
router/view/templates/scripts/sse.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!function(){var e;function t(e){return new EventSource(e,{withCredentials:!0})}function n(t){if(e.getAttributeValue(t,"sse-swap")){var n=e.getClosestMatch(t,a);if(null==n)return null;for(var i=e.getInternalData(n),o=i.sseEventSource,g=e.getAttributeValue(t,"sse-swap").split(","),u=0;u<g.length;u++){let l=g[u].trim(),c=function(a){if(!r(n)){if(!e.bodyContains(t)){o.removeEventListener(l,c);return}e.triggerEvent(t,"htmx:sseBeforeMessage",a)&&(s(t,a.data),e.triggerEvent(t,"htmx:sseMessage",a))}};e.getInternalData(t).sseEventListener=c,o.addEventListener(l,c)}}if(e.getAttributeValue(t,"hx-trigger")){var n=e.getClosestMatch(t,a);if(null==n)return null;var i=e.getInternalData(n),o=i.sseEventSource;e.getTriggerSpecs(t).forEach(function(s){if("sse:"===s.trigger.slice(0,4)){var a=function(i){!r(n)&&(e.bodyContains(t)||o.removeEventListener(s.trigger.slice(4),a),htmx.trigger(t,s.trigger,i),htmx.trigger(t,"htmx:sseMessage",i))};e.getInternalData(t).sseEventListener=a,o.addEventListener(s.trigger.slice(4),a)}})}}function r(t){if(!e.bodyContains(t)){var n=e.getInternalData(t).sseEventSource;if(void 0!=n)return e.triggerEvent(t,"htmx:sseClose",{source:n,type:"nodeMissing"}),n.close(),!0}return!1}function s(t,n){e.withExtensions(t,function(e){n=e.transformResponse(n,null,t)});var r=e.getSwapSpecification(t),s=e.getTarget(t);e.swap(s,n,r)}function a(t){return null!=e.getInternalData(t).sseEventSource}htmx.defineExtension("sse",{init:function(n){e=n,void 0==htmx.createEventSource&&(htmx.createEventSource=t)},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(t,s){var a=s.target||s.detail.elt;switch(t){case"htmx:beforeCleanupElement":var i=e.getInternalData(a),o=i.sseEventSource;o&&(e.triggerEvent(a,"htmx:sseClose",{source:o,type:"nodeReplaced"}),i.sseEventSource.close());return;case"htmx:afterProcessNode":!function t(s,a){if(null==s)return null;if(e.getAttributeValue(s,"sse-connect")){var i,o,g,u,l,c=e.getAttributeValue(s,"sse-connect");if(null==c)return;i=s,o=c,g=void 0,u=htmx.createEventSource(o),u.onerror=function(n){if(e.triggerErrorEvent(i,"htmx:sseError",{error:n,source:u}),!r(i)&&u.readyState===EventSource.CLOSED){var s=500*(g=Math.max(Math.min(2*(g=g||0),128),1));window.setTimeout(function(){t(i,g)},s)}},u.onopen=function(t){if(e.triggerEvent(i,"htmx:sseOpen",{source:u}),g&&g>0){let r=i.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let s=0;s<r.length;s++)n(r[s]);g=0}},e.getInternalData(i).sseEventSource=u,l=e.getAttributeValue(i,"sse-close"),l&&u.addEventListener(l,function(){e.triggerEvent(i,"htmx:sseClose",{source:u,type:"message"}),u.close()})}n(s)}(a)}}})}();
|
||||
34
router/view/view.go
Normal file
34
router/view/view.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var FS embed.FS
|
||||
|
||||
// InfoTpl is a template for rendering channel information.
|
||||
var InfoTpl *template.Template
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
|
||||
InfoTpl, err = template.New("update").ParseFS(FS, "templates/channel_info.html")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse template: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// StaticFS initializes the static file system for serving frontend files.
|
||||
func StaticFS() (http.FileSystem, error) {
|
||||
frontendFS, err := fs.Sub(FS, "templates")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize static files: %w", err)
|
||||
}
|
||||
return http.FS(frontendFS), nil
|
||||
}
|
||||
Reference in New Issue
Block a user