feat: add source link with icon to book page and ranking cards
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

This commit is contained in:
Admin
2026-03-01 19:51:22 +05:00
parent 03d7cc6234
commit 1222653858

View File

@@ -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"