Move scrape form to dedicated /scrape page, add '+ Add' link in home header
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

- Extract scrape form and autocomplete JS from homeTmpl into new scrapeTmpl
- Add handleScrape GET handler serving /scrape with ranking autocomplete
- Register GET /scrape route in server.go
- Replace inline scrape form on home page with '+ Add' flat button in header
- handleHome no longer loads ranking items (only needed on /scrape)
This commit is contained in:
Admin
2026-03-01 22:26:50 +05:00
parent 7397085ecb
commit fb5d22eb8d
2 changed files with 243 additions and 230 deletions

View File

@@ -70,6 +70,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook) mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
// UI routes // UI routes
mux.HandleFunc("GET /", s.handleHome) mux.HandleFunc("GET /", s.handleHome)
mux.HandleFunc("GET /scrape", s.handleScrape)
mux.HandleFunc("GET /ranking", s.handleRanking) mux.HandleFunc("GET /ranking", s.handleRanking)
mux.HandleFunc("POST /ranking/refresh", s.handleRankingRefresh) mux.HandleFunc("POST /ranking/refresh", s.handleRankingRefresh)
mux.HandleFunc("GET /ranking/view", s.handleRankingView) mux.HandleFunc("GET /ranking/view", s.handleRankingView)

View File

@@ -123,8 +123,11 @@ const homeTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10" hx-history="false"> <div class="max-w-4xl mx-auto px-4 py-10" hx-history="false">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h1 class="text-3xl font-bold text-zinc-100">libnovel</h1> <h1 class="text-3xl font-bold text-zinc-100">libnovel</h1>
<div class="flex items-center gap-1">
<a href="/scrape" hx-get="/scrape" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">+ Add</a>
<a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg text-amber-400 hover:text-amber-300 transition-colors">Browse Rankings</a> <a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg text-amber-400 hover:text-amber-300 transition-colors">Browse Rankings</a>
</div> </div>
</div>
<p class="text-zinc-400 mb-2">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p> <p class="text-zinc-400 mb-2">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
<!-- Filter bar --> <!-- Filter bar -->
@@ -135,36 +138,6 @@ const homeTmpl = `
<p id="filter-count" class="text-xs text-zinc-500 mt-1 hidden"></p> <p id="filter-count" class="text-xs text-zinc-500 mt-1 hidden"></p>
</div> </div>
<!-- Scrape form -->
<div class="mb-10 rounded-xl border border-zinc-800 bg-zinc-900 p-5">
<h2 class="text-sm font-semibold text-zinc-300 mb-3">Scrape a new book</h2>
<form id="scrape-form"
hx-post="/ui/scrape/book"
hx-target="#scrape-status"
hx-swap="innerHTML"
class="flex gap-2">
<div class="flex-1 relative" id="scrape-search-wrap">
<input type="text"
id="scrape-search"
autocomplete="off"
spellcheck="false"
placeholder="Search rankings or paste a URL…"
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
<!-- hidden url field submitted to HTMX -->
<input type="url" name="url" id="scrape-url" required class="hidden" />
<!-- dropdown -->
<ul id="scrape-dropdown"
class="hidden absolute z-50 left-0 right-0 top-full mt-1 rounded-xl border border-zinc-700 bg-zinc-900 shadow-xl max-h-80 overflow-y-auto">
</ul>
</div>
<button type="submit"
class="px-4 py-2 rounded-lg text-amber-400 hover:text-amber-300 text-sm font-medium transition-colors whitespace-nowrap">
Scrape
</button>
</form>
<div id="scrape-status" class="mt-3"></div>
</div>
<!-- Continue reading section (populated by JS) --> <!-- Continue reading section (populated by JS) -->
<div id="continue-reading-section" class="hidden mb-10"> <div id="continue-reading-section" class="hidden mb-10">
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Continue reading</h2> <h2 class="text-lg font-semibold text-zinc-200 mb-3">Continue reading</h2>
@@ -292,7 +265,75 @@ const homeTmpl = `
if (input) input.addEventListener('input', filterCards); if (input) input.addEventListener('input', filterCards);
/* ── ranking search / scrape autocomplete ──────────────────────────────── */ }());
</script>`
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
books, err := s.writer.ListBooks()
if err != nil {
http.Error(w, "failed to list books: "+err.Error(), http.StatusInternalServerError)
return
}
t := template.Must(template.New("home").Parse(homeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
}{
Books: books,
})
s.respond(w, r, "Home", buf.String())
}
// ─── GET /scrape — add a new book ─────────────────────────────────────────────
const scrapeTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
<a href="/"
hx-get="/"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← All books
</a>
<h1 class="text-3xl font-bold text-zinc-100 mb-1">Add a book</h1>
<p class="text-zinc-400 text-sm mb-8">Search the rankings or paste a novelfire.net URL to scrape a new book.</p>
<form id="scrape-form"
hx-post="/ui/scrape/book"
hx-target="#scrape-status"
hx-swap="innerHTML">
<div class="relative mb-3" id="scrape-search-wrap">
<input type="text"
id="scrape-search"
autocomplete="off"
spellcheck="false"
placeholder="Search rankings or paste a URL…"
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
<!-- hidden url field submitted to HTMX -->
<input type="url" name="url" id="scrape-url" required class="hidden" />
<!-- dropdown -->
<ul id="scrape-dropdown"
class="hidden absolute z-50 left-0 right-0 top-full mt-1 rounded-xl border border-zinc-700 bg-zinc-900 shadow-xl max-h-80 overflow-y-auto">
</ul>
</div>
<button type="submit"
class="px-4 py-2 rounded-lg text-amber-400 hover:text-amber-300 text-sm font-medium transition-colors">
Scrape
</button>
</form>
<div id="scrape-status" class="mt-4"></div>
</div>
<script>
(function () { (function () {
var RANKING = {{.RankingJSON}}; var RANKING = {{.RankingJSON}};
if (!RANKING || !RANKING.length) return; if (!RANKING || !RANKING.length) return;
@@ -305,7 +346,6 @@ const homeTmpl = `
var activeIdx = -1; var activeIdx = -1;
// Sync the hidden url field whenever the visible input looks like a URL.
function syncURLField(val) { function syncURLField(val) {
val = val.trim(); val = val.trim();
if (/^https?:\/\//i.test(val)) { if (/^https?:\/\//i.test(val)) {
@@ -321,7 +361,6 @@ const homeTmpl = `
activeIdx = -1; activeIdx = -1;
} }
// Select an item: put the URL in the visible input so the user can see/copy it.
function selectItem(item) { function selectItem(item) {
var url = item.source_url || ''; var url = item.source_url || '';
searchInput.value = url; searchInput.value = url;
@@ -334,7 +373,6 @@ const homeTmpl = `
li.className = 'flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-zinc-800 transition-colors'; li.className = 'flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-zinc-800 transition-colors';
li.dataset.url = item.source_url || ''; li.dataset.url = item.source_url || '';
// Cover image
if (item.cover) { if (item.cover) {
var img = document.createElement('img'); var img = document.createElement('img');
img.src = item.cover; img.src = item.cover;
@@ -347,7 +385,6 @@ const homeTmpl = `
li.appendChild(ph); li.appendChild(ph);
} }
// Text block
var txt = document.createElement('div'); var txt = document.createElement('div');
txt.className = 'min-w-0 flex-1'; txt.className = 'min-w-0 flex-1';
@@ -363,7 +400,6 @@ const homeTmpl = `
txt.appendChild(author); txt.appendChild(author);
} }
// Show the URL so the user knows what will land in the field.
if (item.source_url) { if (item.source_url) {
var urlHint = document.createElement('p'); var urlHint = document.createElement('p');
urlHint.className = 'text-xs text-zinc-500 truncate mt-0.5'; urlHint.className = 'text-xs text-zinc-500 truncate mt-0.5';
@@ -399,7 +435,6 @@ const homeTmpl = `
}); });
activeIdx = idx; activeIdx = idx;
if (idx >= 0 && idx < items.length) { if (idx >= 0 && idx < items.length) {
// Preview the URL in the input while navigating with keyboard.
var url = items[idx].source_url || ''; var url = items[idx].source_url || '';
searchInput.value = url; searchInput.value = url;
urlInput.value = url; urlInput.value = url;
@@ -413,7 +448,7 @@ const homeTmpl = `
items.forEach(function (item, i) { items.forEach(function (item, i) {
var li = buildItem(item, q); var li = buildItem(item, q);
li.addEventListener('mousedown', function (e) { li.addEventListener('mousedown', function (e) {
e.preventDefault(); // keep focus on input e.preventDefault();
selectItem(item); selectItem(item);
}); });
dropdown.appendChild(li); dropdown.appendChild(li);
@@ -433,7 +468,6 @@ const homeTmpl = `
var q = searchInput.value.trim().toLowerCase(); var q = searchInput.value.trim().toLowerCase();
syncURLField(searchInput.value); syncURLField(searchInput.value);
if (!q) { closeDrop(); return; } if (!q) { closeDrop(); return; }
// If it looks like a URL, no autocomplete needed.
if (/^https?:\/\//i.test(q)) { closeDrop(); return; } if (/^https?:\/\//i.test(q)) { closeDrop(); return; }
showDrop(filterRanking(q), q); showDrop(filterRanking(q), q);
}); });
@@ -456,7 +490,6 @@ const homeTmpl = `
} }
}); });
// Validate before submit: if url field empty, treat visible input as raw URL.
form.addEventListener('htmx:configRequest', function (e) { form.addEventListener('htmx:configRequest', function (e) {
var val = searchInput.value.trim(); var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) { if (!urlInput.value && /^https?:\/\//i.test(val)) {
@@ -464,7 +497,6 @@ const homeTmpl = `
} }
}); });
// Also handle plain form submit (non-HTMX fallback).
form.addEventListener('submit', function () { form.addEventListener('submit', function () {
var val = searchInput.value.trim(); var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) { if (!urlInput.value && /^https?:\/\//i.test(val)) {
@@ -472,47 +504,27 @@ const homeTmpl = `
} }
}); });
// Close dropdown when clicking outside.
document.addEventListener('mousedown', function (e) { document.addEventListener('mousedown', function (e) {
if (!document.getElementById('scrape-search-wrap').contains(e.target)) { if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
closeDrop(); closeDrop();
} }
}); });
}()); }());
}());
</script>` </script>`
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
books, err := s.writer.ListBooks()
if err != nil {
http.Error(w, "failed to list books: "+err.Error(), http.StatusInternalServerError)
return
}
// Load ranking items for the scrape-search autocomplete.
// Failures are non-fatal — the form degrades to a plain URL input.
rankingItems, _ := s.writer.ReadRankingItems() rankingItems, _ := s.writer.ReadRankingItems()
// Encode ranking items as JSON for embedding in the template.
rankingJSON, _ := json.Marshal(rankingItems) rankingJSON, _ := json.Marshal(rankingItems)
t := template.Must(template.New("home").Parse(homeTmpl)) t := template.Must(template.New("scrape").Parse(scrapeTmpl))
var buf bytes.Buffer var buf bytes.Buffer
_ = t.Execute(&buf, struct { _ = t.Execute(&buf, struct {
Books interface{}
RankingJSON template.JS RankingJSON template.JS
}{ }{
Books: books,
RankingJSON: template.JS(rankingJSON), RankingJSON: template.JS(rankingJSON),
}) })
s.respond(w, r, "Home", buf.String()) s.respond(w, r, "Add a book", buf.String())
} }
// ─── GET /ranking — ranking page ─────────────────────────────────────────────── // ─── GET /ranking — ranking page ───────────────────────────────────────────────