This commit is contained in:
parent
9c78ea2d48
commit
7c684af8c3
79 changed files with 3594 additions and 3257 deletions
563
internal/admin/admin.go
Normal file
563
internal/admin/admin.go
Normal file
|
@ -0,0 +1,563 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/db"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
const (
|
||||
// Session store key
|
||||
sessionKey = "butterrobot-session"
|
||||
|
||||
// Template directory
|
||||
templateDir = "./internal/admin/templates"
|
||||
)
|
||||
|
||||
// FlashMessage represents a flash message
|
||||
type FlashMessage struct {
|
||||
Category string
|
||||
Message string
|
||||
}
|
||||
|
||||
// TemplateData holds data for rendering templates
|
||||
type TemplateData struct {
|
||||
User *model.User
|
||||
LoggedIn bool
|
||||
Title string
|
||||
Path string
|
||||
Flash []FlashMessage
|
||||
Plugins map[string]model.Plugin
|
||||
Channels []*model.Channel
|
||||
Channel *model.Channel
|
||||
ChannelPlugin *model.ChannelPlugin
|
||||
}
|
||||
|
||||
// Admin represents the admin interface
|
||||
type Admin struct {
|
||||
config *config.Config
|
||||
db *db.Database
|
||||
store *sessions.CookieStore
|
||||
templates map[string]*template.Template
|
||||
baseTemplate *template.Template
|
||||
}
|
||||
|
||||
// New creates a new Admin instance
|
||||
func New(cfg *config.Config, database *db.Database) *Admin {
|
||||
// Create session store
|
||||
store := sessions.NewCookieStore([]byte(cfg.SecretKey))
|
||||
|
||||
// Load templates
|
||||
templates := make(map[string]*template.Template)
|
||||
|
||||
// Create a template function map with helper functions
|
||||
funcMap := template.FuncMap{
|
||||
"contains": strings.Contains,
|
||||
}
|
||||
|
||||
// Create a custom template with functions
|
||||
baseTemplate := template.New("_base.html").Funcs(funcMap)
|
||||
baseTemplate = template.Must(baseTemplate.ParseFiles(filepath.Join(templateDir, "_base.html")))
|
||||
|
||||
// Parse and register all templates
|
||||
templateFiles := []string{
|
||||
"index.html",
|
||||
"login.html",
|
||||
"channel_list.html",
|
||||
"channel_detail.html",
|
||||
"plugin_list.html",
|
||||
"channel_plugins_list.html",
|
||||
}
|
||||
|
||||
for _, tf := range templateFiles {
|
||||
// Create a clone of the base template
|
||||
t, err := template.Must(baseTemplate.Clone()).ParseFiles(filepath.Join(templateDir, tf))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
templates[tf] = t
|
||||
}
|
||||
|
||||
return &Admin{
|
||||
config: cfg,
|
||||
db: database,
|
||||
store: store,
|
||||
templates: templates,
|
||||
baseTemplate: baseTemplate,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers admin routes on the given router
|
||||
func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Register admin routes
|
||||
mux.HandleFunc("/admin/", a.handleIndex)
|
||||
mux.HandleFunc("/admin/login", a.handleLogin)
|
||||
mux.HandleFunc("/admin/logout", a.handleLogout)
|
||||
mux.HandleFunc("/admin/plugins", a.handlePluginList)
|
||||
mux.HandleFunc("/admin/channels", a.handleChannelList)
|
||||
mux.HandleFunc("/admin/channels/", a.handleChannelDetail)
|
||||
mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList)
|
||||
mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete)
|
||||
}
|
||||
|
||||
// getCurrentUser gets the current user from the session
|
||||
func (a *Admin) getCurrentUser(r *http.Request) *model.User {
|
||||
session, _ := a.store.Get(r, sessionKey)
|
||||
|
||||
// Check if user is logged in
|
||||
userID, ok := session.Values["user_id"].(int64)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
user, err := a.db.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// isLoggedIn checks if the user is logged in
|
||||
func (a *Admin) isLoggedIn(r *http.Request) bool {
|
||||
session, _ := a.store.Get(r, sessionKey)
|
||||
return session.Values["logged_in"] == true
|
||||
}
|
||||
|
||||
// addFlash adds a flash message to the session
|
||||
func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, category string) {
|
||||
session, _ := a.store.Get(r, sessionKey)
|
||||
|
||||
// Add flash message
|
||||
flashes := session.Flashes()
|
||||
if flashes == nil {
|
||||
flashes = make([]interface{}, 0)
|
||||
}
|
||||
|
||||
flash := FlashMessage{
|
||||
Category: category,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
session.AddFlash(flash)
|
||||
session.Save(r, w)
|
||||
}
|
||||
|
||||
// getFlashes gets all flash messages from the session
|
||||
func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessage {
|
||||
session, _ := a.store.Get(r, sessionKey)
|
||||
|
||||
// Get flash messages
|
||||
flashes := session.Flashes()
|
||||
messages := make([]FlashMessage, 0, len(flashes))
|
||||
|
||||
for _, f := range flashes {
|
||||
if flash, ok := f.(FlashMessage); ok {
|
||||
messages = append(messages, flash)
|
||||
}
|
||||
}
|
||||
|
||||
// Save session to clear flashes
|
||||
session.Save(r, w)
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// requireLogin middleware checks if the user is logged in
|
||||
func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// render renders a template with the given data
|
||||
func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) {
|
||||
// Add current user data
|
||||
data.User = a.getCurrentUser(r)
|
||||
data.LoggedIn = a.isLoggedIn(r)
|
||||
data.Path = r.URL.Path
|
||||
data.Flash = a.getFlashes(w, r)
|
||||
|
||||
// Get template
|
||||
tmpl, ok := a.templates[templateName]
|
||||
if !ok {
|
||||
http.Error(w, "Template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Render template
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleIndex handles the admin index route
|
||||
func (a *Admin) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/admin/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to login if not logged in
|
||||
if !a.isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to channel list
|
||||
http.Redirect(w, r, "/admin/channels", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleLogin handles the login route
|
||||
func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// If already logged in, redirect to index
|
||||
if a.isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle login form submission
|
||||
if r.Method == http.MethodPost {
|
||||
// Parse form
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check credentials
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
user, err := a.db.CheckCredentials(username, password)
|
||||
if err != nil || user == nil {
|
||||
a.addFlash(w, r, "Incorrect credentials", "danger")
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Set session
|
||||
session, _ := a.store.Get(r, sessionKey)
|
||||
session.Values["logged_in"] = true
|
||||
session.Values["user_id"] = user.ID
|
||||
|
||||
// Set session expiration
|
||||
session.Options.MaxAge = 3600 * 24 * 7 // 1 week
|
||||
session.Save(r, w)
|
||||
|
||||
a.addFlash(w, r, "You were logged in", "success")
|
||||
|
||||
// Redirect to index
|
||||
next := r.URL.Query().Get("next")
|
||||
if next == "" {
|
||||
next = "/admin/"
|
||||
}
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Render login template
|
||||
a.render(w, r, "login.html", TemplateData{
|
||||
Title: "Login",
|
||||
})
|
||||
}
|
||||
|
||||
// handleLogout handles the logout route
|
||||
func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
// Clear session
|
||||
session, _ := a.store.Get(r, sessionKey)
|
||||
session.Values = make(map[interface{}]interface{})
|
||||
session.Options.MaxAge = -1 // Delete session
|
||||
session.Save(r, w)
|
||||
|
||||
a.addFlash(w, r, "You were logged out", "success")
|
||||
|
||||
// Redirect to login
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handlePluginList handles the plugin list route
|
||||
func (a *Admin) handlePluginList(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if user is logged in
|
||||
if !a.isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Get available plugins
|
||||
plugins := plugin.GetAvailablePlugins()
|
||||
|
||||
// Render template
|
||||
a.render(w, r, "plugin_list.html", TemplateData{
|
||||
Title: "Plugins",
|
||||
Plugins: plugins,
|
||||
})
|
||||
}
|
||||
|
||||
// handleChannelList handles the channel list route
|
||||
func (a *Admin) handleChannelList(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if user is logged in
|
||||
if !a.isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all channels
|
||||
channels, err := a.db.GetAllChannels()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get channels", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Render template
|
||||
a.render(w, r, "channel_list.html", TemplateData{
|
||||
Title: "Channels",
|
||||
Channels: channels,
|
||||
})
|
||||
}
|
||||
|
||||
// handleChannelDetail handles the channel detail route
|
||||
func (a *Admin) handleChannelDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if user is logged in
|
||||
if !a.isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract channel ID from path
|
||||
path := r.URL.Path
|
||||
if path == "/admin/channels/" {
|
||||
http.Redirect(w, r, "/admin/channels", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
channelID := strings.TrimPrefix(path, "/admin/channels/")
|
||||
if strings.Contains(channelID, "/") {
|
||||
// Handle delete request
|
||||
if strings.HasSuffix(path, "/delete") && r.Method == http.MethodPost {
|
||||
channelID = strings.TrimSuffix(channelID, "/delete")
|
||||
|
||||
// Delete channel
|
||||
id, err := strconv.ParseInt(channelID, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid channel ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.db.DeleteChannel(id); err != nil {
|
||||
http.Error(w, "Failed to delete channel", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.addFlash(w, r, "Channel removed", "success")
|
||||
http.Redirect(w, r, "/admin/channels", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert channel ID to int64
|
||||
id, err := strconv.ParseInt(channelID, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid channel ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
if r.Method == http.MethodPost {
|
||||
// Parse form
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the form was submitted
|
||||
if r.FormValue("form_submitted") == "true" {
|
||||
// Update channel
|
||||
enabled := r.FormValue("enabled") == "true"
|
||||
if err := a.db.UpdateChannel(id, enabled); err != nil {
|
||||
http.Error(w, "Failed to update channel", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.addFlash(w, r, "Channel updated", "success")
|
||||
http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get channel
|
||||
channel, err := a.db.GetChannelByID(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Channel not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get available plugins
|
||||
plugins := plugin.GetAvailablePlugins()
|
||||
|
||||
// Render template
|
||||
a.render(w, r, "channel_detail.html", TemplateData{
|
||||
Title: "Channel: " + channel.PlatformChannelID,
|
||||
Channel: channel,
|
||||
Plugins: plugins,
|
||||
})
|
||||
}
|
||||
|
||||
// handleChannelPluginList handles the channel plugin list route
|
||||
func (a *Admin) handleChannelPluginList(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if user is logged in
|
||||
if !a.isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
if r.Method == http.MethodPost {
|
||||
// Parse form
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract form data
|
||||
channelID, err := strconv.ParseInt(r.FormValue("channel_id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid channel ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pluginID := r.FormValue("plugin_id")
|
||||
enabled := r.FormValue("enabled") == "y"
|
||||
|
||||
// Create channel plugin
|
||||
config := make(map[string]interface{})
|
||||
_, err = a.db.CreateChannelPlugin(channelID, pluginID, enabled, config)
|
||||
if err == db.ErrDuplicated {
|
||||
a.addFlash(w, r, "Plugin "+pluginID+" is already present on the channel", "danger")
|
||||
} else if err != nil {
|
||||
http.Error(w, "Failed to create channel plugin", http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
a.addFlash(w, r, "Plugin "+pluginID+" added to the channel", "success")
|
||||
}
|
||||
|
||||
// Redirect back
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer == "" {
|
||||
referer = "/admin/channelplugins"
|
||||
}
|
||||
http.Redirect(w, r, referer, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all channels
|
||||
channels, err := a.db.GetAllChannels()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get channels", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Render template
|
||||
a.render(w, r, "channel_plugins_list.html", TemplateData{
|
||||
Title: "Channel Plugins",
|
||||
Channels: channels,
|
||||
Plugins: plugin.GetAvailablePlugins(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route
|
||||
func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if user is logged in
|
||||
if !a.isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract channel plugin ID from path
|
||||
path := r.URL.Path
|
||||
if path == "/admin/channelplugins/" {
|
||||
http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/")
|
||||
|
||||
// Handle delete request
|
||||
if strings.HasSuffix(channelPluginID, "/delete") && r.Method == http.MethodPost {
|
||||
channelPluginID = strings.TrimSuffix(channelPluginID, "/delete")
|
||||
|
||||
// Delete channel plugin
|
||||
id, err := strconv.ParseInt(channelPluginID, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.db.DeleteChannelPlugin(id); err != nil {
|
||||
http.Error(w, "Failed to delete channel plugin", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.addFlash(w, r, "Plugin removed", "success")
|
||||
|
||||
// Redirect back
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer == "" {
|
||||
referer = "/admin/channelplugins"
|
||||
}
|
||||
http.Redirect(w, r, referer, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle update request
|
||||
if r.Method == http.MethodPost {
|
||||
// Parse form
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert channel plugin ID to int64
|
||||
id, err := strconv.ParseInt(channelPluginID, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update channel plugin
|
||||
enabled := r.FormValue("enabled") == "true"
|
||||
if err := a.db.UpdateChannelPlugin(id, enabled); err != nil {
|
||||
http.Error(w, "Failed to update channel plugin", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.addFlash(w, r, "Plugin updated", "success")
|
||||
|
||||
// Redirect back
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer == "" {
|
||||
referer = "/admin/channelplugins"
|
||||
}
|
||||
http.Redirect(w, r, referer, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to channel plugins list
|
||||
http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther)
|
||||
}
|
123
internal/admin/templates/_base.html
Normal file
123
internal/admin/templates/_base.html
Normal file
|
@ -0,0 +1,123 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - ButterRobot Admin</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="sticky-top">
|
||||
<header class="navbar navbar-expand-md navbar-light sticky-top d-print-none">
|
||||
<div class="container-xl">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar-menu">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pr-0 pr-md-3">
|
||||
<a href="/admin/">
|
||||
<h1>ButterRobot Admin</h1>
|
||||
</a>
|
||||
</h1>
|
||||
<div class="navbar-nav flex-row order-md-last">
|
||||
<div class="nav-item">
|
||||
{{if not .LoggedIn}}
|
||||
<a href="/admin/login">Log in</a>
|
||||
{{else}}
|
||||
<div class="d-none d-xl-block pl-2">
|
||||
<div>{{.User.Username}} - <a class="mt-1 small"
|
||||
href="/admin/logout">Log out</a></div>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{{if .LoggedIn}}
|
||||
<div class="navbar-expand-md">
|
||||
<div class="collapse navbar-collapse" id="navbar-menu">
|
||||
<div class="navbar navbar-light">
|
||||
<div class="container-xl">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item {{if contains .Path "/channels"}}active{{end}}">
|
||||
<a class="nav-link" href="/admin/channels">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
|
||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<line x1="5" y1="9" x2="19" y2="9" />
|
||||
<line x1="5" y1="15" x2="19" y2="15" />
|
||||
<line x1="11" y1="4" x2="7" y2="20" />
|
||||
<line x1="17" y1="4" x2="13" y2="20" /></svg>
|
||||
</span>
|
||||
<span class="nav-link-title">
|
||||
Channels
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {{if contains .Path "/plugins"}}active{{end}}">
|
||||
<a class="nav-link" href="/admin/plugins">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
|
||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-link-title">
|
||||
Plugins
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {{if contains .Path "/channelplugins"}}active{{end}}">
|
||||
<a class="nav-link" href="/admin/channelplugins">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
|
||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-link-title">
|
||||
Channel Plugins
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{range .Flash}}
|
||||
<div class="card">
|
||||
<div class="card-status-top bg-{{.Category}}"></div>
|
||||
<div class="card-body">
|
||||
<p>{{.Message}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="content">
|
||||
<div class="container-xl">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
114
internal/admin/templates/channel_detail.html
Normal file
114
internal/admin/templates/channel_detail.html
Normal file
|
@ -0,0 +1,114 @@
|
|||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Channel #{{.Channel.ID}}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/channels/{{.Channel.ID}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Platform</label>
|
||||
<input type="text" class="form-control" value="{{.Channel.Platform}}" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Channel ID</label>
|
||||
<input type="text" class="form-control" value="{{.Channel.PlatformChannelID}}" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Channel Name</label>
|
||||
<input type="text" class="form-control" value="{{.Channel.ChannelName}}" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" value="true" {{if .Channel.Enabled}}checked{{end}}>
|
||||
<span class="form-check-label">Channel Enabled</span>
|
||||
</label>
|
||||
<!-- Add a hidden field to ensure a value is sent even when checkbox is unchecked -->
|
||||
<input type="hidden" name="form_submitted" value="true">
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<a href="/admin/channels" class="btn btn-link">Back to Channels</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Channel Plugins</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plugin</th>
|
||||
<th>Enabled</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $pluginID, $channelPlugin := .Channel.Plugins}}
|
||||
<tr>
|
||||
<td>{{$pluginID}}</td>
|
||||
<td>
|
||||
{{if $channelPlugin.Enabled}}
|
||||
<span class="badge bg-success">Enabled</span>
|
||||
{{else}}
|
||||
<span class="badge bg-danger">Disabled</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}" class="d-inline">
|
||||
<input type="hidden" name="enabled" value="{{if $channelPlugin.Enabled}}false{{else}}true{{end}}">
|
||||
<button type="submit" class="btn btn-sm {{if $channelPlugin.Enabled}}btn-danger{{else}}btn-success{{end}}">
|
||||
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center">No plugins for this channel</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Add Plugin</h4>
|
||||
<form method="post" action="/admin/channelplugins">
|
||||
<input type="hidden" name="channel_id" value="{{.Channel.ID}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Plugin</label>
|
||||
<select name="plugin_id" class="form-select" required>
|
||||
<option value="">Select a plugin</option>
|
||||
{{range $id, $plugin := .Plugins}}
|
||||
<option value="{{$id}}">{{$plugin.GetName}} ({{$id}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" value="y">
|
||||
<span class="form-check-label">Enable plugin</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">Add Plugin</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
64
internal/admin/templates/channel_list.html
Normal file
64
internal/admin/templates/channel_list.html
Normal file
|
@ -0,0 +1,64 @@
|
|||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Channels</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Platform</th>
|
||||
<th>Channel ID</th>
|
||||
<th>Name</th>
|
||||
<th>Enabled</th>
|
||||
<th>Plugins</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Channels}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Platform}}</td>
|
||||
<td>{{.PlatformChannelID}}</td>
|
||||
<td>{{.ChannelName}}</td>
|
||||
<td>
|
||||
{{if .Enabled}}
|
||||
<span class="badge bg-success">Enabled</span>
|
||||
{{else}}
|
||||
<span class="badge bg-danger">Disabled</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{$count := len .Plugins}}
|
||||
{{if eq $count 0}}
|
||||
<span class="badge bg-yellow">No plugins</span>
|
||||
{{else}}
|
||||
<span class="badge bg-blue">{{$count}} plugins</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/channels/{{.ID}}" class="btn btn-primary btn-sm">Edit</a>
|
||||
<form method="post" action="/admin/channels/{{.ID}}/delete" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('Are you sure you want to delete this channel?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center">No channels found</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
93
internal/admin/templates/channel_plugins_list.html
Normal file
93
internal/admin/templates/channel_plugins_list.html
Normal file
|
@ -0,0 +1,93 @@
|
|||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Channel Plugins</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Channel</th>
|
||||
<th>Plugin</th>
|
||||
<th>Enabled</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Channels}}
|
||||
{{range $pluginID, $channelPlugin := .Plugins}}
|
||||
<tr>
|
||||
<td>{{$channelPlugin.ID}}</td>
|
||||
<td><a href="/admin/channels/{{.ID}}">{{.ChannelName}}</a></td>
|
||||
<td>{{$pluginID}}</td>
|
||||
<td>
|
||||
{{if $channelPlugin.Enabled}}
|
||||
<span class="badge bg-success">Enabled</span>
|
||||
{{else}}
|
||||
<span class="badge bg-danger">Disabled</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}" class="d-inline">
|
||||
<input type="hidden" name="enabled" value="{{if $channelPlugin.Enabled}}false{{else}}true{{end}}">
|
||||
<button type="submit" class="btn btn-sm {{if $channelPlugin.Enabled}}btn-danger{{else}}btn-success{{end}}">
|
||||
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No channel plugins found</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Add Plugin to Channel</h4>
|
||||
<form method="post" action="/admin/channelplugins">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Channel</label>
|
||||
<select name="channel_id" class="form-select" required>
|
||||
<option value="">Select a channel</option>
|
||||
{{range .Channels}}
|
||||
<option value="{{.ID}}">{{.ChannelName}} ({{.Platform}}:{{.PlatformChannelID}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Plugin</label>
|
||||
<select name="plugin_id" class="form-select" required>
|
||||
<option value="">Select a plugin</option>
|
||||
{{range $id, $plugin := .Plugins}}
|
||||
<option value="{{$id}}">{{$plugin.GetName}} ({{$id}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" value="y">
|
||||
<span class="form-check-label">Enable plugin</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">Add Plugin</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
15
internal/admin/templates/index.html
Normal file
15
internal/admin/templates/index.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">ButterRobot Admin</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Welcome to the ButterRobot admin interface.</p>
|
||||
<p>Use the navigation above to manage channels and plugins.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
26
internal/admin/templates/login.html
Normal file
26
internal/admin/templates/login.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Login</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/login">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" placeholder="Enter username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" placeholder="Password" required>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
45
internal/admin/templates/plugin_list.html
Normal file
45
internal/admin/templates/plugin_list.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Available Plugins</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Help</th>
|
||||
<th>Requires Config</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $id, $plugin := .Plugins}}
|
||||
<tr>
|
||||
<td>{{$id}}</td>
|
||||
<td>{{$plugin.GetName}}</td>
|
||||
<td>{{$plugin.GetHelp}}</td>
|
||||
<td>
|
||||
{{if $plugin.RequiresConfig}}
|
||||
<span class="badge bg-yellow">Yes</span>
|
||||
{{else}}
|
||||
<span class="badge bg-green">No</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center">No plugins found</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
Loading…
Add table
Add a link
Reference in a new issue