feat: add books collection with media cards and site layout components
This commit is contained in:
parent
8953e0851b
commit
aaf0f94fe9
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@ -1,11 +1,8 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwind from '@astrojs/tailwind';
|
||||||
|
|
||||||
// https://astro.build/config
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
vite: {
|
integrations: [tailwind()]
|
||||||
plugins: [tailwindcss()]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Dune"
|
title: "Dune"
|
||||||
|
author: "Frank Herbert"
|
||||||
year: 1965
|
year: 1965
|
||||||
media_type: "book"
|
media_type: "book"
|
||||||
genre: "Science Fiction"
|
genre: "Science Fiction"
|
||||||
@ -8,9 +9,9 @@ cover: "/covers/dune-placeholder.jpg"
|
|||||||
status: "Read"
|
status: "Read"
|
||||||
date_added: "2025-05-20"
|
date_added: "2025-05-20"
|
||||||
tags: ["classic", "epic"]
|
tags: ["classic", "epic"]
|
||||||
author: "Frank Herbert"
|
|
||||||
pages: 412
|
pages: 412
|
||||||
isbn: "978-0441172719"
|
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
1533
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,12 +9,11 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
|
||||||
"astro": "^5.7.13",
|
"astro": "^5.7.13",
|
||||||
"tailwindcss": "^4.1.7"
|
"tailwindcss": "^3.4.3",
|
||||||
|
"@astrojs/tailwind": "^6.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
|
||||||
"autoprefixer": "^10.4.21"
|
"autoprefixer": "^10.4.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,25 +1,29 @@
|
|||||||
---
|
---
|
||||||
// src/components/MediaCard.astro
|
// src/components/MediaCard.astro
|
||||||
let { title, cover, rating, url, description, author } = Astro.props; // Default destructuring for individual props
|
/**
|
||||||
|
* Props:
|
||||||
// If bookData is passed (e.g., from Alpine client-side rendering), parse it
|
* @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) {
|
if (Astro.props.bookData) {
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(Astro.props.bookData);
|
const parsedData = JSON.parse(Astro.props.bookData);
|
||||||
// Try both top-level and frontmatter fields
|
title = parsedData.title;
|
||||||
title = parsedData.title || (parsedData.frontmatter && parsedData.frontmatter.title);
|
cover = parsedData.cover;
|
||||||
cover = parsedData.cover || (parsedData.frontmatter && parsedData.frontmatter.cover);
|
rating = parsedData.rating;
|
||||||
rating = parsedData.rating || (parsedData.frontmatter && parsedData.frontmatter.rating);
|
|
||||||
url = parsedData.url;
|
url = parsedData.url;
|
||||||
description = parsedData.description || (parsedData.frontmatter && parsedData.frontmatter.description);
|
description = parsedData.description;
|
||||||
author = parsedData.author || (parsedData.frontmatter && parsedData.frontmatter.author);
|
author = parsedData.author;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("MediaCard: Failed to parse bookData JSON", e, Astro.props.bookData);
|
// Fallback to individual props
|
||||||
// Fallback to individual props or defaults will apply if parsing fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const placeholderCover = "/placeholder-cover.png";
|
||||||
const placeholderCover = "/placeholder-cover.png"; // A generic placeholder if a cover is missing
|
|
||||||
---
|
---
|
||||||
<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">
|
<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">
|
<a href={url} class="block">
|
||||||
|
|||||||
@ -13,54 +13,17 @@ const navTabs = [
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
||||||
<slot name="head" />
|
<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>
|
</head>
|
||||||
<body class="font-sans bg-slate-50 text-slate-900 min-h-screen">
|
<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">
|
<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="flex items-center w-full mb-2 md:mb-0">
|
||||||
<div class="hamburger" title="Menu">
|
<div class="flex flex-col justify-center cursor-pointer mr-4 w-8 h-8" title="Menu" aria-label="Open menu">
|
||||||
<span></span>
|
<span class="h-1 bg-slate-700 my-1 rounded block w-full"></span>
|
||||||
<span></span>
|
<span class="h-1 bg-slate-700 my-1 rounded block w-full"></span>
|
||||||
<span></span>
|
<span class="h-1 bg-slate-700 my-1 rounded block w-full"></span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold text-slate-700 flex-1">{title}</h1>
|
<h1 class="text-2xl font-bold text-slate-700 flex-1">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
@ -77,6 +40,6 @@ const navTabs = [
|
|||||||
<main class="px-2 md:px-8">
|
<main class="px-2 md:px-8">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import MediaCard from '../components/MediaCard.astro';
|
|||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
|
|
||||||
const bookImports = import.meta.glob('../../content/books/*.md');
|
const bookImports = import.meta.glob('../../content/books/*.md');
|
||||||
|
|
||||||
let allBooksAstro = []; // Use let to reassign after sorting
|
let allBooksAstro = []; // Use let to reassign after sorting
|
||||||
for (const path in bookImports) {
|
for (const path in bookImports) {
|
||||||
const bookModule = await bookImports[path]();
|
const bookModule = await bookImports[path]();
|
||||||
@ -38,67 +39,35 @@ const alpineData = {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<SiteLayout title={pageTitle} activeTab="books">
|
<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">
|
<header class="mb-8 text-center">
|
||||||
<h1 class="text-4xl font-bold text-slate-800">{pageTitle}</h1>
|
<h1 class="text-4xl font-bold text-slate-800">{pageTitle}</h1>
|
||||||
{booksFoundCount > 0 && (
|
{booksFoundCount > 0 && (
|
||||||
<p class="text-lg text-slate-600 mt-2">
|
<p class="text-lg text-slate-600 mt-2">
|
||||||
Displaying <span x-text="filteredBooks.length"></span> of {booksFoundCount} books.
|
Displaying {booksFoundCount} books.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</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 -->
|
<!-- Book Grid -->
|
||||||
{booksFoundCount > 0 ? (
|
{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">
|
<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">
|
{allBooksAstro.map(book => (
|
||||||
<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">
|
<MediaCard
|
||||||
<a :href="book.url" class="block">
|
title={book.title}
|
||||||
<img
|
cover={book.cover}
|
||||||
:src="book.cover || '/placeholder-cover.png'"
|
rating={book.rating}
|
||||||
:alt="book.title ? `Cover for ${book.title}` : 'Book cover'"
|
url={book.url}
|
||||||
class="w-full h-24 object-contain bg-gray-100 rounded-t-lg"
|
description={book.description}
|
||||||
@error="event.target.src='/placeholder-cover.png'"
|
author={book.author}
|
||||||
/>
|
/>
|
||||||
</a>
|
))}
|
||||||
<div class="p-2">
|
</div>
|
||||||
<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>
|
<p class="text-center text-slate-500">No books found.</p>
|
||||||
</h3>
|
)}
|
||||||
<template x-if="book.author">
|
</div>
|
||||||
<p class="text-xs text-slate-500 mb-1" x-text="`by ${book.author}`"></p>
|
</SiteLayout>
|
||||||
</template>
|
|
||||||
<template x-if="book.rating">
|
<template x-if="book.rating">
|
||||||
<p class="text-xs text-slate-600 mb-1" x-text="`Rating: ${book.rating}/5`"></p>
|
<p class="text-xs text-slate-600 mb-1" x-text="`Rating: ${book.rating}/5`"></p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -2,12 +2,12 @@
|
|||||||
import { Markdown } from 'astro/components';
|
import { Markdown } from 'astro/components';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
console.log('Running getStaticPaths...');
|
// console.log (removed for production)('Running getStaticPaths...');
|
||||||
// Define glob inside the function
|
// Define glob inside the function
|
||||||
const bookImports = import.meta.glob('/content/books/*.md');
|
const bookImports = import.meta.glob('/content/books/*.md');
|
||||||
const paths = Object.keys(bookImports);
|
const paths = Object.keys(bookImports);
|
||||||
|
|
||||||
console.log('Found file paths:', paths);
|
// console.log (removed for production)('Found file paths:', paths);
|
||||||
|
|
||||||
// Process all matched files
|
// Process all matched files
|
||||||
const books = await Promise.all(
|
const books = await Promise.all(
|
||||||
@ -18,8 +18,8 @@ export async function getStaticPaths() {
|
|||||||
const filename = path.split('/').pop() || '';
|
const filename = path.split('/').pop() || '';
|
||||||
const slug = filename.replace(/\.md$/, '');
|
const slug = filename.replace(/\.md$/, '');
|
||||||
|
|
||||||
console.log(`File: ${filename}, Generated slug: ${slug}`);
|
// console.log (removed for production)(`File: ${filename}, Generated slug: ${slug}`);
|
||||||
console.log(`Debug: Full path=${path}, Normalized URL would be: /books/${slug}`);
|
// console.log (removed for production)(`Debug: Full path=${path}, Normalized URL would be: /books/${slug}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mod,
|
...mod,
|
||||||
@ -34,7 +34,7 @@ export async function getStaticPaths() {
|
|||||||
props: { book }
|
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;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,15 +50,15 @@ const { frontmatter, compiledContent } = book;
|
|||||||
<main class="max-w-2xl mx-auto p-8">
|
<main class="max-w-2xl mx-auto p-8">
|
||||||
<a href="/books" class="text-sky-600 hover:underline">← Back to Books</a>
|
<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">
|
<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" />
|
<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}</h1>
|
<h1 class="text-3xl font-bold mb-2 text-center">{frontmatter.title || 'Untitled Book'}</h1>
|
||||||
<p class="text-slate-500 mb-4 text-center">by {frontmatter.author}</p>
|
{frontmatter.author && <p class="text-slate-500 mb-4 text-center">by {frontmatter.author}</p>}
|
||||||
<p class="mb-2"><strong>Genre:</strong> {frontmatter.genre}</p>
|
{frontmatter.genre && <p class="mb-2"><strong>Genre:</strong> {frontmatter.genre}</p>}
|
||||||
<p class="mb-2"><strong>Rating:</strong> {frontmatter.rating}/5</p>
|
{frontmatter.rating && <p class="mb-2"><strong>Rating:</strong> {frontmatter.rating}/5</p>}
|
||||||
<p class="mb-2"><strong>Year:</strong> {frontmatter.year}</p>
|
{frontmatter.year && <p class="mb-2"><strong>Year:</strong> {frontmatter.year}</p>}
|
||||||
<p class="mb-2"><strong>Status:</strong> {frontmatter.status}</p>
|
{frontmatter.status && <p class="mb-2"><strong>Status:</strong> {frontmatter.status}</p>}
|
||||||
<p class="mb-2"><strong>Pages:</strong> {frontmatter.pages}</p>
|
{frontmatter.pages && <p class="mb-2"><strong>Pages:</strong> {frontmatter.pages}</p>}
|
||||||
<p class="mb-2"><strong>ISBN:</strong> {frontmatter.isbn}</p>
|
{frontmatter.isbn && <p class="mb-2"><strong>ISBN:</strong> {frontmatter.isbn}</p>}
|
||||||
<div class="prose mt-6">
|
<div class="prose mt-6">
|
||||||
<book.Content />
|
<book.Content />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user