Initial lookbook implementation
Pinterest-style visual bookmarking app with: - URL metadata extraction (OG/Twitter meta, oEmbed fallback) - Image caching in Postgres with 480px thumbnails - Multi-tag filtering with Ctrl/Cmd for OR mode - Fuzzy tag suggestions and inline tag editing - Browser console auth() with first-use password setup - Brutalist UI with Commit Mono font and Pico CSS - Light/dark mode via browser preference
This commit is contained in:
commit
fc625fb9cf
486 changed files with 195373 additions and 0 deletions
167
internal/handlers/auth.go
Normal file
167
internal/handlers/auth.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.soup.land/soup/lookbook/internal/data/auth"
|
||||
"git.soup.land/soup/lookbook/internal/data/session"
|
||||
)
|
||||
|
||||
const sessionCookieName = "lookbook_session"
|
||||
const sessionDuration = 30 * 24 * time.Hour
|
||||
|
||||
type authRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type authStatus struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
}
|
||||
|
||||
func HandlePostAuthLogin(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
var req authRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
if req.Password == "" {
|
||||
http.Error(w, "Password required", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
existing, err := auth.QGet(r.Context(), rc.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get auth: %w", err)
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
if _, err := auth.QCreate(r.Context(), rc.DB, hash); err != nil {
|
||||
return fmt.Errorf("create password: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := bcrypt.CompareHashAndPassword(existing.PasswordHash, []byte(req.Password)); err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
token, err := newToken(32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(sessionDuration)
|
||||
if _, err := session.QCreate(r.Context(), rc.DB, token, expiresAt); err != nil {
|
||||
return fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
setSessionCookie(w, r, token, expiresAt)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func HandlePostAuthLogout(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err == nil {
|
||||
_ = session.QDelete(r.Context(), rc.DB, cookie.Value)
|
||||
}
|
||||
clearSessionCookie(w, r)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func HandleGetAuthStatus(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
status := authStatus{}
|
||||
|
||||
existing, err := auth.QGet(r.Context(), rc.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get auth: %w", err)
|
||||
}
|
||||
status.HasPassword = existing != nil
|
||||
|
||||
authed, err := isAuthenticated(r, rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth status: %w", err)
|
||||
}
|
||||
status.Authenticated = authed
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
func isAuthenticated(r *http.Request, rc *RequestContext) (bool, error) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
row, err := session.QFindByToken(r.Context(), rc.DB, cookie.Value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if row == nil {
|
||||
return false, nil
|
||||
}
|
||||
if time.Now().After(row.ExpiresAt) {
|
||||
_ = session.QDelete(r.Context(), rc.DB, cookie.Value)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func requireAuth(w http.ResponseWriter, r *http.Request, rc *RequestContext) bool {
|
||||
authed, err := isAuthenticated(r, rc)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
if !authed {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func newToken(length int) (string, error) {
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
15
internal/handlers/context.go
Normal file
15
internal/handlers/context.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
|
||||
"git.soup.land/soup/sxgo/ssr"
|
||||
)
|
||||
|
||||
// RequestContext holds dependencies that are injected into every request handler.
|
||||
type RequestContext struct {
|
||||
DB *sql.DB
|
||||
Logger *slog.Logger
|
||||
TmplCache *ssr.TmplCache
|
||||
}
|
||||
145
internal/handlers/gallery.go
Normal file
145
internal/handlers/gallery.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.soup.land/soup/lookbook/internal/components"
|
||||
"git.soup.land/soup/lookbook/internal/data/image"
|
||||
"git.soup.land/soup/lookbook/internal/data/item"
|
||||
"git.soup.land/soup/lookbook/internal/data/tag"
|
||||
"git.soup.land/soup/sxgo/ssr"
|
||||
)
|
||||
|
||||
type galleryPageData struct {
|
||||
Items []galleryItem
|
||||
Tags []tag.Row
|
||||
HasAuth bool
|
||||
TagNames []string
|
||||
}
|
||||
|
||||
type galleryItem struct {
|
||||
Item item.Row
|
||||
Thumb *image.Ref
|
||||
TagNames []string
|
||||
TagListText string
|
||||
}
|
||||
|
||||
func (g galleryPageData) Render(sw *ssr.Writer) error {
|
||||
return sw.Tmpl(g, `
|
||||
<section class="hero">
|
||||
<form method="POST" action="/items" data-auth-required {{if not .HasAuth}}hidden{{end}}>
|
||||
<input type="url" name="url" placeholder="Paste a link" required>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
<div class="notice" data-auth-status>Read only</div>
|
||||
</section>
|
||||
|
||||
<section class="filters">
|
||||
{{range .Tags}}
|
||||
<button type="button" class="filter-pill" data-tag-filter="{{.Name}}">{{.Name}}</button>
|
||||
{{end}}
|
||||
<div class="tag-suggestions" data-tag-suggestions data-tags='{{json .TagNames}}'></div>
|
||||
</section>
|
||||
|
||||
<section class="gallery">
|
||||
{{range .Items}}
|
||||
<div class="card" data-item-tags="{{.TagListText}}">
|
||||
<a href="/items/{{.Item.ID}}">
|
||||
{{if .Thumb}}
|
||||
<img src="/images/{{.Thumb.ID}}" alt="{{.Item.Title}}">
|
||||
{{else}}
|
||||
<div class="placeholder">{{if .Item.Title}}{{.Item.Title}}{{else}}{{.Item.SourceURL}}{{end}}</div>
|
||||
{{end}}
|
||||
<div class="overlay">
|
||||
<div class="title">{{if .Item.Title}}{{.Item.Title}}{{else}}{{.Item.SourceURL}}{{end}}</div>
|
||||
<div class="tags">{{range $i, $tag := .TagNames}}{{if $i}} · {{end}}{{$tag}}{{end}}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
`)
|
||||
}
|
||||
|
||||
func HandleGetGallery(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
items, err := item.QList(r.Context(), rc.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list items: %w", err)
|
||||
}
|
||||
|
||||
var itemIDs []int64
|
||||
for _, row := range items {
|
||||
itemIDs = append(itemIDs, row.ID)
|
||||
}
|
||||
|
||||
thumbs, err := image.QListPrimaryRefsByItems(r.Context(), rc.DB, itemIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list thumbs: %w", err)
|
||||
}
|
||||
|
||||
entries, err := tag.QListItemTags(r.Context(), rc.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list tags: %w", err)
|
||||
}
|
||||
|
||||
itemTagNames := make(map[int64][]string)
|
||||
for _, entry := range entries {
|
||||
itemTagNames[entry.ItemID] = append(itemTagNames[entry.ItemID], entry.Name)
|
||||
}
|
||||
|
||||
var cards []galleryItem
|
||||
for _, row := range items {
|
||||
tagNames := itemTagNames[row.ID]
|
||||
cards = append(cards, galleryItem{
|
||||
Item: row,
|
||||
Thumb: refOrNil(thumbs[row.ID]),
|
||||
TagNames: tagNames,
|
||||
TagListText: strings.Join(tagNames, ","),
|
||||
})
|
||||
}
|
||||
|
||||
tags, err := tag.QList(r.Context(), rc.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list tags: %w", err)
|
||||
}
|
||||
|
||||
authed, err := isAuthenticated(r, rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth status: %w", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
sw := ssr.NewWriter(w, rc.TmplCache)
|
||||
|
||||
var tagNames []string
|
||||
for _, row := range tags {
|
||||
tagNames = append(tagNames, row.Name)
|
||||
}
|
||||
|
||||
data := galleryPageData{
|
||||
Items: cards,
|
||||
Tags: tags,
|
||||
HasAuth: authed,
|
||||
TagNames: tagNames,
|
||||
}
|
||||
return components.Page{
|
||||
Title: "Gallery",
|
||||
Content: data,
|
||||
ShowNav: true,
|
||||
HasAuth: authed,
|
||||
}.Render(sw)
|
||||
}
|
||||
|
||||
func refOrNil(ref image.Ref) *image.Ref {
|
||||
if ref.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
return &ref
|
||||
}
|
||||
|
||||
func parseID(value string) (int64, error) {
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
49
internal/handlers/handler.go
Normal file
49
internal/handlers/handler.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Handler is a function that handles HTTP requests and can return an error.
|
||||
// It receives a RequestContext with injected dependencies (DB, Logger).
|
||||
type Handler func(rc *RequestContext, w http.ResponseWriter, r *http.Request) error
|
||||
|
||||
// WithErrorHandling wraps a Handler to automatically handle errors.
|
||||
func WithErrorHandling(rc *RequestContext, h Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapper := &responseWrapper{
|
||||
ResponseWriter: w,
|
||||
written: false,
|
||||
}
|
||||
|
||||
err := h(rc, wrapper, r)
|
||||
if err != nil {
|
||||
rc.Logger.Error("handler error",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
|
||||
if !wrapper.written {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// responseWrapper tracks if response was written
|
||||
type responseWrapper struct {
|
||||
http.ResponseWriter
|
||||
written bool
|
||||
}
|
||||
|
||||
func (w *responseWrapper) Write(b []byte) (int, error) {
|
||||
w.written = true
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (w *responseWrapper) WriteHeader(statusCode int) {
|
||||
w.written = true
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
32
internal/handlers/image.go
Normal file
32
internal/handlers/image.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.soup.land/soup/lookbook/internal/data/image"
|
||||
)
|
||||
|
||||
func HandleGetImage(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
row, err := image.QFindByID(r.Context(), rc.DB, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find image: %w", err)
|
||||
}
|
||||
if row == nil {
|
||||
http.NotFound(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
if row.ContentType != "" {
|
||||
w.Header().Set("Content-Type", row.ContentType)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||||
_, _ = w.Write(row.Bytes)
|
||||
return nil
|
||||
}
|
||||
245
internal/handlers/item.go
Normal file
245
internal/handlers/item.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.soup.land/soup/lookbook/internal/components"
|
||||
"git.soup.land/soup/lookbook/internal/data/image"
|
||||
"git.soup.land/soup/lookbook/internal/data/item"
|
||||
"git.soup.land/soup/lookbook/internal/data/tag"
|
||||
"git.soup.land/soup/lookbook/internal/services"
|
||||
"git.soup.land/soup/sxgo/ssr"
|
||||
)
|
||||
|
||||
type itemPageData struct {
|
||||
Item item.Row
|
||||
Images []image.Row
|
||||
TagNames []string
|
||||
AllTagNames []string
|
||||
HasAuth bool
|
||||
}
|
||||
|
||||
func (d itemPageData) Render(sw *ssr.Writer) error {
|
||||
return sw.Tmpl(d, `
|
||||
<section class="detail">
|
||||
<div>
|
||||
{{if .Images}}
|
||||
<img src="/images/{{(index .Images 0).ID}}" alt="{{.Item.Title}}">
|
||||
{{else}}
|
||||
<div class="notice">No preview image cached.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<h1>{{if .Item.Title}}{{.Item.Title}}{{else}}{{.Item.SourceURL}}{{end}}</h1>
|
||||
{{if .Item.SiteName}}<div>{{.Item.SiteName}}</div>{{end}}
|
||||
{{if .Item.Description}}<p>{{.Item.Description}}</p>{{end}}
|
||||
<a href="{{.Item.SourceURL}}" target="_blank" rel="noreferrer">{{.Item.SourceURL}}</a>
|
||||
|
||||
<div>
|
||||
<strong>Tags</strong>
|
||||
<div class="tag-list" data-tag-list>
|
||||
{{range .TagNames}}
|
||||
<span class="tag-chip">{{.}}</span>
|
||||
{{else}}
|
||||
<span class="tag-chip">No tags</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<button type="button" class="ghost" data-tag-toggle data-auth-required {{if not .HasAuth}}hidden{{end}}>Edit tags</button>
|
||||
</div>
|
||||
|
||||
<div class="tag-editor" data-tag-editor data-auth-required hidden {{if not .HasAuth}}hidden{{end}}>
|
||||
<form method="POST" action="/items/{{.Item.ID}}/tags" data-tag-form>
|
||||
<input type="text" name="tags" value="{{range $i, $tag := .TagNames}}{{if $i}}, {{end}}{{$tag}}{{end}}" data-tag-input>
|
||||
<button type="submit">Save tags</button>
|
||||
</form>
|
||||
<div class="tag-suggestions" data-tag-suggestions data-tags='{{json .AllTagNames}}'></div>
|
||||
</div>
|
||||
|
||||
<div class="actions" data-auth-required {{if not .HasAuth}}hidden{{end}}>
|
||||
<form method="POST" action="/items/{{.Item.ID}}/refresh">
|
||||
<button type="submit">Refresh metadata</button>
|
||||
</form>
|
||||
<form method="POST" action="/items/{{.Item.ID}}/delete">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`)
|
||||
}
|
||||
|
||||
func HandleGetItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
row, err := item.QFindByID(r.Context(), rc.DB, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find item: %w", err)
|
||||
}
|
||||
if row == nil {
|
||||
http.NotFound(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
images, err := image.QListByItem(r.Context(), rc.DB, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list images: %w", err)
|
||||
}
|
||||
|
||||
tags, err := tag.QListByItem(r.Context(), rc.DB, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list item tags: %w", err)
|
||||
}
|
||||
|
||||
allTags, err := tag.QList(r.Context(), rc.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list tags: %w", err)
|
||||
}
|
||||
|
||||
var tagNames []string
|
||||
for _, row := range tags {
|
||||
tagNames = append(tagNames, row.Name)
|
||||
}
|
||||
|
||||
var allTagNames []string
|
||||
for _, row := range allTags {
|
||||
allTagNames = append(allTagNames, row.Name)
|
||||
}
|
||||
|
||||
authed, err := isAuthenticated(r, rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth status: %w", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
sw := ssr.NewWriter(w, rc.TmplCache)
|
||||
|
||||
data := itemPageData{
|
||||
Item: *row,
|
||||
Images: images,
|
||||
TagNames: tagNames,
|
||||
AllTagNames: allTagNames,
|
||||
HasAuth: authed,
|
||||
}
|
||||
|
||||
return components.Page{
|
||||
Title: "Item",
|
||||
Content: data,
|
||||
ShowNav: true,
|
||||
HasAuth: authed,
|
||||
}.Render(sw)
|
||||
}
|
||||
|
||||
func HandlePostItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
if !requireAuth(w, r, rc) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
sourceURL := strings.TrimSpace(r.FormValue("url"))
|
||||
if sourceURL == "" {
|
||||
http.Error(w, "URL required", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := services.CreateItemFromURL(r.Context(), rc.DB, sourceURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create item: %w", err)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
func HandlePostItemTags(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
if !requireAuth(w, r, rc) {
|
||||
return nil
|
||||
}
|
||||
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := parseTags(r.FormValue("tags"))
|
||||
if err := tag.QReplaceItemTags(r.Context(), rc.DB, id, tags); err != nil {
|
||||
return fmt.Errorf("update tags: %w", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func HandleDeleteItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
if !requireAuth(w, r, rc) {
|
||||
return nil
|
||||
}
|
||||
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := item.QSoftDelete(r.Context(), rc.DB, id); err != nil {
|
||||
return fmt.Errorf("delete item: %w", err)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
func HandleRefreshItem(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
|
||||
if !requireAuth(w, r, rc) {
|
||||
return nil
|
||||
}
|
||||
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
row, err := item.QFindByID(r.Context(), rc.DB, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find item: %w", err)
|
||||
}
|
||||
if row == nil {
|
||||
http.NotFound(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := services.RefreshItemFromURL(r.Context(), rc.DB, *row); err != nil {
|
||||
return fmt.Errorf("refresh item: %w", err)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/items/%d", id), http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTags(value string) []string {
|
||||
var tags []string
|
||||
for _, tagValue := range strings.Split(value, ",") {
|
||||
clean := strings.TrimSpace(tagValue)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
tags = append(tags, clean)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
42
internal/handlers/router.go
Normal file
42
internal/handlers/router.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Router wraps http.ServeMux and automatically injects RequestContext into handlers.
|
||||
type Router struct {
|
||||
mux *http.ServeMux
|
||||
rc *RequestContext
|
||||
}
|
||||
|
||||
func NewRouter(rc *RequestContext) *Router {
|
||||
return &Router{
|
||||
mux: http.NewServeMux(),
|
||||
rc: rc,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle registers a handler that returns an error.
|
||||
// The RequestContext is automatically injected and error handling is applied.
|
||||
func (rt *Router) Handle(pattern string, h Handler) {
|
||||
rt.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
|
||||
rc := &RequestContext{
|
||||
DB: rt.rc.DB,
|
||||
Logger: rt.rc.Logger,
|
||||
TmplCache: rt.rc.TmplCache,
|
||||
}
|
||||
|
||||
handler := WithErrorHandling(rc, h)
|
||||
handler(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// HandleStd registers a standard http.Handler (for static files, etc.)
|
||||
func (rt *Router) HandleStd(pattern string, h http.Handler) {
|
||||
rt.mux.Handle(pattern, h)
|
||||
}
|
||||
|
||||
func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rt.mux.ServeHTTP(w, r)
|
||||
}
|
||||
23
internal/handlers/templates.go
Normal file
23
internal/handlers/templates.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"git.soup.land/soup/lookbook/internal/static"
|
||||
)
|
||||
|
||||
var TemplateFuncs = template.FuncMap{
|
||||
"staticURL": static.VersionedPath,
|
||||
"json": jsonTemplate,
|
||||
}
|
||||
|
||||
func jsonTemplate(v any) template.JS {
|
||||
payload, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return template.JS("null")
|
||||
}
|
||||
safe := strings.ReplaceAll(string(payload), "</", "<\\/")
|
||||
return template.JS(safe)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue