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
245 lines
6 KiB
Go
245 lines
6 KiB
Go
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
|
|
}
|