mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 21:55:42 +00:00
Add What's New admin console and changelog script integration (#5804)
- Add CRUD handlers for managing what's-new entries stored in S3 - Add whats_new.html template with add/edit/delete forms - Add navigation link to layout.html - Add player-friendly summary generation to generate_changelog.sh - Add seed JSON file for initial data The admin console at /whats-new allows managing changelog entries that will be displayed to players in the Unity client. The script integration generates summaries and opens the admin console with pre-populated content after sending weekly changelogs. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -373,6 +373,115 @@ send_email_fastmail() {
|
||||
echo "Email sent successfully"
|
||||
}
|
||||
|
||||
# Generate player-friendly "What's New" summary
|
||||
# This creates a short summary suitable for in-game display
|
||||
generate_whats_new_summary() {
|
||||
local pr_file="$1"
|
||||
local output_file="/tmp/eagle0_whats_new_$$.txt"
|
||||
|
||||
echo "Generating player-friendly What's New summary..." >&2
|
||||
|
||||
# Create a prompt for player-facing summary
|
||||
local prompt_file="/tmp/eagle0_whats_new_prompt_$$.txt"
|
||||
|
||||
cat > "$prompt_file" <<'WHATS_NEW_PROMPT'
|
||||
Based on these merged PRs, write a SHORT player-friendly summary.
|
||||
Focus only on changes players will notice. Ignore internal/technical changes.
|
||||
|
||||
Output format (exactly this format, no markdown, no extra text):
|
||||
TITLE: (5-10 words describing the main change)
|
||||
DESCRIPTION: (1-2 sentences, what players can now do differently)
|
||||
CATEGORY: (one of: feature, improvement, fix, content)
|
||||
|
||||
If there are multiple notable player-visible changes, pick the single most important one.
|
||||
If there are no player-visible changes at all, output exactly: NONE
|
||||
|
||||
Examples of good output:
|
||||
TITLE: Bug Reporting
|
||||
DESCRIPTION: You can now report bugs directly from the Settings menu.
|
||||
CATEGORY: feature
|
||||
|
||||
TITLE: Faster Reconnection
|
||||
DESCRIPTION: The game now reconnects more smoothly after network interruptions.
|
||||
CATEGORY: improvement
|
||||
|
||||
Here are the merged PRs:
|
||||
|
||||
WHATS_NEW_PROMPT
|
||||
|
||||
cat "$pr_file" >> "$prompt_file"
|
||||
|
||||
# Use Claude CLI to generate the summary
|
||||
if ! command -v claude &> /dev/null; then
|
||||
echo "NONE"
|
||||
rm -f "$prompt_file"
|
||||
return
|
||||
fi
|
||||
|
||||
cat "$prompt_file" | claude --print > "$output_file" 2>/dev/null
|
||||
|
||||
rm -f "$prompt_file"
|
||||
|
||||
# Check if the output is NONE
|
||||
if grep -q "^NONE$" "$output_file"; then
|
||||
echo "NONE"
|
||||
rm -f "$output_file"
|
||||
return
|
||||
fi
|
||||
|
||||
# Return the output
|
||||
cat "$output_file"
|
||||
rm -f "$output_file"
|
||||
}
|
||||
|
||||
# URL encode a string for use in query parameters
|
||||
urlencode() {
|
||||
local string="$1"
|
||||
local strlen=${#string}
|
||||
local encoded=""
|
||||
local pos c o
|
||||
|
||||
for (( pos=0 ; pos<strlen ; pos++ )); do
|
||||
c=${string:$pos:1}
|
||||
case "$c" in
|
||||
[-_.~a-zA-Z0-9] ) o="${c}" ;;
|
||||
* ) printf -v o '%%%02x' "'$c"
|
||||
esac
|
||||
encoded+="${o}"
|
||||
done
|
||||
echo "${encoded}"
|
||||
}
|
||||
|
||||
# Open the admin console with pre-populated what's new content
|
||||
open_whats_new_preview() {
|
||||
local title="$1"
|
||||
local description="$2"
|
||||
local category="$3"
|
||||
|
||||
local encoded_title=$(urlencode "$title")
|
||||
local encoded_desc=$(urlencode "$description")
|
||||
local encoded_cat=$(urlencode "$category")
|
||||
|
||||
local preview_url="https://admin.eagle0.net/whats-new?preview=true&title=${encoded_title}&description=${encoded_desc}&category=${encoded_cat}"
|
||||
|
||||
echo ""
|
||||
echo "=== What's New Preview ==="
|
||||
echo "Title: $title"
|
||||
echo "Description: $description"
|
||||
echo "Category: $category"
|
||||
echo ""
|
||||
echo "Opening admin console preview..."
|
||||
|
||||
# Open the URL in the default browser
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
open "$preview_url"
|
||||
elif command -v xdg-open &> /dev/null; then
|
||||
xdg-open "$preview_url"
|
||||
else
|
||||
echo "Preview URL: $preview_url"
|
||||
fi
|
||||
}
|
||||
|
||||
# Update the tag to mark this run
|
||||
update_tag() {
|
||||
echo "Updating $TAG_NAME tag..."
|
||||
@@ -462,6 +571,26 @@ main() {
|
||||
|
||||
# Update tag for next run
|
||||
update_tag
|
||||
|
||||
# Generate player-friendly What's New summary and open admin preview
|
||||
echo ""
|
||||
echo "Generating What's New summary for in-game display..."
|
||||
whats_new_output=$(generate_whats_new_summary "$pr_file")
|
||||
|
||||
if [[ "$whats_new_output" != "NONE" ]]; then
|
||||
# Parse the output
|
||||
title=$(echo "$whats_new_output" | grep "^TITLE:" | sed 's/^TITLE:[[:space:]]*//')
|
||||
description=$(echo "$whats_new_output" | grep "^DESCRIPTION:" | sed 's/^DESCRIPTION:[[:space:]]*//')
|
||||
category=$(echo "$whats_new_output" | grep "^CATEGORY:" | sed 's/^CATEGORY:[[:space:]]*//')
|
||||
|
||||
if [[ -n "$title" && -n "$description" ]]; then
|
||||
open_whats_new_preview "$title" "$description" "$category"
|
||||
else
|
||||
echo "Could not parse What's New output, skipping preview"
|
||||
fi
|
||||
else
|
||||
echo "No player-visible changes detected, skipping What's New preview"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -10,6 +10,7 @@ go_library(
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/admin_server",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = [
|
||||
"//src/main/go/net/eagle0/util/aws",
|
||||
"//src/main/protobuf/net/eagle0/eagle/admin:game_admin_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:api_go_proto", # keep
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/admin:admin_go_proto",
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
adminpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/admin"
|
||||
authpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
|
||||
gameadminpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/admin"
|
||||
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -80,6 +81,7 @@ var (
|
||||
loginTemplate *template.Template
|
||||
myAccountTemplate *template.Template
|
||||
goodbyeTemplate *template.Template
|
||||
whatsNewTemplate *template.Template
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -162,6 +164,11 @@ func main() {
|
||||
log.Fatalf("Failed to parse goodbye template: %v", err)
|
||||
}
|
||||
|
||||
whatsNewTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/whats_new.html")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse whats_new template: %v", err)
|
||||
}
|
||||
|
||||
// Log configuration at startup
|
||||
log.Printf("Configuration: eagle-addr=%s, auth-addr=%s, auth-tls=%v", *eagleAddr, *authAddr, *authTLS)
|
||||
|
||||
@@ -223,6 +230,8 @@ func main() {
|
||||
http.HandleFunc("/users/", requireAuth(handleUserRoutes))
|
||||
http.HandleFunc("/invitations", requireAuth(handleInvitationsPage))
|
||||
http.HandleFunc("/invitations/", requireAuth(handleInvitationRoutes))
|
||||
http.HandleFunc("/whats-new", requireAuth(handleWhatsNewPage))
|
||||
http.HandleFunc("/whats-new/", requireAuth(handleWhatsNewRoutes))
|
||||
http.HandleFunc("/login", handleLoginPage)
|
||||
http.HandleFunc("/login/start", handleLoginStart)
|
||||
http.HandleFunc("/login/complete", handleLoginComplete)
|
||||
@@ -3082,3 +3091,336 @@ func handleGoodbyePage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== What's New Management ====================
|
||||
|
||||
const whatsNewBucket = "eagle0-assets"
|
||||
const whatsNewKey = "whats-new.json"
|
||||
|
||||
// WhatsNewEntry represents a single changelog entry
|
||||
type WhatsNewEntry struct {
|
||||
ID string `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
// WhatsNewData represents the JSON structure stored in S3
|
||||
type WhatsNewData struct {
|
||||
Entries []WhatsNewEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// WhatsNewPageData is the data for the what's-new page template
|
||||
type WhatsNewPageData struct {
|
||||
Title string
|
||||
Entries []WhatsNewEntry
|
||||
BuildInfo BuildInfo
|
||||
PreviewMode bool
|
||||
PreviewEntry WhatsNewEntry
|
||||
Error string
|
||||
}
|
||||
|
||||
// fetchWhatsNewData fetches the what's-new JSON from S3
|
||||
func fetchWhatsNewData() (*WhatsNewData, error) {
|
||||
bb, err := aws.NewBucketBasics()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create S3 client: %w", err)
|
||||
}
|
||||
|
||||
data, err := bb.FetchBytes(whatsNewBucket, whatsNewKey)
|
||||
if err != nil {
|
||||
// If file doesn't exist, return empty data
|
||||
return &WhatsNewData{Entries: []WhatsNewEntry{}}, nil
|
||||
}
|
||||
|
||||
var whatsNew WhatsNewData
|
||||
if err := json.Unmarshal(data, &whatsNew); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse what's-new JSON: %w", err)
|
||||
}
|
||||
|
||||
return &whatsNew, nil
|
||||
}
|
||||
|
||||
// saveWhatsNewData saves the what's-new JSON to S3
|
||||
func saveWhatsNewData(data *WhatsNewData) error {
|
||||
bb, err := aws.NewBucketBasics()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create S3 client: %w", err)
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal what's-new JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := bb.UploadBytesPublic(whatsNewBucket, whatsNewKey, jsonData); err != nil {
|
||||
return fmt.Errorf("failed to upload what's-new JSON: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateEntryID generates a unique ID for a new entry based on date
|
||||
func generateEntryID(date string, existingEntries []WhatsNewEntry) string {
|
||||
// Count existing entries for this date
|
||||
count := 1
|
||||
for _, entry := range existingEntries {
|
||||
if strings.HasPrefix(entry.ID, date) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s-%03d", date, count)
|
||||
}
|
||||
|
||||
// handleWhatsNewPage renders the main what's-new management page
|
||||
func handleWhatsNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
whatsNew, err := fetchWhatsNewData()
|
||||
|
||||
data := WhatsNewPageData{
|
||||
Title: "What's New",
|
||||
BuildInfo: getBuildInfo(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
} else {
|
||||
data.Entries = whatsNew.Entries
|
||||
}
|
||||
|
||||
// Check for preview mode (from changelog script)
|
||||
if r.URL.Query().Get("preview") == "true" {
|
||||
data.PreviewMode = true
|
||||
data.PreviewEntry = WhatsNewEntry{
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Title: r.URL.Query().Get("title"),
|
||||
Description: r.URL.Query().Get("description"),
|
||||
Category: r.URL.Query().Get("category"),
|
||||
}
|
||||
if data.PreviewEntry.Category == "" {
|
||||
data.PreviewEntry.Category = "feature"
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := whatsNewTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
|
||||
log.Printf("Template error: %v", err)
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWhatsNewRoutes handles sub-routes for what's-new management
|
||||
func handleWhatsNewRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/whats-new/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
switch parts[0] {
|
||||
case "add":
|
||||
handleWhatsNewAdd(w, r)
|
||||
case "edit":
|
||||
if len(parts) > 1 {
|
||||
handleWhatsNewEdit(w, r, parts[1])
|
||||
} else {
|
||||
http.Error(w, "Missing entry ID", http.StatusBadRequest)
|
||||
}
|
||||
case "delete":
|
||||
if len(parts) > 1 {
|
||||
handleWhatsNewDelete(w, r, parts[1])
|
||||
} else {
|
||||
http.Error(w, "Missing entry ID", http.StatusBadRequest)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWhatsNewAdd adds a new what's-new entry
|
||||
func handleWhatsNewAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
date := r.FormValue("date")
|
||||
title := r.FormValue("title")
|
||||
description := r.FormValue("description")
|
||||
category := r.FormValue("category")
|
||||
|
||||
if date == "" || title == "" || description == "" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `<div class="alert alert-error">Date, title, and description are required</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
if category == "" {
|
||||
category = "feature"
|
||||
}
|
||||
|
||||
whatsNew, err := fetchWhatsNewData()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Failed to fetch data: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
newEntry := WhatsNewEntry{
|
||||
ID: generateEntryID(date, whatsNew.Entries),
|
||||
Date: date,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Category: category,
|
||||
}
|
||||
|
||||
// Prepend new entry (newest first)
|
||||
whatsNew.Entries = append([]WhatsNewEntry{newEntry}, whatsNew.Entries...)
|
||||
|
||||
if err := saveWhatsNewData(whatsNew); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Failed to save: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Added what's-new entry: %s - %s", newEntry.ID, newEntry.Title)
|
||||
|
||||
// Redirect back to the main page
|
||||
w.Header().Set("HX-Redirect", "/whats-new")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, `<div class="alert alert-success">Entry added successfully</div>`)
|
||||
}
|
||||
|
||||
// handleWhatsNewEdit edits an existing what's-new entry
|
||||
func handleWhatsNewEdit(w http.ResponseWriter, r *http.Request, entryID string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
date := r.FormValue("date")
|
||||
title := r.FormValue("title")
|
||||
description := r.FormValue("description")
|
||||
category := r.FormValue("category")
|
||||
|
||||
if title == "" || description == "" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `<div class="alert alert-error">Title and description are required</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
whatsNew, err := fetchWhatsNewData()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Failed to fetch data: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, entry := range whatsNew.Entries {
|
||||
if entry.ID == entryID {
|
||||
whatsNew.Entries[i].Date = date
|
||||
whatsNew.Entries[i].Title = title
|
||||
whatsNew.Entries[i].Description = description
|
||||
whatsNew.Entries[i].Category = category
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Entry not found: %s</div>`, entryID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := saveWhatsNewData(whatsNew); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Failed to save: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Updated what's-new entry: %s - %s", entryID, title)
|
||||
|
||||
// Return updated row HTML for HTMX swap
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<tr id="entry-%s">
|
||||
<td>%s</td>
|
||||
<td><span class="category category-%s">%s</span></td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td class="actions">
|
||||
<button class="btn-small" onclick="editEntry('%s', '%s', '%s', '%s', '%s')">Edit</button>
|
||||
<button class="btn-small secondary" hx-post="/whats-new/delete/%s" hx-target="#entry-%s" hx-swap="outerHTML" hx-confirm="Delete this entry?">Delete</button>
|
||||
</td>
|
||||
</tr>`, entryID, date, category, category, title, description, entryID, date, escapeJS(title), escapeJS(description), category, entryID, entryID)
|
||||
}
|
||||
|
||||
// handleWhatsNewDelete deletes a what's-new entry
|
||||
func handleWhatsNewDelete(w http.ResponseWriter, r *http.Request, entryID string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
whatsNew, err := fetchWhatsNewData()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Failed to fetch data: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, entry := range whatsNew.Entries {
|
||||
if entry.ID == entryID {
|
||||
whatsNew.Entries = append(whatsNew.Entries[:i], whatsNew.Entries[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Entry not found: %s</div>`, entryID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := saveWhatsNewData(whatsNew); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Failed to save: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Deleted what's-new entry: %s", entryID)
|
||||
|
||||
// Return empty string to remove the row
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// escapeJS escapes a string for use in JavaScript
|
||||
func escapeJS(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "'", "\\'")
|
||||
s = strings.ReplaceAll(s, "\"", "\\\"")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<li><a href="/">Games</a></li>
|
||||
<li><a href="/accounts">Accounts</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><a href="/whats-new">What's New</a></li>
|
||||
<li id="health-status" hx-get="/health/status" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||
<span class="health-loading">Health: ...</span>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
{{define "content"}}
|
||||
<h1>What's New Management</h1>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<details {{if .PreviewMode}}open{{end}}>
|
||||
<summary><strong>Add New Entry</strong></summary>
|
||||
<form hx-post="/whats-new/add" hx-target="this" hx-swap="outerHTML" class="entry-form">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Date
|
||||
<input type="date" name="date" value="{{if .PreviewMode}}{{.PreviewEntry.Date}}{{end}}" required>
|
||||
</label>
|
||||
<label>
|
||||
Category
|
||||
<select name="category" required>
|
||||
<option value="feature" {{if eq .PreviewEntry.Category "feature"}}selected{{end}}>Feature</option>
|
||||
<option value="improvement" {{if eq .PreviewEntry.Category "improvement"}}selected{{end}}>Improvement</option>
|
||||
<option value="fix" {{if eq .PreviewEntry.Category "fix"}}selected{{end}}>Fix</option>
|
||||
<option value="content" {{if eq .PreviewEntry.Category "content"}}selected{{end}}>Content</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Title
|
||||
<input type="text" name="title" placeholder="Short headline (e.g., 'Bug Reporting')" value="{{.PreviewEntry.Title}}" required>
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea name="description" rows="3" placeholder="1-2 sentence player-friendly description" required>{{.PreviewEntry.Description}}</textarea>
|
||||
</label>
|
||||
<button type="submit">Add Entry</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<h2>Existing Entries</h2>
|
||||
<p class="entry-count">{{len .Entries}} entries | <a href="https://assets.eagle0.net/whats-new.json" target="_blank">View JSON</a></p>
|
||||
|
||||
<table class="whats-new-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Category</th>
|
||||
<th>Title</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="entries-body">
|
||||
{{range .Entries}}
|
||||
<tr id="entry-{{.ID}}">
|
||||
<td>{{.Date}}</td>
|
||||
<td><span class="category category-{{.Category}}">{{.Category}}</span></td>
|
||||
<td>{{.Title}}</td>
|
||||
<td>{{.Description}}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-small" onclick="editEntry('{{.ID}}', '{{.Date}}', '{{.Title}}', '{{.Description}}', '{{.Category}}')">Edit</button>
|
||||
<button class="btn-small secondary" hx-post="/whats-new/delete/{{.ID}}" hx-target="#entry-{{.ID}}" hx-swap="outerHTML" hx-confirm="Delete this entry?">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">No entries yet. Add your first one above!</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<dialog id="edit-dialog">
|
||||
<article>
|
||||
<h3>Edit Entry</h3>
|
||||
<form id="edit-form" hx-swap="outerHTML">
|
||||
<input type="hidden" id="edit-id" name="id">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Date
|
||||
<input type="date" id="edit-date" name="date" required>
|
||||
</label>
|
||||
<label>
|
||||
Category
|
||||
<select id="edit-category" name="category" required>
|
||||
<option value="feature">Feature</option>
|
||||
<option value="improvement">Improvement</option>
|
||||
<option value="fix">Fix</option>
|
||||
<option value="content">Content</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Title
|
||||
<input type="text" id="edit-title" name="title" required>
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea id="edit-description" name="description" rows="3" required></textarea>
|
||||
</label>
|
||||
<footer>
|
||||
<button type="button" class="secondary" onclick="closeEditDialog()">Cancel</button>
|
||||
<button type="submit">Save Changes</button>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.entry-form {
|
||||
background: var(--pico-card-background-color);
|
||||
padding: 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.whats-new-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.category {
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.category-feature {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.category-improvement {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.category-fix {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.category-content {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.entry-count {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--pico-muted-color);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
#edit-dialog article {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
#edit-dialog footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function editEntry(id, date, title, description, category) {
|
||||
document.getElementById('edit-id').value = id;
|
||||
document.getElementById('edit-date').value = date;
|
||||
document.getElementById('edit-title').value = title;
|
||||
document.getElementById('edit-description').value = description;
|
||||
document.getElementById('edit-category').value = category;
|
||||
|
||||
const form = document.getElementById('edit-form');
|
||||
form.setAttribute('hx-post', '/whats-new/edit/' + id);
|
||||
form.setAttribute('hx-target', '#entry-' + id);
|
||||
htmx.process(form);
|
||||
|
||||
document.getElementById('edit-dialog').showModal();
|
||||
}
|
||||
|
||||
function closeEditDialog() {
|
||||
document.getElementById('edit-dialog').close();
|
||||
}
|
||||
|
||||
// Close dialog after successful edit
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.pathInfo.requestPath.startsWith('/whats-new/edit/')) {
|
||||
if (evt.detail.successful) {
|
||||
closeEditDialog();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "2026-02-03-001",
|
||||
"date": "2026-02-03",
|
||||
"title": "Bug Reporting",
|
||||
"description": "You can now report bugs directly from the Settings menu.",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "2026-01-27-001",
|
||||
"date": "2026-01-27",
|
||||
"title": "Improved Reconnection",
|
||||
"description": "The game now reconnects more smoothly after network interruptions.",
|
||||
"category": "improvement"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user