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,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 = `
|
||||
|
||||
Reference in New Issue
Block a user