Slug-based RSS URLs and success flash messages
Build and Deploy / build-and-deploy (push) Successful in 45s
Build and Deploy / build-and-deploy (push) Successful in 45s
- Add podcast_slug to users with global uniqueness check
- RSS available at /rss/{slug} (path-based) with legacy ?slug= fallback
- Slug field in dashboard settings with validation (lowercase, hyphens)
- JS flash messages for success/error after form submissions
- Remove unused imports
This commit is contained in:
+4
-3
@@ -43,6 +43,7 @@ func (h *EpisodeHandler) ServeDashboard(w http.ResponseWriter, r *http.Request)
|
|||||||
h.Tpl.Render(w, "dashboard", map[string]any{
|
h.Tpl.Render(w, "dashboard", map[string]any{
|
||||||
"Episodes": episodes,
|
"Episodes": episodes,
|
||||||
"PodcastTitle": title,
|
"PodcastTitle": title,
|
||||||
|
"PodcastSlug": user.PodcastSlug,
|
||||||
"PodcastImage": user.PodcastImage,
|
"PodcastImage": user.PodcastImage,
|
||||||
"PodcastAuthor": user.PodcastAuthor,
|
"PodcastAuthor": user.PodcastAuthor,
|
||||||
"BaseURL": h.BaseURL,
|
"BaseURL": h.BaseURL,
|
||||||
@@ -105,7 +106,7 @@ func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/?success=episode+created", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +182,7 @@ func (h *EpisodeHandler) ServeEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "update failed", http.StatusInternalServerError)
|
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/?success=episode+updated", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +209,7 @@ func (h *EpisodeHandler) ServeDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "delete failed", http.StatusInternalServerError)
|
http.Error(w, "delete failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/?success=episode+deleted", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *EpisodeHandler) ServeUpload(w http.ResponseWriter, r *http.Request) {
|
func (h *EpisodeHandler) ServeUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+17
-13
@@ -3,7 +3,6 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"podstalk/store"
|
"podstalk/store"
|
||||||
@@ -58,22 +57,27 @@ type rssEnclosure struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *RSSHandler) ServeRSS(w http.ResponseWriter, r *http.Request) {
|
func (h *RSSHandler) ServeRSS(w http.ResponseWriter, r *http.Request) {
|
||||||
// Resolve user: check query param, fall back to first user.
|
// Try path-based slug first: /rss/my-slug
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
if slug == "" {
|
||||||
|
slug = r.URL.Query().Get("slug")
|
||||||
|
}
|
||||||
|
if slug == "" {
|
||||||
|
slug = r.URL.Query().Get("user") // legacy
|
||||||
|
}
|
||||||
|
|
||||||
var user *store.User
|
var user *store.User
|
||||||
idStr := r.URL.Query().Get("user")
|
if slug != "" {
|
||||||
if idStr != "" {
|
user, _ = h.Store.UserBySlug(slug)
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
}
|
||||||
if err == nil {
|
if user == nil {
|
||||||
user, _ = h.Store.GetUser(id)
|
// legacy: try numeric user ID
|
||||||
|
if idStr := r.URL.Query().Get("user"); idStr != "" {
|
||||||
|
// already tried above, fall through
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
// Fall back to first user with any episodes
|
http.Error(w, "podcast not found", http.StatusNotFound)
|
||||||
// Simplest: just get user ID 1
|
|
||||||
user, _ = h.Store.GetUser(1)
|
|
||||||
}
|
|
||||||
if user == nil {
|
|
||||||
http.Error(w, "no podcast found", http.StatusNotFound)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-3
@@ -4,10 +4,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"podstalk/store"
|
"podstalk/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var slugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||||
|
|
||||||
type SettingsHandler struct {
|
type SettingsHandler struct {
|
||||||
Store *store.Store
|
Store *store.Store
|
||||||
UploadDir string
|
UploadDir string
|
||||||
@@ -36,6 +39,26 @@ func (h *SettingsHandler) ServeSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
title = user.PodcastTitle
|
title = user.PodcastTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slug := r.FormValue("podcast_slug")
|
||||||
|
if slug == "" {
|
||||||
|
slug = user.PodcastSlug
|
||||||
|
}
|
||||||
|
if slug != "" {
|
||||||
|
if !slugRe.MatchString(slug) {
|
||||||
|
http.Redirect(w, r, "/?error=slug+must+be+lowercase+letters+numbers+and+hyphens", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taken, err := h.Store.SlugTaken(slug, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/?error=internal+error", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
http.Redirect(w, r, "/?error=slug+already+taken", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
author := r.FormValue("podcast_author")
|
author := r.FormValue("podcast_author")
|
||||||
if author == "" {
|
if author == "" {
|
||||||
author = user.PodcastAuthor
|
author = user.PodcastAuthor
|
||||||
@@ -48,7 +71,6 @@ func (h *SettingsHandler) ServeSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Remove old image
|
|
||||||
if image != "" {
|
if image != "" {
|
||||||
os.Remove(filepath.Join(h.UploadDir, image))
|
os.Remove(filepath.Join(h.UploadDir, image))
|
||||||
}
|
}
|
||||||
@@ -68,6 +90,6 @@ func (h *SettingsHandler) ServeSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Store.UpdateUserPodcast(userID, title, image, author)
|
h.Store.UpdateUserPodcast(userID, title, slug, image, author)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/?success=podcast+settings+saved", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,9 @@ func main() {
|
|||||||
http.HandleFunc("/episodes/delete", authMid.RequireAuth(epH.ServeDelete))
|
http.HandleFunc("/episodes/delete", authMid.RequireAuth(epH.ServeDelete))
|
||||||
http.HandleFunc("/settings", authMid.RequireAuth(setH.ServeSettings))
|
http.HandleFunc("/settings", authMid.RequireAuth(setH.ServeSettings))
|
||||||
|
|
||||||
// Public RSS feed — per-user via ?user=N query param.
|
// Public RSS: /rss/my-slug or /rss?slug=my-slug or /rss?user=N (legacy)
|
||||||
http.HandleFunc("/rss", rssH.ServeRSS)
|
http.HandleFunc("/rss", rssH.ServeRSS)
|
||||||
|
http.HandleFunc("GET /rss/{slug}", rssH.ServeRSS)
|
||||||
|
|
||||||
// Public uploaded files.
|
// Public uploaded files.
|
||||||
http.HandleFunc("/uploads/", epH.ServeUpload)
|
http.HandleFunc("/uploads/", epH.ServeUpload)
|
||||||
|
|||||||
+31
-7
@@ -12,6 +12,7 @@ type User struct {
|
|||||||
Email string
|
Email string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
PodcastTitle string
|
PodcastTitle string
|
||||||
|
PodcastSlug string
|
||||||
PodcastImage string
|
PodcastImage string
|
||||||
PodcastAuthor string
|
PodcastAuthor string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -53,6 +54,7 @@ func migrate(db *sql.DB) error {
|
|||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
podcast_title TEXT NOT NULL DEFAULT '',
|
podcast_title TEXT NOT NULL DEFAULT '',
|
||||||
|
podcast_slug TEXT NOT NULL DEFAULT '',
|
||||||
podcast_image TEXT NOT NULL DEFAULT '',
|
podcast_image TEXT NOT NULL DEFAULT '',
|
||||||
podcast_author TEXT NOT NULL DEFAULT '',
|
podcast_author TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
@@ -95,8 +97,8 @@ func (s *Store) CreateUser(email, passwordHash string) (int64, error) {
|
|||||||
|
|
||||||
func (s *Store) UserByEmail(email string) (*User, error) {
|
func (s *Store) UserByEmail(email string) (*User, error) {
|
||||||
u := &User{}
|
u := &User{}
|
||||||
err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_image, podcast_author, created_at FROM users WHERE email = ?", email).
|
err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_slug, podcast_image, podcast_author, created_at FROM users WHERE email = ?", email).
|
||||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt)
|
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastSlug, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -105,17 +107,39 @@ func (s *Store) UserByEmail(email string) (*User, error) {
|
|||||||
|
|
||||||
func (s *Store) GetUser(id int64) (*User, error) {
|
func (s *Store) GetUser(id int64) (*User, error) {
|
||||||
u := &User{}
|
u := &User{}
|
||||||
err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_image, podcast_author, created_at FROM users WHERE id = ?", id).
|
err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_slug, podcast_image, podcast_author, created_at FROM users WHERE id = ?", id).
|
||||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt)
|
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastSlug, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUserPodcast(id int64, title, image, author string) error {
|
func (s *Store) UserBySlug(slug string) (*User, error) {
|
||||||
_, err := s.db.Exec("UPDATE users SET podcast_title=?, podcast_image=?, podcast_author=? WHERE id=?",
|
u := &User{}
|
||||||
title, image, author, id)
|
err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_slug, podcast_image, podcast_author, created_at FROM users WHERE podcast_slug = ?", slug).
|
||||||
|
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastSlug, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SlugTaken(slug string, excludeUserID int64) (bool, error) {
|
||||||
|
var id int64
|
||||||
|
err := s.db.QueryRow("SELECT id FROM users WHERE podcast_slug = ? AND id != ?", slug, excludeUserID).Scan(&id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateUserPodcast(id int64, title, slug, image, author string) error {
|
||||||
|
_, err := s.db.Exec("UPDATE users SET podcast_title=?, podcast_slug=?, podcast_image=?, podcast_author=? WHERE id=?",
|
||||||
|
title, slug, image, author, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-2
@@ -11,11 +11,12 @@
|
|||||||
.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; }
|
.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; }
|
||||||
.flash-error { background: #ffcdd2; }
|
.flash-error { background: #ffcdd2; }
|
||||||
.flash-success { background: #c8e6c9; }
|
.flash-success { background: #c8e6c9; }
|
||||||
|
.flash-info { background: #e3f2fd; }
|
||||||
.episode-actions { display: flex; gap: 0.5rem; }
|
.episode-actions { display: flex; gap: 0.5rem; }
|
||||||
.settings-section { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 2px solid #ddd; }
|
.settings-section { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 2px solid #ddd; }
|
||||||
.settings-form { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; }
|
.settings-form { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; }
|
||||||
.settings-form .form-group { margin-bottom: 0; }
|
.settings-form .form-group { margin-bottom: 0; }
|
||||||
.inline-form { display: flex; gap: 0.5rem; align-items: center; }
|
.hidden { display: none; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -31,6 +32,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div id="flash-error" class="flash flash-error hidden"></div>
|
||||||
|
<div id="flash-success" class="flash flash-success hidden"></div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2>Podcast Settings</h2>
|
<h2>Podcast Settings</h2>
|
||||||
<form method="post" action="/settings" enctype="multipart/form-data">
|
<form method="post" action="/settings" enctype="multipart/form-data">
|
||||||
@@ -40,6 +44,11 @@
|
|||||||
<input type="text" id="podcast_title" name="podcast_title" class="nb-input blue"
|
<input type="text" id="podcast_title" name="podcast_title" class="nb-input blue"
|
||||||
value="{{.PodcastTitle}}" />
|
value="{{.PodcastTitle}}" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="flex: 1; min-width: 160px;">
|
||||||
|
<label for="podcast_slug">URL Slug</label>
|
||||||
|
<input type="text" id="podcast_slug" name="podcast_slug" class="nb-input blue"
|
||||||
|
value="{{.PodcastSlug}}" placeholder="my-podcast" />
|
||||||
|
</div>
|
||||||
<div class="form-group" style="flex: 1; min-width: 200px;">
|
<div class="form-group" style="flex: 1; min-width: 200px;">
|
||||||
<label for="podcast_author">Author</label>
|
<label for="podcast_author">Author</label>
|
||||||
<input type="text" id="podcast_author" name="podcast_author" class="nb-input blue"
|
<input type="text" id="podcast_author" name="podcast_author" class="nb-input blue"
|
||||||
@@ -71,7 +80,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const rssURL = '{{.BaseURL}}/rss?user={{.UserID}}';
|
// Flash messages from query params
|
||||||
|
(function() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const err = params.get('error');
|
||||||
|
const ok = params.get('success');
|
||||||
|
if (err) {
|
||||||
|
const el = document.getElementById('flash-error');
|
||||||
|
el.textContent = err.replace(/\+/g, ' ').replace(/%20/g, ' ');
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
const el = document.getElementById('flash-success');
|
||||||
|
el.textContent = ok.replace(/\+/g, ' ').replace(/%20/g, ' ');
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
// Clean URL
|
||||||
|
if (err || ok) {
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const rssURL = '{{.BaseURL}}/rss/{{.PodcastSlug}}';
|
||||||
document.getElementById('rss-url').textContent = rssURL;
|
document.getElementById('rss-url').textContent = rssURL;
|
||||||
function copyRSS() {
|
function copyRSS() {
|
||||||
navigator.clipboard.writeText(rssURL).then(() => {
|
navigator.clipboard.writeText(rssURL).then(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user