feat: add books collection with media cards and site layout components

This commit is contained in:
greg 2025-05-21 23:06:06 +02:00
parent 8953e0851b
commit aaf0f94fe9
10 changed files with 1209 additions and 530 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
.git
.gitignore
Dockerfile
dist
npm-debug.log
.vscode
*.log
.env

View File

@ -1,11 +1,8 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
vite: {
plugins: [tailwindcss()]
}
integrations: [tailwind()]
});

View File

@ -1,5 +1,6 @@
---
title: "Dune"
author: "Frank Herbert"
year: 1965
media_type: "book"
genre: "Science Fiction"
@ -8,9 +9,9 @@ cover: "/covers/dune-placeholder.jpg"
status: "Read"
date_added: "2025-05-20"
tags: ["classic", "epic"]
author: "Frank Herbert"
pages: 412
isbn: "978-0441172719"
summary: "A classic of science fiction, Dune explores politics, religion, ecology, and human evolution on the desert planet Arrakis."
---
A masterpiece of science fiction, exploring themes of politics, religion, ecology, and human evolution on the desert planet Arrakis.
**Dune** is a masterpiece of science fiction, exploring themes of *politics*, *religion*, *ecology*, and *human evolution* on the desert planet Arrakis.

1533
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,12 +9,11 @@
"astro": "astro"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.7",
"astro": "^5.7.13",
"tailwindcss": "^4.1.7"
"tailwindcss": "^3.4.3",
"@astrojs/tailwind": "^6.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.7",
"autoprefixer": "^10.4.21"
}
}

View File

@ -1,6 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -1,25 +1,29 @@
---
// src/components/MediaCard.astro
let { title, cover, rating, url, description, author } = Astro.props; // Default destructuring for individual props
// If bookData is passed (e.g., from Alpine client-side rendering), parse it
/**
* Props:
* @prop {string} title
* @prop {string} cover
* @prop {number} rating
* @prop {string} url
* @prop {string} description
* @prop {string} author
*/
let { title, cover, rating, url, description, author } = Astro.props;
if (Astro.props.bookData) {
try {
const parsedData = JSON.parse(Astro.props.bookData);
// Try both top-level and frontmatter fields
title = parsedData.title || (parsedData.frontmatter && parsedData.frontmatter.title);
cover = parsedData.cover || (parsedData.frontmatter && parsedData.frontmatter.cover);
rating = parsedData.rating || (parsedData.frontmatter && parsedData.frontmatter.rating);
title = parsedData.title;
cover = parsedData.cover;
rating = parsedData.rating;
url = parsedData.url;
description = parsedData.description || (parsedData.frontmatter && parsedData.frontmatter.description);
author = parsedData.author || (parsedData.frontmatter && parsedData.frontmatter.author);
description = parsedData.description;
author = parsedData.author;
} catch (e) {
console.error("MediaCard: Failed to parse bookData JSON", e, Astro.props.bookData);
// Fallback to individual props or defaults will apply if parsing fails
// Fallback to individual props
}
}
const placeholderCover = "/placeholder-cover.png"; // A generic placeholder if a cover is missing
const placeholderCover = "/placeholder-cover.png";
---
<div class="bg-slate-50 border-2 border-sky-500 rounded-xl shadow-lg w-56 mx-auto p-4 my-4 transition-all duration-200 hover:shadow-2xl">
<a href={url} class="block">

View File

@ -13,54 +13,17 @@ const navTabs = [
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<slot name="head" />
<style>
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 50;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: #0ea5e9;
color: white;
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
cursor: pointer;
transition: background 0.2s;
}
.fab:hover { background: #0369a1; }
.hamburger {
width: 2rem;
height: 2rem;
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
margin-right: 1rem;
}
.hamburger span {
height: 0.25rem;
background: #334155;
margin: 0.2rem 0;
border-radius: 2px;
width: 100%;
display: block;
}
</style>
</head>
<body class="font-sans bg-slate-50 text-slate-900 min-h-screen">
<header class="mb-4 flex flex-col md:flex-row items-center p-4 bg-white shadow rounded-lg">
<div class="flex items-center w-full mb-2 md:mb-0">
<div class="hamburger" title="Menu">
<span></span>
<span></span>
<span></span>
<div class="flex flex-col justify-center cursor-pointer mr-4 w-8 h-8" title="Menu" aria-label="Open menu">
<span class="h-1 bg-slate-700 my-1 rounded block w-full"></span>
<span class="h-1 bg-slate-700 my-1 rounded block w-full"></span>
<span class="h-1 bg-slate-700 my-1 rounded block w-full"></span>
</div>
<h1 class="text-2xl font-bold text-slate-700 flex-1">{title}</h1>
</div>
@ -77,6 +40,6 @@ const navTabs = [
<main class="px-2 md:px-8">
<slot />
</main>
<a class="fab" href="#" title="Add new item">+</a>
<a class="fixed bottom-8 right-8 z-50 w-14 h-14 rounded-full bg-sky-500 text-white text-3xl flex items-center justify-center shadow-lg hover:bg-sky-700 transition-colors duration-200" href="#" aria-label="Add new item">+</a>
</body>
</html>

View File

@ -5,6 +5,7 @@ import MediaCard from '../components/MediaCard.astro';
import '../styles/global.css';
const bookImports = import.meta.glob('../../content/books/*.md');
let allBooksAstro = []; // Use let to reassign after sorting
for (const path in bookImports) {
const bookModule = await bookImports[path]();
@ -38,67 +39,35 @@ const alpineData = {
---
<SiteLayout title={pageTitle} activeTab="books">
<div class="container mx-auto px-4 py-8" x-data="page" x-init="init()">
<div class="container mx-auto px-4 py-8">
<header class="mb-8 text-center">
<h1 class="text-4xl font-bold text-slate-800">{pageTitle}</h1>
{booksFoundCount > 0 && (
<p class="text-lg text-slate-600 mt-2">
Displaying <span x-text="filteredBooks.length"></span> of {booksFoundCount} books.
Displaying {booksFoundCount} books.
</p>
)}
</header>
<!-- Filters -->
{booksFoundCount > 0 && (
<div class="mb-8 p-4 bg-slate-100 rounded-lg shadow">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<div>
<label for="genre-filter" class="block text-sm font-medium text-slate-700 mb-1">Genre</label>
<select id="genre-filter" x-model="selectedGenre" class="mt-1 block w-full py-2 px-3 border border-slate-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm">
<option value="">All Genres</option>
<template x-for="genre in genres" :key="genre">
<option :value="genre" x-text="genre"></option>
</template>
</select>
</div>
<div>
<label for="rating-filter" class="block text-sm font-medium text-slate-700 mb-1">Min. Rating</label>
<select id="rating-filter" x-model="selectedRating" class="mt-1 block w-full py-2 px-3 border border-slate-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm">
<option value="">All Ratings</option>
<template x-for="rating in ratings" :key="rating">
<option :value="rating" x-text="`${rating} Stars`"></option>
</template>
</select>
</div>
<div class="md:mt-auto">
<button @click="resetFilters()" class="w-full bg-slate-500 hover:bg-slate-600 text-white font-semibold py-2 px-4 rounded-md shadow-sm sm:text-sm">
Reset Filters
</button>
</div>
</div>
</div>
)}
<!-- Book Grid -->
{booksFoundCount > 0 ? (
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-8 md:gap-10 justify-items-center">
<template x-for="book in filteredBooks" :key="book.url">
<div class="bg-slate-50 border-2 border-sky-500 rounded-xl shadow-lg w-56 mx-auto p-4 my-4 transition-all duration-200 hover:shadow-2xl">
<a :href="book.url" class="block">
<img
:src="book.cover || '/placeholder-cover.png'"
:alt="book.title ? `Cover for ${book.title}` : 'Book cover'"
class="w-full h-24 object-contain bg-gray-100 rounded-t-lg"
@error="event.target.src='/placeholder-cover.png'"
/>
</a>
<div class="p-2">
<h3 class="text-sm font-semibold text-slate-800 mb-1 truncate">
<a :href="book.url" class="hover:text-sky-600" x-text="book.title"></a>
</h3>
<template x-if="book.author">
<p class="text-xs text-slate-500 mb-1" x-text="`by ${book.author}`"></p>
</template>
{allBooksAstro.map(book => (
<MediaCard
title={book.title}
cover={book.cover}
rating={book.rating}
url={book.url}
description={book.description}
author={book.author}
/>
))}
</div>
) : (
<p class="text-center text-slate-500">No books found.</p>
)}
</div>
</SiteLayout>
<template x-if="book.rating">
<p class="text-xs text-slate-600 mb-1" x-text="`Rating: ${book.rating}/5`"></p>
</template>

View File

@ -2,12 +2,12 @@
import { Markdown } from 'astro/components';
export async function getStaticPaths() {
console.log('Running getStaticPaths...');
// console.log (removed for production)('Running getStaticPaths...');
// Define glob inside the function
const bookImports = import.meta.glob('/content/books/*.md');
const paths = Object.keys(bookImports);
console.log('Found file paths:', paths);
// console.log (removed for production)('Found file paths:', paths);
// Process all matched files
const books = await Promise.all(
@ -18,8 +18,8 @@ export async function getStaticPaths() {
const filename = path.split('/').pop() || '';
const slug = filename.replace(/\.md$/, '');
console.log(`File: ${filename}, Generated slug: ${slug}`);
console.log(`Debug: Full path=${path}, Normalized URL would be: /books/${slug}`);
// console.log (removed for production)(`File: ${filename}, Generated slug: ${slug}`);
// console.log (removed for production)(`Debug: Full path=${path}, Normalized URL would be: /books/${slug}`);
return {
...mod,
@ -34,7 +34,7 @@ export async function getStaticPaths() {
props: { book }
}));
console.log('Generated routes:', routes.map(r => r.params.slug));
// console.log (removed for production)('Generated routes:', routes.map(r => r.params.slug));
return routes;
}
@ -50,15 +50,15 @@ const { frontmatter, compiledContent } = book;
<main class="max-w-2xl mx-auto p-8">
<a href="/books" class="text-sky-600 hover:underline">← Back to Books</a>
<div class="bg-white rounded-xl shadow-lg p-6 mt-6 flex flex-col items-center">
<img src={frontmatter.cover || '/placeholder-cover.png'} alt={frontmatter.title} class="w-24 h-32 object-contain bg-gray-100 rounded mb-4 shadow" />
<h1 class="text-3xl font-bold mb-2 text-center">{frontmatter.title}</h1>
<p class="text-slate-500 mb-4 text-center">by {frontmatter.author}</p>
<p class="mb-2"><strong>Genre:</strong> {frontmatter.genre}</p>
<p class="mb-2"><strong>Rating:</strong> {frontmatter.rating}/5</p>
<p class="mb-2"><strong>Year:</strong> {frontmatter.year}</p>
<p class="mb-2"><strong>Status:</strong> {frontmatter.status}</p>
<p class="mb-2"><strong>Pages:</strong> {frontmatter.pages}</p>
<p class="mb-2"><strong>ISBN:</strong> {frontmatter.isbn}</p>
<img src={frontmatter.cover || '/placeholder-cover.png'} alt={frontmatter.title || 'Book cover'} class="w-24 h-32 object-contain bg-gray-100 rounded mb-4 shadow" />
<h1 class="text-3xl font-bold mb-2 text-center">{frontmatter.title || 'Untitled Book'}</h1>
{frontmatter.author && <p class="text-slate-500 mb-4 text-center">by {frontmatter.author}</p>}
{frontmatter.genre && <p class="mb-2"><strong>Genre:</strong> {frontmatter.genre}</p>}
{frontmatter.rating && <p class="mb-2"><strong>Rating:</strong> {frontmatter.rating}/5</p>}
{frontmatter.year && <p class="mb-2"><strong>Year:</strong> {frontmatter.year}</p>}
{frontmatter.status && <p class="mb-2"><strong>Status:</strong> {frontmatter.status}</p>}
{frontmatter.pages && <p class="mb-2"><strong>Pages:</strong> {frontmatter.pages}</p>}
{frontmatter.isbn && <p class="mb-2"><strong>ISBN:</strong> {frontmatter.isbn}</p>}
<div class="prose mt-6">
<book.Content />
</div>