diff --git a/scraper/internal/server/server.go b/scraper/internal/server/server.go
index 631c200..2b1097b 100644
--- a/scraper/internal/server/server.go
+++ b/scraper/internal/server/server.go
@@ -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)
diff --git a/scraper/internal/server/ui.go b/scraper/internal/server/ui.go
index 7bd867c..61b5b94 100644
--- a/scraper/internal/server/ui.go
+++ b/scraper/internal/server/ui.go
@@ -123,7 +123,10 @@ const homeTmpl = `
Continue reading
@@ -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();
- }
- });
- }());
-
}());
`
@@ -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 = `
+
+
+ ← All books
+
+
+
Add a book
+
Search the rankings or paste a novelfire.net URL to scrape a new book.
+
+
+
+
+
+`
+
+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 = `