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>
|
<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>
|
<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>
|
</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 -->
|
<!-- Scrape form -->
|
||||||
<div class="mb-10 rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
<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-push-url="true"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
data-slug="{{.Slug}}"
|
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">
|
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">
|
<div class="flex gap-4">
|
||||||
{{if .Cover}}
|
{{if .Cover}}
|
||||||
@@ -187,46 +196,64 @@ const homeTmpl = `
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
/* ── continue-reading ──────────────────────────────────────────────────── */
|
||||||
var progress = {};
|
var progress = {};
|
||||||
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
|
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
|
||||||
|
|
||||||
var inProgress = Object.keys(progress);
|
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 availableHeading = document.getElementById('available-heading');
|
||||||
var moved = 0;
|
|
||||||
|
|
||||||
inProgress.forEach(function(slug) {
|
if (inProgress.length > 0) {
|
||||||
var card = document.querySelector('[data-slug="' + slug + '"]');
|
var moved = 0;
|
||||||
if (!card) return;
|
inProgress.forEach(function(slug) {
|
||||||
var clone = card.cloneNode(true);
|
var card = document.querySelector('[data-slug="' + slug + '"]');
|
||||||
// Inject "Chapter N" line into the cloned card
|
if (!card) return;
|
||||||
var chapterNum = progress[slug];
|
var clone = card.cloneNode(true);
|
||||||
var meta = clone.querySelector('.min-w-0');
|
var chapterNum = progress[slug];
|
||||||
if (meta && chapterNum) {
|
var meta = clone.querySelector('.min-w-0');
|
||||||
var chLine = document.createElement('p');
|
if (meta && chapterNum) {
|
||||||
chLine.className = 'text-xs text-amber-400 mt-1';
|
var chLine = document.createElement('p');
|
||||||
chLine.textContent = 'Chapter ' + chapterNum;
|
chLine.className = 'text-xs text-amber-400 mt-1';
|
||||||
// Insert after the author line (second child) or after the title
|
chLine.textContent = 'Chapter ' + chapterNum;
|
||||||
var author = meta.querySelector('p');
|
var author = meta.querySelector('p');
|
||||||
if (author) {
|
if (author) author.insertAdjacentElement('afterend', chLine);
|
||||||
author.insertAdjacentElement('afterend', chLine);
|
else { var title = meta.querySelector('h2'); if (title) title.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>`
|
</script>`
|
||||||
|
|
||||||
@@ -293,8 +320,16 @@ const rankingTmpl = `
|
|||||||
</div>
|
</div>
|
||||||
<div id="ranking-refresh-status" class="mt-2"></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 -->
|
<!-- 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}}
|
{{range .Books}}
|
||||||
{{if .Local}}
|
{{if .Local}}
|
||||||
{{/* Book is in local library — wrap entire card in a clickable link */}}
|
{{/* Book is in local library — wrap entire card in a clickable link */}}
|
||||||
@@ -303,6 +338,7 @@ const rankingTmpl = `
|
|||||||
hx-target="#main-content"
|
hx-target="#main-content"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-swap="innerHTML"
|
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">
|
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">
|
<div class="flex gap-4">
|
||||||
{{if .Cover}}
|
{{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}}
|
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-teal-900 text-teal-400">{{.}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{/* Book not yet in local library */}}
|
{{/* 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">
|
<div class="flex gap-4">
|
||||||
{{if .Cover}}
|
{{if .Cover}}
|
||||||
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
|
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
|
||||||
@@ -348,7 +397,7 @@ const rankingTmpl = `
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .SourceURL}}
|
{{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">
|
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
|
||||||
<input type="hidden" name="url" value="{{.SourceURL}}">
|
<input type="hidden" name="url" value="{{.SourceURL}}">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
@@ -357,6 +406,14 @@ const rankingTmpl = `
|
|||||||
Scrape
|
Scrape
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</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>
|
<p class="text-zinc-500 col-span-2">No ranking data available. Click "Refresh Rankings" to fetch from novelfire.net.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</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
|
// rankingViewItem enriches a RankingItem with whether it is present in the
|
||||||
// local book library, so the template can highlight it differently.
|
// 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">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
|
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
|
||||||
{{if .Meta.SourceURL}}
|
{{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">
|
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
|
||||||
<input type="hidden" name="url" value="{{.Meta.SourceURL}}">
|
<input type="hidden" name="url" value="{{.Meta.SourceURL}}">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
|
|||||||
Reference in New Issue
Block a user