bookmark search
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *sql.DB
|
||||||
|
|
||||||
|
// InitDB opens the sqlite file at path and ensures the schema exists.
|
||||||
|
// Call this once at startup before AddBookmark/RetrieveBookmarks are used.
|
||||||
|
func InitDB(path string) error {
|
||||||
|
var err error
|
||||||
|
db, err = sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL
|
||||||
|
);`
|
||||||
|
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
return fmt.Errorf("create schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bookmark struct {
|
||||||
|
ID uint64
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
Category string // kebab-case, e.g. "market-data-and-filling-dbs"
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddBookmark(b Bookmark) (id uint64, err error) {
|
||||||
|
res, err := db.Exec(
|
||||||
|
`INSERT INTO bookmarks (title, url, category) VALUES (?, ?, ?)`,
|
||||||
|
b.Title, b.URL, b.Category,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert bookmark: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("get inserted id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint64(lastID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RetrieveBookmarks() (marks []Bookmark, err error) {
|
||||||
|
rows, err := db.Query(`SELECT id, title, url, category FROM bookmarks ORDER BY id DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query bookmarks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var b Bookmark
|
||||||
|
if err := rows.Scan(&b.ID, &b.Title, &b.URL, &b.Category); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan bookmark: %w", err)
|
||||||
|
}
|
||||||
|
marks = append(marks, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("rows error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return marks, nil
|
||||||
|
}
|
||||||
@@ -7,7 +7,20 @@ require (
|
|||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
modernc.org/libc v1.75.7 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
|
modernc.org/sqlite v1.59.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||||
|
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||||
|
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"site/database"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed source
|
//go:embed source
|
||||||
@@ -22,12 +24,18 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
database.InitDB("data.db")
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /{$}", servePage("home", "templates/pages/index.html", sourceRoot, false))
|
mux.HandleFunc("GET /{$}", servePage("home", "templates/pages/index.html", sourceRoot, false))
|
||||||
mux.HandleFunc("GET /research", servePage("research", "templates/pages/research.html", sourceRoot, true))
|
mux.HandleFunc("GET /research", servePage("research", "templates/pages/research.html", sourceRoot, true))
|
||||||
mux.HandleFunc("GET /bookkeeping", servePage("bookkeeping", "templates/pages/bookkeeping.html", sourceRoot, true))
|
mux.HandleFunc("GET /bookkeeping", servePage("bookkeeping", "templates/pages/bookkeeping.html", sourceRoot, true))
|
||||||
mux.HandleFunc("GET /personal", servePage("personal", "templates/pages/interests.html", sourceRoot, true))
|
mux.HandleFunc("GET /personal", servePage("personal", "templates/pages/interests.html", sourceRoot, true))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /search", search(sourceRoot))
|
||||||
|
mux.HandleFunc("GET /api/bookmarks/search", bookmarksearch())
|
||||||
|
mux.HandleFunc("POST /api/bookmarks", bookmarkAdd())
|
||||||
|
|
||||||
staticRoot, err := fs.Sub(sourceFS, "source/static")
|
staticRoot, err := fs.Sub(sourceFS, "source/static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -70,3 +78,121 @@ func servePage(name string, templatefile string, sourcefile fs.FS, sub bool) htt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func search(sourcefile fs.FS) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := fs.ReadFile(sourcefile, "templates/pages/search.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Println("read error:", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultsTmpl = template.Must(template.New("results").Parse(`
|
||||||
|
{{if .}}
|
||||||
|
{{range .}}
|
||||||
|
<a class="result-card" href="{{.URL}}">
|
||||||
|
<p class="result-card__title">{{.Title}}</p>
|
||||||
|
<p class="result-card__meta">{{.CategoryLabel}}</p>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No bookmarks match yet.</p>
|
||||||
|
{{end}}
|
||||||
|
`))
|
||||||
|
|
||||||
|
var addTmpl = template.Must(template.New("results").Parse(`
|
||||||
|
<a class="result-card" href="{{.URL}}">
|
||||||
|
<p class="result-card__title">{{.Title}}</p>
|
||||||
|
<p class="result-card__meta">{{.CategoryLabel}}</p>
|
||||||
|
</a>
|
||||||
|
`))
|
||||||
|
|
||||||
|
// CategoryLabel gives the template a human-readable label without
|
||||||
|
// needing a helper func registered on the template itself.
|
||||||
|
type bookmarkView struct {
|
||||||
|
database.Bookmark
|
||||||
|
CategoryLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func bookmarksearch() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||||||
|
category := r.URL.Query().Get("category")
|
||||||
|
|
||||||
|
var matches []bookmarkView
|
||||||
|
|
||||||
|
bookmarks, err := database.RetrieveBookmarks()
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range bookmarks {
|
||||||
|
if category != "" && category != "all" && b.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q != "" && !strings.Contains(strings.ToLower(b.Title), q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, bookmarkView{
|
||||||
|
Bookmark: b,
|
||||||
|
CategoryLabel: strings.ReplaceAll(b.Category, "-", " "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := resultsTmpl.Execute(w, matches); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bookmarkAdd() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(r.FormValue("title"))
|
||||||
|
url := strings.TrimSpace(r.FormValue("url"))
|
||||||
|
category := r.FormValue("category")
|
||||||
|
|
||||||
|
if title == "" || url == "" || category == "" {
|
||||||
|
http.Error(w, "title, url and category are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b := database.Bookmark{
|
||||||
|
Title: title,
|
||||||
|
URL: url,
|
||||||
|
Category: category,
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := database.AddBookmark(database.Bookmark{
|
||||||
|
Title: title,
|
||||||
|
URL: url,
|
||||||
|
Category: category,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not save bookmark", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b = database.Bookmark{ID: id, Title: title, URL: url, Category: category}
|
||||||
|
|
||||||
|
view := bookmarkView{
|
||||||
|
Bookmark: b,
|
||||||
|
CategoryLabel: strings.ReplaceAll(b.Category, "-", " "),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := addTmpl.Execute(w, view); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Bookmark Search — samantha42.xyz</title>
|
||||||
|
|
||||||
|
<!-- Link your site's existing stylesheet here -->
|
||||||
|
<link rel="stylesheet" href="/static/output.css"/>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Minimal fallback styles, scoped to this page only.
|
||||||
|
Safe to delete once your stylesheet covers these classes. */
|
||||||
|
.search-page { max-width: 720px; margin: 0 auto; padding: 2rem 1rem; gap: 1rem;}
|
||||||
|
.search-page__title { margin-bottom: 1.25rem; }
|
||||||
|
|
||||||
|
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; }
|
||||||
|
.search-bar__input {
|
||||||
|
flex: 1; padding: 0.6rem 0.9rem; font-size: 1rem;
|
||||||
|
border: 1px solid #ccc; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.search-bar__button {
|
||||||
|
padding: 0.6rem 1.1rem; font-size: 1rem;
|
||||||
|
border: 1px solid #333; background: #333; color: #fff;
|
||||||
|
border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.search-bar__button:hover { opacity: 0.9; }
|
||||||
|
|
||||||
|
.add-form { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
|
||||||
|
.add-form input, .add-form select {
|
||||||
|
padding: 0.5rem 0.7rem; font-size: 0.95rem;
|
||||||
|
border: 1px solid #ccc; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.add-form input[name="title"] { flex: 2; min-width: 160px; }
|
||||||
|
.add-form input[name="url"] { flex: 2; min-width: 160px; }
|
||||||
|
|
||||||
|
.categories { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; }
|
||||||
|
.category {
|
||||||
|
padding: 0.4rem 0.85rem; font-size: 0.9rem;
|
||||||
|
border: 1px solid #ccc; border-radius: 999px;
|
||||||
|
background: #fff; cursor: pointer;
|
||||||
|
}
|
||||||
|
.category[aria-pressed="true"] { background: #333; color: #fff; border-color: #333; }
|
||||||
|
|
||||||
|
.results { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.result-card {
|
||||||
|
display: block; padding: 0.85rem 1rem; border: 1px solid #e2e2e2;
|
||||||
|
border-radius: 8px; text-decoration: none; color: inherit;
|
||||||
|
}
|
||||||
|
.result-card:hover { border-color: #333; }
|
||||||
|
.result-card__title { font-weight: 600; margin: 0 0 0.2rem; }
|
||||||
|
.result-card__meta { font-size: 0.85rem; color: #777; }
|
||||||
|
|
||||||
|
.empty-state { padding: 2rem 0; text-align: center; color: #777; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main class="search-page">
|
||||||
|
<section class="hero" id="about">
|
||||||
|
<p class="hero-eyebrow">bookmarked · crawled data</p>
|
||||||
|
<h1 >Personal web<br><span>Value self reliance</span>
|
||||||
|
</h1>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The form issues the request. Its hx-trigger fires on typing (debounced),
|
||||||
|
on submit, and on a custom "category-changed" event dispatched by the
|
||||||
|
category buttons below. hx-include picks up both the "q" input and the
|
||||||
|
hidden "category" input, since both live inside the form.
|
||||||
|
|
||||||
|
Server contract: GET /api/bookmarks/search?q=&category=
|
||||||
|
returns an HTML fragment — a list of .result-card elements (or the
|
||||||
|
.empty-state markup) — which htmx swaps into #results.
|
||||||
|
-->
|
||||||
|
<form
|
||||||
|
class="search-bar"
|
||||||
|
role="search"
|
||||||
|
id="searchForm"
|
||||||
|
hx-get="/api/bookmarks/search"
|
||||||
|
hx-trigger="input changed delay:400ms from:#searchInput, submit, category-changed"
|
||||||
|
hx-target="#results"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
id="searchInput"
|
||||||
|
class="search-bar__input"
|
||||||
|
placeholder="Search your bookmarks…"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="category" id="categoryInput" value="all">
|
||||||
|
<button type="submit" class="search-bar__button">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Server contract: POST /api/bookmarks
|
||||||
|
Body: title, url, category (form-encoded)
|
||||||
|
Returns: a single .result-card fragment, which htmx prepends into
|
||||||
|
#results. hx-on::after-request resets the form once the card lands.
|
||||||
|
-->
|
||||||
|
<form
|
||||||
|
class="add-form"
|
||||||
|
id="addForm"
|
||||||
|
hx-post="/api/bookmarks"
|
||||||
|
hx-target="#results"
|
||||||
|
hx-swap="afterbegin"
|
||||||
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||||
|
>
|
||||||
|
<input type="text" name="title" placeholder="Title" required>
|
||||||
|
<input type="url" name="url" placeholder="https://…" required>
|
||||||
|
<select name="category" required>
|
||||||
|
<option value="books-and-sources">Books and sources</option>
|
||||||
|
<option value="market-data-and-filling-dbs">Market data and filling DBs</option>
|
||||||
|
<option value="fashion">Fashion</option>
|
||||||
|
<option value="diy-wiki-and-vods">DIY wiki and VODs</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="search-bar__button">Add</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="categories" id="categories" role="group" aria-label="Filter by category">
|
||||||
|
<button type="button" class="category" data-category="all" aria-pressed="true">All</button>
|
||||||
|
<button type="button" class="category" data-category="books-and-sources">Books and sources</button>
|
||||||
|
<button type="button" class="category" data-category="market-data-and-filling-dbs">Market data and filling DBs</button>
|
||||||
|
<button type="button" class="category" data-category="fashion">Fashion</button>
|
||||||
|
<button type="button" class="category" data-category="diy-wiki-and-vods">DIY wiki and VODs</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Populated by the server response. On first load this is empty; add
|
||||||
|
hx-get="/api/bookmarks/search" hx-trigger="load" hx-target="#results"
|
||||||
|
to the <main> tag (or to this div) once you want it to load results
|
||||||
|
immediately instead of waiting for the first search.
|
||||||
|
-->
|
||||||
|
<div class="results" id="results"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Category buttons only manage local UI state (which pill is active) and
|
||||||
|
// then ask the form to re-run its htmx request. No fetch/JSON handling
|
||||||
|
// here — htmx + the server own that part now.
|
||||||
|
document.getElementById('categories').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.category');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.category').forEach(c => c.setAttribute('aria-pressed', 'false'));
|
||||||
|
btn.setAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
document.getElementById('categoryInput').value = btn.dataset.category;
|
||||||
|
htmx.trigger(document.getElementById('searchForm'), 'category-changed');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user