feat: add source link with icon to book page and ranking cards
This commit is contained in:
@@ -124,7 +124,15 @@ const homeTmpl = `
|
||||
<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 bg-amber-700 hover:bg-amber-600 text-white">Browse Rankings</a>
|
||||
</div>
|
||||
<p class="text-zinc-400 mb-8">{{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 -->
|
||||
<div class="mb-6">
|
||||
<input id="filter-input" type="search" placeholder="Filter by title, author, genre…"
|
||||
autocomplete="off" spellcheck="false"
|
||||
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" />
|
||||
<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">
|
||||
@@ -163,6 +171,7 @@ const homeTmpl = `
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
data-slug="{{.Slug}}"
|
||||
data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
||||
class="book-card group block rounded-xl border border-zinc-800 bg-zinc-900 p-5 hover:border-amber-500 transition-colors cursor-pointer">
|
||||
<div class="flex gap-4">
|
||||
{{if .Cover}}
|
||||
@@ -187,46 +196,64 @@ const homeTmpl = `
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
/* ── continue-reading ──────────────────────────────────────────────────── */
|
||||
var progress = {};
|
||||
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
|
||||
|
||||
var inProgress = Object.keys(progress);
|
||||
if (inProgress.length === 0) return;
|
||||
|
||||
var continueSection = document.getElementById('continue-reading-section');
|
||||
var continueGrid = document.getElementById('continue-reading-grid');
|
||||
var continueSection = document.getElementById('continue-reading-section');
|
||||
var continueGrid = document.getElementById('continue-reading-grid');
|
||||
var availableHeading = document.getElementById('available-heading');
|
||||
var moved = 0;
|
||||
|
||||
inProgress.forEach(function(slug) {
|
||||
var card = document.querySelector('[data-slug="' + slug + '"]');
|
||||
if (!card) return;
|
||||
var clone = card.cloneNode(true);
|
||||
// Inject "Chapter N" line into the cloned card
|
||||
var chapterNum = progress[slug];
|
||||
var meta = clone.querySelector('.min-w-0');
|
||||
if (meta && chapterNum) {
|
||||
var chLine = document.createElement('p');
|
||||
chLine.className = 'text-xs text-amber-400 mt-1';
|
||||
chLine.textContent = 'Chapter ' + chapterNum;
|
||||
// Insert after the author line (second child) or after the title
|
||||
var author = meta.querySelector('p');
|
||||
if (author) {
|
||||
author.insertAdjacentElement('afterend', chLine);
|
||||
} else {
|
||||
var title = meta.querySelector('h2');
|
||||
if (title) title.insertAdjacentElement('afterend', chLine);
|
||||
if (inProgress.length > 0) {
|
||||
var moved = 0;
|
||||
inProgress.forEach(function(slug) {
|
||||
var card = document.querySelector('[data-slug="' + slug + '"]');
|
||||
if (!card) return;
|
||||
var clone = card.cloneNode(true);
|
||||
var chapterNum = progress[slug];
|
||||
var meta = clone.querySelector('.min-w-0');
|
||||
if (meta && chapterNum) {
|
||||
var chLine = document.createElement('p');
|
||||
chLine.className = 'text-xs text-amber-400 mt-1';
|
||||
chLine.textContent = 'Chapter ' + chapterNum;
|
||||
var author = meta.querySelector('p');
|
||||
if (author) author.insertAdjacentElement('afterend', chLine);
|
||||
else { var title = meta.querySelector('h2'); if (title) title.insertAdjacentElement('afterend', chLine); }
|
||||
}
|
||||
continueGrid.appendChild(clone);
|
||||
card.parentNode.removeChild(card);
|
||||
moved++;
|
||||
});
|
||||
if (moved > 0) {
|
||||
continueSection.classList.remove('hidden');
|
||||
availableHeading.classList.remove('hidden');
|
||||
}
|
||||
continueGrid.appendChild(clone);
|
||||
card.parentNode.removeChild(card);
|
||||
moved++;
|
||||
});
|
||||
|
||||
if (moved > 0) {
|
||||
continueSection.classList.remove('hidden');
|
||||
availableHeading.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/* ── filter ────────────────────────────────────────────────────────────── */
|
||||
var input = document.getElementById('filter-input');
|
||||
var countEl = document.getElementById('filter-count');
|
||||
var booksGrid = document.getElementById('books-grid');
|
||||
|
||||
function filterCards() {
|
||||
var q = input.value.trim().toLowerCase();
|
||||
var cards = booksGrid ? booksGrid.querySelectorAll('[data-filter]') : [];
|
||||
var shown = 0;
|
||||
cards.forEach(function(card) {
|
||||
var match = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
|
||||
card.style.display = match ? '' : 'none';
|
||||
if (match) shown++;
|
||||
});
|
||||
if (q && countEl) {
|
||||
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
||||
countEl.classList.remove('hidden');
|
||||
} else if (countEl) {
|
||||
countEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
if (input) input.addEventListener('input', filterCards);
|
||||
}());
|
||||
</script>`
|
||||
|
||||
@@ -293,8 +320,16 @@ const rankingTmpl = `
|
||||
</div>
|
||||
<div id="ranking-refresh-status" class="mt-2"></div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="mt-4 mb-2">
|
||||
<input id="ranking-filter" type="search" placeholder="Filter by title, genre, status…"
|
||||
autocomplete="off" spellcheck="false"
|
||||
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" />
|
||||
<p id="ranking-filter-count" class="text-xs text-zinc-500 mt-1 hidden"></p>
|
||||
</div>
|
||||
|
||||
<!-- Book grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 mt-8">
|
||||
<div id="ranking-grid" class="grid gap-4 sm:grid-cols-2 mt-4">
|
||||
{{range .Books}}
|
||||
{{if .Local}}
|
||||
{{/* Book is in local library — wrap entire card in a clickable link */}}
|
||||
@@ -303,6 +338,7 @@ const rankingTmpl = `
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
||||
class="group block rounded-xl border border-teal-700 bg-teal-950 p-4 hover:border-teal-400 transition-colors">
|
||||
<div class="flex gap-4">
|
||||
{{if .Cover}}
|
||||
@@ -323,12 +359,25 @@ const rankingTmpl = `
|
||||
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-teal-900 text-teal-400">{{.}}</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .SourceURL}}
|
||||
<div class="mt-3">
|
||||
<a href="{{.SourceURL}}" target="_blank" rel="noopener noreferrer"
|
||||
title="View on novelfire.net"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{{else}}
|
||||
{{/* Book not yet in local library */}}
|
||||
<div class="group block rounded-xl border border-zinc-800 bg-zinc-900 p-4 hover:border-amber-500 transition-colors">
|
||||
<div data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
||||
class="group block rounded-xl border border-zinc-800 bg-zinc-900 p-4 hover:border-amber-500 transition-colors">
|
||||
<div class="flex gap-4">
|
||||
{{if .Cover}}
|
||||
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
|
||||
@@ -348,7 +397,7 @@ const rankingTmpl = `
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .SourceURL}}
|
||||
<div class="mt-3">
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
|
||||
<input type="hidden" name="url" value="{{.SourceURL}}">
|
||||
<button type="submit"
|
||||
@@ -357,6 +406,14 @@ const rankingTmpl = `
|
||||
Scrape
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{.SourceURL}}" target="_blank" rel="noopener noreferrer"
|
||||
title="View on novelfire.net"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -367,7 +424,33 @@ const rankingTmpl = `
|
||||
<p class="text-zinc-500 col-span-2">No ranking data available. Click "Refresh Rankings" to fetch from novelfire.net.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>`
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var input = document.getElementById('ranking-filter');
|
||||
var countEl = document.getElementById('ranking-filter-count');
|
||||
var grid = document.getElementById('ranking-grid');
|
||||
if (!input || !grid) return;
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
var q = input.value.trim().toLowerCase();
|
||||
var cards = grid.querySelectorAll('[data-filter]');
|
||||
var shown = 0;
|
||||
cards.forEach(function (card) {
|
||||
var match = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
|
||||
card.style.display = match ? '' : 'none';
|
||||
if (match) shown++;
|
||||
});
|
||||
if (q) {
|
||||
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
||||
countEl.classList.remove('hidden');
|
||||
} else {
|
||||
countEl.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}());
|
||||
</script>`
|
||||
|
||||
// rankingViewItem enriches a RankingItem with whether it is present in the
|
||||
// local book library, so the template can highlight it differently.
|
||||
@@ -611,6 +694,14 @@ const bookTmpl = `
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
|
||||
{{if .Meta.SourceURL}}
|
||||
<a href="{{.Meta.SourceURL}}" target="_blank" rel="noopener noreferrer"
|
||||
title="View on novelfire.net"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Source
|
||||
</a>
|
||||
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
|
||||
<input type="hidden" name="url" value="{{.Meta.SourceURL}}">
|
||||
<button type="submit"
|
||||
|
||||
Reference in New Issue
Block a user