Move scrape form to dedicated /scrape page, add '+ Add' link in home header
- 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:
@@ -70,6 +70,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
||||
// UI routes
|
||||
mux.HandleFunc("GET /", s.handleHome)
|
||||
mux.HandleFunc("GET /scrape", s.handleScrape)
|
||||
mux.HandleFunc("GET /ranking", s.handleRanking)
|
||||
mux.HandleFunc("POST /ranking/refresh", s.handleRankingRefresh)
|
||||
mux.HandleFunc("GET /ranking/view", s.handleRankingView)
|
||||
|
||||
@@ -123,8 +123,11 @@ const homeTmpl = `
|
||||
<div class="max-w-4xl mx-auto px-4 py-10" hx-history="false">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-zinc-400 mb-2">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
|
||||
|
||||
<!-- Filter bar -->
|
||||
@@ -135,36 +138,6 @@ const homeTmpl = `
|
||||
<p id="filter-count" class="text-xs text-zinc-500 mt-1 hidden"></p>
|
||||
</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) -->
|
||||
<div id="continue-reading-section" class="hidden mb-10">
|
||||
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Continue reading</h2>
|
||||
@@ -292,8 +265,76 @@ const homeTmpl = `
|
||||
|
||||
if (input) input.addEventListener('input', filterCards);
|
||||
|
||||
/* ── ranking search / scrape autocomplete ──────────────────────────────── */
|
||||
(function () {
|
||||
}());
|
||||
</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 () {
|
||||
var RANKING = {{.RankingJSON}};
|
||||
if (!RANKING || !RANKING.length) return;
|
||||
|
||||
@@ -305,7 +346,6 @@ const homeTmpl = `
|
||||
|
||||
var activeIdx = -1;
|
||||
|
||||
// Sync the hidden url field whenever the visible input looks like a URL.
|
||||
function syncURLField(val) {
|
||||
val = val.trim();
|
||||
if (/^https?:\/\//i.test(val)) {
|
||||
@@ -321,7 +361,6 @@ const homeTmpl = `
|
||||
activeIdx = -1;
|
||||
}
|
||||
|
||||
// Select an item: put the URL in the visible input so the user can see/copy it.
|
||||
function selectItem(item) {
|
||||
var url = item.source_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.dataset.url = item.source_url || '';
|
||||
|
||||
// Cover image
|
||||
if (item.cover) {
|
||||
var img = document.createElement('img');
|
||||
img.src = item.cover;
|
||||
@@ -347,7 +385,6 @@ const homeTmpl = `
|
||||
li.appendChild(ph);
|
||||
}
|
||||
|
||||
// Text block
|
||||
var txt = document.createElement('div');
|
||||
txt.className = 'min-w-0 flex-1';
|
||||
|
||||
@@ -363,7 +400,6 @@ const homeTmpl = `
|
||||
txt.appendChild(author);
|
||||
}
|
||||
|
||||
// Show the URL so the user knows what will land in the field.
|
||||
if (item.source_url) {
|
||||
var urlHint = document.createElement('p');
|
||||
urlHint.className = 'text-xs text-zinc-500 truncate mt-0.5';
|
||||
@@ -399,7 +435,6 @@ const homeTmpl = `
|
||||
});
|
||||
activeIdx = idx;
|
||||
if (idx >= 0 && idx < items.length) {
|
||||
// Preview the URL in the input while navigating with keyboard.
|
||||
var url = items[idx].source_url || '';
|
||||
searchInput.value = url;
|
||||
urlInput.value = url;
|
||||
@@ -413,7 +448,7 @@ const homeTmpl = `
|
||||
items.forEach(function (item, i) {
|
||||
var li = buildItem(item, q);
|
||||
li.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault(); // keep focus on input
|
||||
e.preventDefault();
|
||||
selectItem(item);
|
||||
});
|
||||
dropdown.appendChild(li);
|
||||
@@ -433,7 +468,6 @@ const homeTmpl = `
|
||||
var q = searchInput.value.trim().toLowerCase();
|
||||
syncURLField(searchInput.value);
|
||||
if (!q) { closeDrop(); return; }
|
||||
// If it looks like a URL, no autocomplete needed.
|
||||
if (/^https?:\/\//i.test(q)) { closeDrop(); return; }
|
||||
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) {
|
||||
var val = searchInput.value.trim();
|
||||
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 () {
|
||||
var val = searchInput.value.trim();
|
||||
if (!urlInput.value && /^https?:\/\//i.test(val)) {
|
||||
@@ -472,47 +504,27 @@ const homeTmpl = `
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside.
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
|
||||
closeDrop();
|
||||
}
|
||||
});
|
||||
}());
|
||||
|
||||
}());
|
||||
</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
|
||||
}
|
||||
|
||||
// Load ranking items for the scrape-search autocomplete.
|
||||
// Failures are non-fatal — the form degrades to a plain URL input.
|
||||
func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
|
||||
rankingItems, _ := s.writer.ReadRankingItems()
|
||||
|
||||
// Encode ranking items as JSON for embedding in the template.
|
||||
rankingJSON, _ := json.Marshal(rankingItems)
|
||||
|
||||
t := template.Must(template.New("home").Parse(homeTmpl))
|
||||
t := template.Must(template.New("scrape").Parse(scrapeTmpl))
|
||||
var buf bytes.Buffer
|
||||
_ = t.Execute(&buf, struct {
|
||||
Books interface{}
|
||||
RankingJSON template.JS
|
||||
}{
|
||||
Books: books,
|
||||
RankingJSON: template.JS(rankingJSON),
|
||||
})
|
||||
|
||||
s.respond(w, r, "Home", buf.String())
|
||||
s.respond(w, r, "Add a book", buf.String())
|
||||
}
|
||||
|
||||
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user