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:
2026-02-03 15:51:53 -08:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 689dc5e531
commit 7d69596176
6 changed files with 709 additions and 0 deletions
+129
View File
@@ -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}}
+18
View File
@@ -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"
}
]
}