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)
|
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)
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user