fix: initial commit

This commit is contained in:
Tim Rijkse
2026-01-14 15:05:12 +01:00
commit dc06a33a72
16 changed files with 2621 additions and 0 deletions

211
js/components/book-card.js Normal file
View File

@@ -0,0 +1,211 @@
/**
* Book Card Component
* Reusable card displaying book thumbnail, title, author, and price
*/
class BookCard extends HTMLElement {
static get observedAttributes() {
return ['title', 'author', 'price', 'image', 'href', 'rating'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
if (this.shadowRoot) {
this.render();
}
}
get title() {
return this.getAttribute('title') || 'Book Title';
}
get author() {
return this.getAttribute('author') || 'Author Name';
}
get price() {
return this.getAttribute('price') || '$0.00';
}
get image() {
return this.getAttribute('image') || '';
}
get href() {
return this.getAttribute('href') || 'book.html';
}
get rating() {
return parseFloat(this.getAttribute('rating')) || 0;
}
renderStars(rating) {
const fullStars = Math.floor(rating);
const hasHalf = rating % 1 >= 0.5;
const emptyStars = 5 - fullStars - (hasHalf ? 1 : 0);
let stars = '';
// Full stars
for (let i = 0; i < fullStars; i++) {
stars += `<svg class="star star-full" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>`;
}
// Half star
if (hasHalf) {
stars += `<svg class="star star-half" viewBox="0 0 24 24">
<defs>
<linearGradient id="halfGrad">
<stop offset="50%" stop-color="currentColor"/>
<stop offset="50%" stop-color="transparent"/>
</linearGradient>
</defs>
<path fill="url(#halfGrad)" stroke="currentColor" stroke-width="1" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>`;
}
// Empty stars
for (let i = 0; i < emptyStars; i++) {
stars += `<svg class="star star-empty" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>`;
}
return stars;
}
render() {
// Generate placeholder image if none provided
const imageHtml = this.image
? `<img src="${this.image}" alt="${this.title}" class="book-image">`
: `<div class="book-image placeholder">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
</div>`;
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.card {
display: block;
text-decoration: none;
color: inherit;
background-color: var(--color-background, #ffffff);
border-radius: var(--radius-lg, 0.75rem);
overflow: hidden;
transition: transform var(--transition-fast, 150ms ease),
box-shadow var(--transition-fast, 150ms ease);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1));
}
.card:active {
transform: translateY(0);
}
.image-container {
position: relative;
aspect-ratio: 3 / 4;
background-color: var(--color-background-tertiary, #f1f5f9);
overflow: hidden;
}
.book-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.book-image.placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-light, #64748b);
}
.book-image.placeholder svg {
width: 48px;
height: 48px;
opacity: 0.5;
}
.content {
padding: var(--spacing-sm, 0.5rem);
}
.title {
font-size: var(--font-size-sm, 0.875rem);
font-weight: var(--font-weight-semibold, 600);
color: var(--color-text, #1e293b);
margin-bottom: var(--spacing-xs, 0.25rem);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: var(--line-height-tight, 1.25);
}
.author {
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-light, #64748b);
margin-bottom: var(--spacing-xs, 0.25rem);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rating {
display: flex;
align-items: center;
gap: 2px;
margin-bottom: var(--spacing-xs, 0.25rem);
}
.star {
width: 12px;
height: 12px;
color: var(--color-accent, #f59e0b);
}
.star-empty {
color: var(--color-border, #e2e8f0);
}
.price {
font-size: var(--font-size-base, 1rem);
font-weight: var(--font-weight-bold, 700);
color: var(--color-primary, #2563eb);
}
</style>
<a href="${this.href}" class="card">
<div class="image-container">
${imageHtml}
</div>
<div class="content">
<h3 class="title">${this.title}</h3>
<p class="author">${this.author}</p>
${this.rating > 0 ? `<div class="rating">${this.renderStars(this.rating)}</div>` : ''}
<p class="price">${this.price}</p>
</div>
</a>
`;
}
}
customElements.define('book-card', BookCard);

View File

@@ -0,0 +1,113 @@
/**
* Horizontal Scroll Nav Component
* Pill-style category buttons in a horizontal scroller
*/
class HorizontalScrollNav extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.categories = [
{ id: 'all', label: 'All', active: true },
{ id: 'fiction', label: 'Fiction', active: false },
{ id: 'non-fiction', label: 'Non-Fiction', active: false },
{ id: 'mystery', label: 'Mystery', active: false },
{ id: 'romance', label: 'Romance', active: false },
{ id: 'sci-fi', label: 'Sci-Fi', active: false },
{ id: 'biography', label: 'Biography', active: false },
{ id: 'history', label: 'History', active: false },
];
}
connectedCallback() {
this.render();
this.addEventListeners();
}
addEventListeners() {
const buttons = this.shadowRoot.querySelectorAll('.nav-pill');
buttons.forEach(button => {
button.addEventListener('click', (e) => {
this.setActiveCategory(e.target.dataset.id);
});
});
}
setActiveCategory(id) {
this.categories = this.categories.map(cat => ({
...cat,
active: cat.id === id
}));
this.render();
this.addEventListeners();
// Dispatch custom event for parent components
this.dispatchEvent(new CustomEvent('category-change', {
detail: { categoryId: id },
bubbles: true,
composed: true
}));
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.nav-container {
display: flex;
gap: var(--spacing-sm, 0.5rem);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
padding: var(--spacing-xs, 0.25rem) 0;
}
.nav-container::-webkit-scrollbar {
display: none;
}
.nav-pill {
flex-shrink: 0;
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
font-size: var(--font-size-sm, 0.875rem);
font-weight: var(--font-weight-medium, 500);
color: var(--color-text-light, #64748b);
background-color: var(--color-background-tertiary, #f1f5f9);
border: none;
border-radius: var(--radius-full, 9999px);
cursor: pointer;
transition: all var(--transition-fast, 150ms ease);
white-space: nowrap;
}
.nav-pill:hover {
background-color: var(--color-border, #e2e8f0);
}
.nav-pill.active {
color: var(--color-text-inverse, #ffffff);
background-color: var(--color-primary, #2563eb);
}
.nav-pill.active:hover {
background-color: var(--color-primary-dark, #1d4ed8);
}
</style>
<nav class="nav-container" role="navigation" aria-label="Book categories">
${this.categories.map(cat => `
<button
class="nav-pill ${cat.active ? 'active' : ''}"
data-id="${cat.id}"
${cat.active ? 'aria-current="true"' : ''}
>
${cat.label}
</button>
`).join('')}
</nav>
`;
}
}
customElements.define('horizontal-scroll-nav', HorizontalScrollNav);

View File

@@ -0,0 +1,98 @@
/**
* Search Bar Component
* Search input with icon
*/
class SearchBar extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.addEventListeners();
}
addEventListeners() {
const input = this.shadowRoot.querySelector('.search-input');
const form = this.shadowRoot.querySelector('.search-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('search', {
detail: { query: input.value },
bubbles: true,
composed: true
}));
});
input.addEventListener('input', (e) => {
this.dispatchEvent(new CustomEvent('search-input', {
detail: { query: e.target.value },
bubbles: true,
composed: true
}));
});
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.search-form {
display: flex;
align-items: center;
position: relative;
}
.search-icon {
position: absolute;
left: var(--spacing-md, 1rem);
width: 20px;
height: 20px;
color: var(--color-text-light, #64748b);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
padding-left: calc(var(--spacing-md, 1rem) + 20px + var(--spacing-sm, 0.5rem));
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text, #1e293b);
background-color: var(--color-background-tertiary, #f1f5f9);
border: 1px solid transparent;
border-radius: var(--radius-lg, 0.75rem);
outline: none;
transition: all var(--transition-fast, 150ms ease);
}
.search-input::placeholder {
color: var(--color-text-light, #64748b);
}
.search-input:focus {
background-color: var(--color-background, #ffffff);
border-color: var(--color-primary, #2563eb);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
</style>
<form class="search-form" role="search">
<svg class="search-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
type="search"
class="search-input"
placeholder="Search books, authors..."
aria-label="Search books and authors"
>
</form>
`;
}
}
customElements.define('search-bar', SearchBar);

View File

@@ -0,0 +1,35 @@
/**
* Site Content Component
* Main content area wrapper with slot for page content
*/
class SiteContent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
flex: 1;
min-height: 0;
}
.content {
padding: var(--spacing-md, 1rem);
}
</style>
<main class="content">
<slot></slot>
</main>
`;
}
}
customElements.define('site-content', SiteContent);

View File

@@ -0,0 +1,111 @@
/**
* Site Footer Component
* Footer with navigation links, social icons, and copyright
*/
class SiteFooter extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
background-color: var(--color-background-secondary, #f8fafc);
border-top: 1px solid var(--color-border, #e2e8f0);
}
.footer {
padding: var(--spacing-lg, 1.5rem) var(--spacing-md, 1rem);
}
.footer-nav {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md, 1rem);
margin-bottom: var(--spacing-lg, 1.5rem);
}
.footer-link {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-light, #64748b);
text-decoration: none;
transition: color var(--transition-fast, 150ms ease);
}
.footer-link:hover {
color: var(--color-primary, #2563eb);
}
.social-icons {
display: flex;
gap: var(--spacing-md, 1rem);
margin-bottom: var(--spacing-lg, 1.5rem);
}
.social-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: var(--color-text-light, #64748b);
background-color: var(--color-background, #ffffff);
border-radius: var(--radius-full, 9999px);
transition: all var(--transition-fast, 150ms ease);
}
.social-icon:hover {
color: var(--color-primary, #2563eb);
background-color: var(--color-background-tertiary, #f1f5f9);
}
.social-icon svg {
width: 20px;
height: 20px;
}
.copyright {
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-light, #64748b);
text-align: center;
}
</style>
<footer class="footer">
<nav class="footer-nav">
<a href="#" class="footer-link">About Us</a>
<a href="#" class="footer-link">Contact</a>
<a href="#" class="footer-link">FAQ</a>
<a href="#" class="footer-link">Privacy Policy</a>
<a href="#" class="footer-link">Terms of Service</a>
</nav>
<div class="social-icons">
<a href="#" class="social-icon" aria-label="Facebook">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</a>
<a href="#" class="social-icon" aria-label="Twitter">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
<a href="#" class="social-icon" aria-label="Instagram">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
</svg>
</a>
</div>
<p class="copyright">&copy; 2026 BookStore. All rights reserved.</p>
</footer>
`;
}
}
customElements.define('site-footer', SiteFooter);

View File

@@ -0,0 +1,41 @@
/**
* Site Header Component
* Sticky header container that holds top-bar, navigation, and search
*/
class SiteHeader extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: sticky;
top: 0;
z-index: 100;
background-color: var(--color-background, #ffffff);
}
.header {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 0.5rem);
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
</style>
<header class="header">
<slot></slot>
</header>
`;
}
}
customElements.define('site-header', SiteHeader);

110
js/components/top-bar.js Normal file
View File

@@ -0,0 +1,110 @@
/**
* Top Bar Component
* Contains logo, menu icon, and cart icon
*/
class TopBar extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
}
.logo {
font-size: var(--font-size-xl, 1.25rem);
font-weight: var(--font-weight-bold, 700);
color: var(--color-text, #1e293b);
text-decoration: none;
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing-sm, 0.5rem);
}
.icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-md, 0.5rem);
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text, #1e293b);
transition: background-color var(--transition-fast, 150ms ease);
}
.icon-button:hover {
background-color: var(--color-background-tertiary, #f1f5f9);
}
.icon-button:active {
background-color: var(--color-border, #e2e8f0);
}
.icon-button svg {
width: 24px;
height: 24px;
}
.cart-badge {
position: relative;
}
.badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: var(--font-size-xs, 0.75rem);
font-weight: var(--font-weight-semibold, 600);
color: var(--color-text-inverse, #ffffff);
background-color: var(--color-primary, #2563eb);
border-radius: var(--radius-full, 9999px);
display: flex;
align-items: center;
justify-content: center;
}
</style>
<div class="top-bar">
<a href="index.html" class="logo">BookStore</a>
<div class="actions">
<button class="icon-button" aria-label="Menu">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<button class="icon-button cart-badge" aria-label="Shopping cart">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
<span class="badge">2</span>
</button>
</div>
</div>
`;
}
}
customElements.define('top-bar', TopBar);