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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue