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)
// 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)

View File

@@ -123,7 +123,10 @@ 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>
<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 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>
@@ -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,194 +265,6 @@ const homeTmpl = `
if (input) input.addEventListener('input', filterCards);
/* ── ranking search / scrape autocomplete ──────────────────────────────── */
(function () {
var RANKING = {{.RankingJSON}};
if (!RANKING || !RANKING.length) return;
var searchInput = document.getElementById('scrape-search');
var urlInput = document.getElementById('scrape-url');
var dropdown = document.getElementById('scrape-dropdown');
var form = document.getElementById('scrape-form');
if (!searchInput || !urlInput || !dropdown || !form) return;
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)) {
urlInput.value = val;
} else {
urlInput.value = '';
}
}
function closeDrop() {
dropdown.classList.add('hidden');
dropdown.innerHTML = '';
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;
urlInput.value = url;
closeDrop();
}
function buildItem(item, q) {
var li = document.createElement('li');
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;
img.alt = '';
img.className = 'w-10 h-14 object-cover rounded flex-shrink-0 bg-zinc-800';
li.appendChild(img);
} else {
var ph = document.createElement('div');
ph.className = 'w-10 h-14 rounded flex-shrink-0 bg-zinc-800';
li.appendChild(ph);
}
// Text block
var txt = document.createElement('div');
txt.className = 'min-w-0 flex-1';
var title = document.createElement('p');
title.className = 'text-sm font-medium text-zinc-100 truncate';
title.textContent = item.title || '';
txt.appendChild(title);
if (item.author) {
var author = document.createElement('p');
author.className = 'text-xs text-zinc-400 truncate mt-0.5';
author.textContent = item.author;
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';
urlHint.textContent = item.source_url;
txt.appendChild(urlHint);
}
var meta = document.createElement('div');
meta.className = 'flex gap-1.5 mt-1 flex-wrap';
if (item.status) {
var s = document.createElement('span');
s.className = 'text-xs px-1.5 py-0.5 rounded-full bg-zinc-700 text-zinc-300';
s.textContent = item.status;
meta.appendChild(s);
}
if (item.rank) {
var r = document.createElement('span');
r.className = 'text-xs px-1.5 py-0.5 rounded-full bg-amber-900 text-amber-300';
r.textContent = '#' + item.rank;
meta.appendChild(r);
}
txt.appendChild(meta);
li.appendChild(txt);
return li;
}
function setActive(idx, items) {
var lis = dropdown.querySelectorAll('li');
lis.forEach(function (li, i) {
if (i === idx) li.classList.add('bg-zinc-800');
else li.classList.remove('bg-zinc-800');
});
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;
}
}
function showDrop(items, q) {
dropdown.innerHTML = '';
activeIdx = -1;
if (!items.length) { closeDrop(); return; }
items.forEach(function (item, i) {
var li = buildItem(item, q);
li.addEventListener('mousedown', function (e) {
e.preventDefault(); // keep focus on input
selectItem(item);
});
dropdown.appendChild(li);
});
dropdown.classList.remove('hidden');
}
function filterRanking(q) {
return RANKING.filter(function (item) {
var genres = (item.genres || []).join(' ');
var haystack = ((item.title || '') + ' ' + (item.author || '') + ' ' + (item.status || '') + ' ' + genres).toLowerCase();
return haystack.indexOf(q) !== -1;
}).slice(0, 10);
}
searchInput.addEventListener('input', function () {
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);
});
searchInput.addEventListener('keydown', function (e) {
var q = searchInput.value.trim().toLowerCase();
var items = /^https?:\/\//i.test(q) ? [] : filterRanking(q);
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(Math.min(activeIdx + 1, items.length - 1), items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(Math.max(activeIdx - 1, 0), items);
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault();
if (items[activeIdx]) selectItem(items[activeIdx]);
} else if (e.key === 'Escape') {
closeDrop();
}
});
// 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)) {
urlInput.value = val;
}
});
// Also handle plain form submit (non-HTMX fallback).
form.addEventListener('submit', function () {
var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) {
urlInput.value = val;
}
});
// Close dropdown when clicking outside.
document.addEventListener('mousedown', function (e) {
if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
closeDrop();
}
});
}());
}());
</script>`
@@ -495,26 +280,253 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
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()
// Encode ranking items as JSON for embedding in the template.
rankingJSON, _ := json.Marshal(rankingItems)
t := template.Must(template.New("home").Parse(homeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
RankingJSON template.JS
Books interface{}
}{
Books: books,
RankingJSON: template.JS(rankingJSON),
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;
var searchInput = document.getElementById('scrape-search');
var urlInput = document.getElementById('scrape-url');
var dropdown = document.getElementById('scrape-dropdown');
var form = document.getElementById('scrape-form');
if (!searchInput || !urlInput || !dropdown || !form) return;
var activeIdx = -1;
function syncURLField(val) {
val = val.trim();
if (/^https?:\/\//i.test(val)) {
urlInput.value = val;
} else {
urlInput.value = '';
}
}
function closeDrop() {
dropdown.classList.add('hidden');
dropdown.innerHTML = '';
activeIdx = -1;
}
function selectItem(item) {
var url = item.source_url || '';
searchInput.value = url;
urlInput.value = url;
closeDrop();
}
function buildItem(item, q) {
var li = document.createElement('li');
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 || '';
if (item.cover) {
var img = document.createElement('img');
img.src = item.cover;
img.alt = '';
img.className = 'w-10 h-14 object-cover rounded flex-shrink-0 bg-zinc-800';
li.appendChild(img);
} else {
var ph = document.createElement('div');
ph.className = 'w-10 h-14 rounded flex-shrink-0 bg-zinc-800';
li.appendChild(ph);
}
var txt = document.createElement('div');
txt.className = 'min-w-0 flex-1';
var title = document.createElement('p');
title.className = 'text-sm font-medium text-zinc-100 truncate';
title.textContent = item.title || '';
txt.appendChild(title);
if (item.author) {
var author = document.createElement('p');
author.className = 'text-xs text-zinc-400 truncate mt-0.5';
author.textContent = item.author;
txt.appendChild(author);
}
if (item.source_url) {
var urlHint = document.createElement('p');
urlHint.className = 'text-xs text-zinc-500 truncate mt-0.5';
urlHint.textContent = item.source_url;
txt.appendChild(urlHint);
}
var meta = document.createElement('div');
meta.className = 'flex gap-1.5 mt-1 flex-wrap';
if (item.status) {
var s = document.createElement('span');
s.className = 'text-xs px-1.5 py-0.5 rounded-full bg-zinc-700 text-zinc-300';
s.textContent = item.status;
meta.appendChild(s);
}
if (item.rank) {
var r = document.createElement('span');
r.className = 'text-xs px-1.5 py-0.5 rounded-full bg-amber-900 text-amber-300';
r.textContent = '#' + item.rank;
meta.appendChild(r);
}
txt.appendChild(meta);
li.appendChild(txt);
return li;
}
function setActive(idx, items) {
var lis = dropdown.querySelectorAll('li');
lis.forEach(function (li, i) {
if (i === idx) li.classList.add('bg-zinc-800');
else li.classList.remove('bg-zinc-800');
});
activeIdx = idx;
if (idx >= 0 && idx < items.length) {
var url = items[idx].source_url || '';
searchInput.value = url;
urlInput.value = url;
}
}
function showDrop(items, q) {
dropdown.innerHTML = '';
activeIdx = -1;
if (!items.length) { closeDrop(); return; }
items.forEach(function (item, i) {
var li = buildItem(item, q);
li.addEventListener('mousedown', function (e) {
e.preventDefault();
selectItem(item);
});
dropdown.appendChild(li);
});
dropdown.classList.remove('hidden');
}
function filterRanking(q) {
return RANKING.filter(function (item) {
var genres = (item.genres || []).join(' ');
var haystack = ((item.title || '') + ' ' + (item.author || '') + ' ' + (item.status || '') + ' ' + genres).toLowerCase();
return haystack.indexOf(q) !== -1;
}).slice(0, 10);
}
searchInput.addEventListener('input', function () {
var q = searchInput.value.trim().toLowerCase();
syncURLField(searchInput.value);
if (!q) { closeDrop(); return; }
if (/^https?:\/\//i.test(q)) { closeDrop(); return; }
showDrop(filterRanking(q), q);
});
searchInput.addEventListener('keydown', function (e) {
var q = searchInput.value.trim().toLowerCase();
var items = /^https?:\/\//i.test(q) ? [] : filterRanking(q);
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(Math.min(activeIdx + 1, items.length - 1), items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(Math.max(activeIdx - 1, 0), items);
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault();
if (items[activeIdx]) selectItem(items[activeIdx]);
} else if (e.key === 'Escape') {
closeDrop();
}
});
form.addEventListener('htmx:configRequest', function (e) {
var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) {
urlInput.value = val;
}
});
form.addEventListener('submit', function () {
var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) {
urlInput.value = val;
}
});
document.addEventListener('mousedown', function (e) {
if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
closeDrop();
}
});
}());
</script>`
func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
rankingItems, _ := s.writer.ReadRankingItems()
rankingJSON, _ := json.Marshal(rankingItems)
t := template.Must(template.New("scrape").Parse(scrapeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
RankingJSON template.JS
}{
RankingJSON: template.JS(rankingJSON),
})
s.respond(w, r, "Add a book", buf.String())
}
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
const rankingTmpl = `