feat: add bookmarks page with image fetcher
Assisted by Claude Code Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||