1
0
mirror of https://github.com/avinal/avinal.github.io.git synced 2026-07-03 23:30:09 +05:30

feat: add bookmarks page with image fetcher

Assisted by Claude Code

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-02 18:17:45 +05:30
parent 99f3fb5ec8
commit 5f467665bc
52 changed files with 848 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
import fs from "fs";
import https from "https";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..");
const jsonPath = path.join(root, "src/data/bookmarks.json");
const imgDir = path.join(root, "public/images/bookmarks");
fs.mkdirSync(imgDir, { recursive: true });
function slugify(title) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function httpGet(url, options = {}) {
return new Promise((resolve, reject) => {
const proto = url.startsWith("https") ? https : http;
proto.get(url, options, resolve).on("error", reject);
});
}
function download(url, dest, redirects = 5) {
return new Promise((resolve, reject) => {
if (redirects <= 0) return reject(new Error("Too many redirects"));
const proto = url.startsWith("https") ? https : http;
proto
.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const next = new URL(res.headers.location, url).href;
download(next, dest, redirects - 1).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
const ct = res.headers["content-type"] || "";
const ext = ct.includes("png") ? ".png" : ct.includes("webp") ? ".webp" : ".jpg";
const finalDest = dest + ext;
const ws = fs.createWriteStream(finalDest);
res.pipe(ws);
ws.on("finish", () => {
ws.close();
resolve("/images/bookmarks/" + path.basename(finalDest));
});
ws.on("error", reject);
})
.on("error", reject);
});
}
async function fetchPosterFromOMDB(title, year) {
const query = encodeURIComponent(title);
const url = `https://www.omdbapi.com/?t=${query}&y=${year}&apikey=trilogy`;
try {
const res = await httpGet(url);
let body = "";
for await (const chunk of res) body += chunk;
const data = JSON.parse(body);
if (data.Poster && data.Poster !== "N/A") return data.Poster;
} catch {}
return null;
}
function delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function main() {
const data = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
let fetched = 0;
let skipped = 0;
let failed = 0;
for (const item of data) {
const slug = slugify(item.title);
const existing = fs.readdirSync(imgDir).find((f) => f.startsWith(slug + "."));
if (existing) {
item.image = "/images/bookmarks/" + existing;
skipped++;
continue;
}
if (item.image && item.image.startsWith("/images/")) {
skipped++;
continue;
}
let imageUrl = item.image;
if (!imageUrl || imageUrl.startsWith("http")) {
const omdbUrl = await fetchPosterFromOMDB(item.title, item.year);
if (omdbUrl) imageUrl = omdbUrl;
await delay(200);
}
if (!imageUrl) {
console.error(` SKIP ${item.title}: no image URL found`);
failed++;
continue;
}
try {
const localPath = await download(imageUrl, path.join(imgDir, slug));
console.log(` OK ${item.title} -> ${localPath}`);
item.image = localPath;
fetched++;
} catch (e) {
const omdbUrl = await fetchPosterFromOMDB(item.title, item.year);
if (omdbUrl) {
try {
const localPath = await download(omdbUrl, path.join(imgDir, slug));
console.log(` OK ${item.title} -> ${localPath} (via OMDB fallback)`);
item.image = localPath;
fetched++;
continue;
} catch {}
}
console.error(` FAIL ${item.title}: ${e.message}`);
failed++;
}
}
fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2) + "\n");
console.log(`\nDone. Fetched: ${fetched}, Skipped: ${skipped}, Failed: ${failed}`);
}
main();
+390
View File
@@ -0,0 +1,390 @@
[
{
"title": "The Pragmatic Programmer",
"author": "David Thomas, Andrew Hunt",
"type": "book",
"year": 2019,
"url": "https://www.goodreads.com/book/show/4099.The_Pragmatic_Programmer",
"image": "/images/bookmarks/the-pragmatic-programmer.jpg"
},
{
"title": "Designing Data-Intensive Applications",
"author": "Martin Kleppmann",
"type": "book",
"year": 2017,
"url": "https://www.goodreads.com/book/show/23463279-designing-data-intensive-applications",
"image": "/images/bookmarks/designing-data-intensive-applications.jpg"
},
{
"title": "Clean Code",
"author": "Robert C. Martin",
"type": "book",
"year": 2008,
"url": "https://www.goodreads.com/book/show/3735293-clean-code",
"image": "/images/bookmarks/clean-code.jpg"
},
{
"title": "The Shawshank Redemption",
"author": "Frank Darabont",
"type": "movie",
"year": 1994,
"url": "https://www.imdb.com/title/tt0111161/",
"image": "/images/bookmarks/the-shawshank-redemption.jpg"
},
{
"title": "Schindler's List",
"author": "Steven Spielberg",
"type": "movie",
"year": 1993,
"url": "https://www.imdb.com/title/tt0108052/",
"image": "/images/bookmarks/schindlers-list.jpg"
},
{
"title": "Forrest Gump",
"author": "Robert Zemeckis",
"type": "movie",
"year": 1994,
"url": "https://www.imdb.com/title/tt0109830/",
"image": "/images/bookmarks/forrest-gump.jpg"
},
{
"title": "The Green Mile",
"author": "Frank Darabont",
"type": "movie",
"year": 1999,
"url": "https://www.imdb.com/title/tt0120689/",
"image": "/images/bookmarks/the-green-mile.jpg"
},
{
"title": "Interstellar",
"author": "Christopher Nolan",
"type": "movie",
"year": 2014,
"url": "https://www.imdb.com/title/tt0816692/",
"image": "/images/bookmarks/interstellar.jpg"
},
{
"title": "Oppenheimer",
"author": "Christopher Nolan",
"type": "movie",
"year": 2023,
"url": "https://www.imdb.com/title/tt15398776/",
"image": "/images/bookmarks/oppenheimer.jpg"
},
{
"title": "The Social Network",
"author": "David Fincher",
"type": "movie",
"year": 2010,
"url": "https://www.imdb.com/title/tt1285016/",
"image": "/images/bookmarks/the-social-network.jpg"
},
{
"title": "A Beautiful Mind",
"author": "Ron Howard",
"type": "movie",
"year": 2001,
"url": "https://www.imdb.com/title/tt0268978/",
"image": "/images/bookmarks/a-beautiful-mind.jpg"
},
{
"title": "The Truman Show",
"author": "Peter Weir",
"type": "movie",
"year": 1998,
"url": "https://www.imdb.com/title/tt0120382/",
"image": "/images/bookmarks/the-truman-show.jpg"
},
{
"title": "The Grand Budapest Hotel",
"author": "Wes Anderson",
"type": "movie",
"year": 2014,
"url": "https://www.imdb.com/title/tt2278388/",
"image": "/images/bookmarks/the-grand-budapest-hotel.jpg"
},
{
"title": "Joker",
"author": "Todd Phillips",
"type": "movie",
"year": 2019,
"url": "https://www.imdb.com/title/tt7286456/",
"image": "/images/bookmarks/joker.jpg"
},
{
"title": "WALL-E",
"author": "Andrew Stanton",
"type": "movie",
"year": 2008,
"url": "https://www.imdb.com/title/tt0910970/",
"image": "/images/bookmarks/wall-e.jpg"
},
{
"title": "The Aviator",
"author": "Martin Scorsese",
"type": "movie",
"year": 2004,
"url": "https://www.imdb.com/title/tt0338751/",
"image": "/images/bookmarks/the-aviator.jpg"
},
{
"title": "Erin Brockovich",
"author": "Steven Soderbergh",
"type": "movie",
"year": 2000,
"url": "https://www.imdb.com/title/tt0195685/",
"image": "/images/bookmarks/erin-brockovich.jpg"
},
{
"title": "Green Book",
"author": "Peter Farrelly",
"type": "movie",
"year": 2018,
"url": "https://www.imdb.com/title/tt6966692/",
"image": "/images/bookmarks/green-book.jpg"
},
{
"title": "Hachi: A Dog's Tale",
"author": "Lasse Hallström",
"type": "movie",
"year": 2009,
"url": "https://www.imdb.com/title/tt1028532/",
"image": "/images/bookmarks/hachi-a-dog-s-tale.jpg"
},
{
"title": "Léon: The Professional",
"author": "Luc Besson",
"type": "movie",
"year": 1994,
"url": "https://www.imdb.com/title/tt0110413/",
"image": "/images/bookmarks/l-on-the-professional.jpg"
},
{
"title": "The Terminator",
"author": "James Cameron",
"type": "movie",
"year": 1984,
"url": "https://www.imdb.com/title/tt0088247/",
"image": "/images/bookmarks/the-terminator.jpg"
},
{
"title": "X-Men: First Class",
"author": "Matthew Vaughn",
"type": "movie",
"year": 2011,
"url": "https://www.imdb.com/title/tt1270798/",
"image": "/images/bookmarks/x-men-first-class.jpg"
},
{
"title": "Now You See Me",
"author": "Louis Leterrier",
"type": "movie",
"year": 2013,
"url": "https://www.imdb.com/title/tt1670345/",
"image": "/images/bookmarks/now-you-see-me.jpg"
},
{
"title": "Special 26",
"author": "Neeraj Pandey",
"type": "movie",
"year": 2013,
"url": "https://www.imdb.com/title/tt2377938/",
"image": "/images/bookmarks/special-26.jpg"
},
{
"title": "Drishyam",
"author": "Nishikant Kamat",
"type": "movie",
"year": 2015,
"url": "https://www.imdb.com/title/tt4430212/",
"image": "/images/bookmarks/drishyam.jpg"
},
{
"title": "Drishyam 2",
"author": "Abhishek Pathak",
"type": "movie",
"year": 2022,
"url": "https://www.imdb.com/title/tt15501640/",
"image": "/images/bookmarks/drishyam-2.jpg"
},
{
"title": "Bumblebee",
"author": "Travis Knight",
"type": "movie",
"year": 2018,
"url": "https://www.imdb.com/title/tt4701182/",
"image": "/images/bookmarks/bumblebee.jpg"
},
{
"title": "The Ministry of Ungentlemanly Warfare",
"author": "Guy Ritchie",
"type": "movie",
"year": 2024,
"url": "https://www.imdb.com/title/tt14128670/",
"image": "/images/bookmarks/the-ministry-of-ungentlemanly-warfare.jpg"
},
{
"title": "The Raid",
"author": "Gareth Evans",
"type": "movie",
"year": 2011,
"url": "https://www.imdb.com/title/tt1899353/",
"image": "/images/bookmarks/the-raid.jpg"
},
{
"title": "The Raid 2",
"author": "Gareth Evans",
"type": "movie",
"year": 2014,
"url": "https://www.imdb.com/title/tt2265171/",
"image": "/images/bookmarks/the-raid-2.jpg"
},
{
"title": "Einstein and Eddington",
"author": "Philip Martin",
"type": "movie",
"year": 2008,
"url": "https://www.imdb.com/title/tt0995036/",
"image": "/images/bookmarks/einstein-and-eddington.jpg"
},
{
"title": "Nuremberg",
"author": "Yves Simoneau",
"type": "movie",
"year": 2000,
"url": "https://www.imdb.com/title/tt0208629/",
"image": "/images/bookmarks/nuremberg.jpg"
},
{
"title": "The Fast and the Furious",
"author": "Rob Cohen",
"type": "movie",
"year": 2001,
"url": "https://www.imdb.com/title/tt0232500/",
"image": "/images/bookmarks/the-fast-and-the-furious.jpg"
},
{
"title": "Chainsaw Man the Movie: Reze Arc",
"author": "MAPPA",
"type": "anime",
"year": 2025,
"image": "/images/bookmarks/chainsaw-man-the-movie-reze-arc.jpg"
},
{
"title": "Mr. Robot",
"author": "Sam Esmail",
"type": "show",
"year": 2015,
"url": "https://www.imdb.com/title/tt4158110/",
"image": "/images/bookmarks/mr-robot.jpg"
},
{
"title": "Silicon Valley",
"author": "Mike Judge",
"type": "show",
"year": 2014,
"url": "https://www.imdb.com/title/tt2575988/",
"image": "/images/bookmarks/silicon-valley.jpg"
},
{
"title": "Masters of the Air",
"author": "John Shiban, John Orloff",
"type": "show",
"year": 2024,
"url": "https://www.imdb.com/title/tt2640044/",
"image": "/images/bookmarks/masters-of-the-air.jpg"
},
{
"title": "Blue Eye Samurai",
"author": "Amber Noizumi, Michael Green",
"type": "anime",
"year": 2023,
"url": "https://www.imdb.com/title/tt13309742/",
"image": "/images/bookmarks/blue-eye-samurai.jpg"
},
{
"title": "My Love from the Star",
"author": "Jang Tae-yoo",
"type": "show",
"year": 2013,
"url": "https://www.imdb.com/title/tt3199438/",
"image": "/images/bookmarks/my-love-from-the-star.jpg"
},
{
"title": "When Life Gives You Tangerines",
"author": "Im Hyun-wook",
"type": "show",
"year": 2025,
"image": "/images/bookmarks/when-life-gives-you-tangerines.jpg"
},
{
"title": "Midnight Diner",
"author": "Joji Matsuoka",
"type": "show",
"year": 2009,
"url": "https://www.imdb.com/title/tt5765544/",
"image": "/images/bookmarks/midnight-diner.jpg"
},
{
"title": "Panchayat",
"author": "Deepak Kumar Mishra",
"type": "show",
"year": 2020,
"url": "https://www.imdb.com/title/tt11247028/",
"image": "/images/bookmarks/panchayat.jpg"
},
{
"title": "Attack on Titan",
"author": "Hajime Isayama",
"type": "anime",
"year": 2013,
"url": "https://www.imdb.com/title/tt2560140/",
"image": "/images/bookmarks/attack-on-titan.jpg"
},
{
"title": "Death Note",
"author": "Tsugumi Ohba, Takeshi Obata",
"type": "anime",
"year": 2006,
"url": "https://www.imdb.com/title/tt0877057/",
"image": "/images/bookmarks/death-note.jpg"
},
{
"title": "Demon Slayer",
"author": "Koyoharu Gotouge",
"type": "anime",
"year": 2019,
"url": "https://www.imdb.com/title/tt9335498/",
"image": "/images/bookmarks/demon-slayer.jpg"
},
{
"title": "Solo Leveling",
"author": "Chugong",
"type": "anime",
"year": 2024,
"url": "https://www.imdb.com/title/tt21209876/",
"image": "/images/bookmarks/solo-leveling.jpg"
},
{
"title": "Sakamoto Days",
"author": "Yuto Suzuki",
"type": "anime",
"year": 2025,
"image": "/images/bookmarks/sakamoto-days.jpg"
},
{
"title": "One Punch Man",
"author": "ONE",
"type": "anime",
"year": 2015,
"url": "https://www.imdb.com/title/tt4508902/",
"image": "/images/bookmarks/one-punch-man.jpg"
},
{
"title": "Orb: On the Movements of the Earth",
"author": "Uoto",
"type": "anime",
"year": 2024,
"image": "/images/bookmarks/orb-on-the-movements-of-the-earth.jpg"
}
]
+327
View File
@@ -0,0 +1,327 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
import bookmarksData from "@/data/bookmarks.json";
interface Bookmark {
title: string;
author: string;
type: "book" | "movie" | "show" | "anime";
year: number;
url?: string;
image?: string;
note?: string;
favorite?: boolean;
}
const bookmarks = (bookmarksData as Bookmark[]).sort((a, b) => b.year - a.year);
const typeLabels: Record<string, string> = {
book: "Books",
movie: "Movies",
show: "Shows",
anime: "Anime",
};
const types = ["book", "movie", "show", "anime"] as const;
const bookCount = bookmarks.filter((b) => b.type === "book").length;
const movieCount = bookmarks.filter((b) => b.type === "movie").length;
const showCount = bookmarks.filter((b) => b.type === "show").length;
const animeCount = bookmarks.filter((b) => b.type === "anime").length;
---
<BaseLayout title="Bookmarks" description="Books, movies, and shows I've enjoyed">
<div class="bookmarks-page">
<header class="bookmarks-header">
<h1>Bookmarks</h1>
<p class="bookmarks-desc">
Books, movies, shows, and anime I think are worth your time. Starred entries are personal favorites.
</p>
</header>
<div class="stats-bar">
<button class="stat stat-total active" data-filter="all">
<svg class="stat-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
<span class="stat-value">{bookmarks.length}</span>
<span class="stat-label">all</span>
</button>
<button class="stat stat-books" data-filter="book">
<svg class="stat-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
<span class="stat-value">{bookCount}</span>
<span class="stat-label">books</span>
</button>
<button class="stat stat-movies" data-filter="movie">
<svg class="stat-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/></svg>
<span class="stat-value">{movieCount}</span>
<span class="stat-label">movies</span>
</button>
<button class="stat stat-shows" data-filter="show">
<svg class="stat-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/></svg>
<span class="stat-value">{showCount}</span>
<span class="stat-label">shows</span>
</button>
<button class="stat stat-anime" data-filter="anime">
<svg class="stat-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
<span class="stat-value">{animeCount}</span>
<span class="stat-label">anime</span>
</button>
</div>
<div class="bookmarks-grid">
{bookmarks.map((b) => (
<a
class="bookmark-card"
data-type={b.type}
href={b.url}
target="_blank"
rel="noopener noreferrer"
>
{b.image && (
<div class="bookmark-cover">
<img src={b.image} alt={b.title} loading="lazy" decoding="async" />
{b.favorite && (
<span class="bookmark-fav" title="Personal favorite">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
</span>
)}
</div>
)}
<div class="bookmark-body">
<div class="bookmark-top">
<span class="badge bookmark-type">{b.type}</span>
<span class="bookmark-year">{b.year}</span>
</div>
<h3 class="bookmark-title">{b.title}</h3>
<p class="bookmark-author">{b.author}</p>
</div>
</a>
))}
</div>
</div>
</BaseLayout>
<style>
.bookmarks-page {
max-width: var(--max-w-page);
margin-inline: auto;
}
.bookmarks-header {
margin-bottom: var(--space-8);
}
.bookmarks-header h1 {
margin-bottom: var(--space-3);
}
.bookmarks-desc {
font-size: var(--text-lg);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
max-width: var(--max-w-prose);
}
.stats-bar {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-3);
border: 1px solid var(--border);
background: var(--bg-surface);
cursor: pointer;
font-family: inherit;
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out);
}
.stat:hover {
border-color: var(--stat-color);
box-shadow: var(--shadow);
}
.stat-icon {
opacity: 0.4;
transition: opacity var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.stat:hover .stat-icon,
.stat.active .stat-icon {
opacity: 1;
color: var(--stat-color);
}
.stat-total { --stat-color: var(--accent); }
.stat-books { --stat-color: #10b981; }
.stat-movies { --stat-color: #f59e0b; }
.stat-shows { --stat-color: #8b5cf6; }
.stat-anime { --stat-color: #ef4444; }
.stat-value {
font-size: var(--text-2xl);
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--text);
line-height: 1;
transition: color var(--duration-fast) var(--ease-out);
}
.stat:hover .stat-value,
.stat.active .stat-value {
color: var(--stat-color);
}
.stat-label {
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.stat.active {
border-color: var(--stat-color);
background: color-mix(in srgb, var(--stat-color) 8%, var(--bg-surface));
}
@media (max-width: 600px) {
.stats-bar {
grid-template-columns: repeat(3, 1fr);
}
}
.bookmarks-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
}
@media (max-width: 900px) {
.bookmarks-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 500px) {
.bookmarks-grid {
grid-template-columns: 1fr;
}
}
.bookmark-card {
border: 1px solid var(--border);
background: var(--bg-surface);
overflow: hidden;
display: flex;
flex-direction: column;
text-decoration: none;
color: inherit;
cursor: pointer;
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.bookmark-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow);
}
.bookmark-card[data-hidden] {
display: none;
}
.bookmark-cover {
aspect-ratio: 2 / 3;
overflow: hidden;
background-color: var(--bg-surface-hover);
position: relative;
}
.bookmark-cover img {
width: 100%;
height: 100%;
object-fit: cover;
filter: grayscale(100%);
transition: filter var(--duration-normal) var(--ease-out);
}
.bookmark-card:hover .bookmark-cover img {
filter: grayscale(0%);
}
.bookmark-fav {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(0, 0, 0, 0.6);
color: #f59e0b;
border-radius: 50%;
}
.bookmark-body {
padding: var(--space-4) var(--space-5) var(--space-5);
}
.bookmark-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.bookmark-type {
text-transform: capitalize;
}
.bookmark-year {
font-size: var(--text-xs);
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.bookmark-title {
font-size: var(--text-base);
font-weight: 600;
margin-bottom: var(--space-1);
}
.bookmark-author {
font-size: var(--text-sm);
color: var(--text-secondary);
}
</style>
<script>
const stats = document.querySelectorAll<HTMLButtonElement>('.stat[data-filter]');
const cards = document.querySelectorAll<HTMLAnchorElement>('.bookmark-card');
stats.forEach((stat) => {
stat.addEventListener('click', () => {
stats.forEach((s) => s.classList.remove('active'));
stat.classList.add('active');
const filter = stat.dataset.filter;
cards.forEach((card) => {
if (filter === 'all' || card.dataset.type === filter) {
card.removeAttribute('data-hidden');
} else {
card.setAttribute('data-hidden', '');
}
});
});
});
</script>