fix: add modal

This commit is contained in:
Tim Rijkse
2026-01-16 09:45:38 +01:00
parent 5755d43cfc
commit 626a304169
8 changed files with 814 additions and 0 deletions

View File

@@ -60,6 +60,75 @@
Schrijf een recensie
</icon-link-button>
</action-links-list>
<content-tabs tabs="Beschrijving,Inzage,Recensies">
<book-description slot="panel-0">
<p>
Zen is opendoen bevat een selectie van zeventig columns die Dick
Verstegen schreef tussen 2002 en 2017. Hij schreef ze als
columnist voor o.a. het boeddhistisch kwartaalblad Vorm &
Leegte, het Boeddhistisch Dagblad, Centrum Waerbeke, het Han
Fortmann Centrum en de Wijkkrant van Nijmegen-Oost. Zijn columns
wijzen zonder uitzondering naar het mysterie van het leven en
geven blijk van bewogenheid en overgave, maar de lichte toets
ontbreekt nooit. Ze gaan over zeer uiteenlopende onderwerpen,
zoals: lente, de ware stem van je hart, compassie, ontroering,
woorden, de dood van zijn vrouw, de kathedraal van Royan, het
windorgel in Vlissingen, liefde, boeddhaschap, bedelen, emoties,
relaties, management, overgave, gedachten, Nepal, leerling zijn,
theekommen, zijn vader, stilte, licht en opendoen.
</p>
<p class="quote">
Uit zijn columns blijkt hoezeer hij zich ervan bewust is dat
zenboeddhisme geen mening is, maar een non-duale zienswijze.
Non-dualiteit is zien dat de dualiteiten geen hindernissen zijn
voor een bevrijd bestaan. (...) zo te spreken of te schrijven
dat het mysteriekarakter onaangetast blijft. Wat mij betreft is
Dick hierin volkomen geslaagd. Hoe hem dit gelukt is, is mij een
raadsel. Misschien wel dankzij zijn grote liefde voor de taal
die spreekt uit elke bladzijde van deze verzameling literaire
miniaturen.
</p>
<p class="author">—Nico Tydeman</p>
</book-description>
<image-gallery
slot="panel-1"
images="images/book-insight.jpg,images/book-insight.jpg,images/book-insight.jpg"
></image-gallery>
<book-reviews slot="panel-2">
<book-review-item
rating="5"
author="Maria van der Berg"
date="12 januari 2026"
>
Een prachtige verzameling columns die je aan het denken zet.
Dick Verstegen schrijft met zoveel warmte en wijsheid. Elk
stukje is een kleine meditatie op zich. Aanrader voor iedereen
die geïnteresseerd is in zen en het dagelijks leven.
</book-review-item>
<book-review-item
rating="5"
author="Jan Pietersen"
date="8 december 2025"
>
Dit boek heeft mijn kijk op zen volledig veranderd. De columns
zijn toegankelijk geschreven en toch diepgaand. Ik lees elke
avond een column voor het slapen gaan. Een boek om te koesteren.
</book-review-item>
<book-review-item
rating="4"
author="Sophie de Vries"
date="23 november 2025"
>
Mooie, poëtische teksten die je uitnodigen om stil te staan bij
het leven. Soms wat abstract, maar overall een waardevolle
toevoeging aan mijn boekenplank. De quote van Nico Tydeman op de
achterkant vat het perfect samen.
</book-review-item>
</book-reviews>
</content-tabs>
</section>
<div class="content-padding">

BIN
images/book-insight.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

View File

@@ -26,6 +26,11 @@ import "./components/book-details.js";
import "./components/icon-cta-button.js";
import "./components/icon-link-button.js";
import "./components/action-links-list.js";
import "./components/content-tabs.js";
import "./components/image-gallery.js";
import "./components/book-description.js";
import "./components/book-reviews.js";
import "./components/book-review-item.js";
// Import icon components
import "./icons/menu-icon.js";

View File

@@ -0,0 +1,53 @@
/**
* Book Description Component
* Displays formatted book description text
*/
class BookDescription extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.description {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 28px;
color: var(--color-text, #1e293b);
}
::slotted(p) {
margin: 0 0 var(--spacing-md, 1rem) 0;
}
::slotted(p:last-child) {
margin-bottom: 0;
}
::slotted(.quote) {
font-style: italic;
}
::slotted(.author) {
font-weight: 400;
}
</style>
<div class="description">
<slot></slot>
</div>
`;
}
}
customElements.define("book-description", BookDescription);

View File

@@ -0,0 +1,128 @@
/**
* Book Review Item Component
* Individual review with rating, author, date, and text
*/
class BookReviewItem extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
get rating() {
return parseInt(this.getAttribute("rating") || "5", 10);
}
get author() {
return this.getAttribute("author") || "Anoniem";
}
get date() {
return this.getAttribute("date") || "";
}
renderStars() {
const rating = Math.min(5, Math.max(0, this.rating));
const fullStars = rating;
const emptyStars = 5 - rating;
let stars = "";
for (let i = 0; i < fullStars; i++) {
stars += `<span class="star filled">★</span>`;
}
for (let i = 0; i < emptyStars; i++) {
stars += `<span class="star empty">☆</span>`;
}
return stars;
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.review {
padding-bottom: var(--spacing-lg, 1.5rem);
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
:host(:last-child) .review {
border-bottom: none;
padding-bottom: 0;
}
.review-header {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 0.25rem);
margin-bottom: var(--spacing-sm, 0.5rem);
}
.stars {
display: flex;
gap: 2px;
}
.star {
font-size: 16px;
line-height: 1;
}
.star.filled {
color: var(--color-purple, #951d51);
}
.star.empty {
color: var(--color-border, #e2e8f0);
}
.review-meta {
display: flex;
align-items: center;
gap: var(--spacing-sm, 0.5rem);
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 14px;
line-height: 20px;
}
.author {
font-weight: 500;
color: var(--color-text, #1e293b);
}
.date {
color: var(--color-text-light, #64748b);
font-weight: 300;
}
.review-text {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 26px;
color: var(--color-text, #1e293b);
margin: 0;
}
</style>
<article class="review">
<div class="review-header">
<div class="stars">${this.renderStars()}</div>
<div class="review-meta">
<span class="author">${this.author}</span>
${this.date ? `<span class="date">• ${this.date}</span>` : ""}
</div>
</div>
<p class="review-text">
<slot></slot>
</p>
</article>
`;
}
}
customElements.define("book-review-item", BookReviewItem);

View File

@@ -0,0 +1,47 @@
/**
* Book Reviews Component
* Displays customer reviews for a book
*/
class BookReviews extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.reviews-container {
display: flex;
flex-direction: column;
gap: var(--spacing-lg, 1.5rem);
}
.no-reviews {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 24px;
color: var(--color-text-light, #64748b);
text-align: center;
padding: var(--spacing-xl, 2rem);
}
</style>
<div class="reviews-container">
<slot>
<p class="no-reviews">Nog geen recensies. Wees de eerste om een recensie te schrijven!</p>
</slot>
</div>
`;
}
}
customElements.define("book-reviews", BookReviews);

View File

@@ -0,0 +1,144 @@
/**
* Content Tabs Component
* Tabbed interface for displaying different content sections
*/
class ContentTabs extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.activeTab = 0;
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
get tabs() {
const tabsAttr = this.getAttribute("tabs");
return tabsAttr ? tabsAttr.split(",").map((t) => t.trim()) : [];
}
setupEventListeners() {
const tabButtons = this.shadowRoot.querySelectorAll(".tab-button");
tabButtons.forEach((button, index) => {
button.addEventListener("click", () => {
this.setActiveTab(index);
});
});
}
setActiveTab(index) {
this.activeTab = index;
this.updateTabs();
this.dispatchEvent(
new CustomEvent("tab-change", {
bubbles: true,
composed: true,
detail: { index, tab: this.tabs[index] },
})
);
}
updateTabs() {
// Update tab buttons
const tabButtons = this.shadowRoot.querySelectorAll(".tab-button");
tabButtons.forEach((button, index) => {
button.classList.toggle("active", index === this.activeTab);
});
// Update panels
const panels = this.querySelectorAll("[slot^='panel-']");
panels.forEach((panel, index) => {
panel.style.display = index === this.activeTab ? "block" : "none";
});
}
render() {
const tabsHtml = this.tabs
.map(
(tab, index) => `
<button
class="tab-button ${index === this.activeTab ? "active" : ""}"
type="button"
role="tab"
aria-selected="${index === this.activeTab}"
>
${tab}
</button>
`
)
.join("");
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.tabs-container {
display: flex;
flex-direction: column;
}
.tab-list {
display: flex;
gap: var(--spacing-lg, 1.5rem);
border-bottom: 1px solid var(--color-border, #e2e8f0);
margin-bottom: var(--spacing-md, 1rem);
}
.tab-button {
padding: var(--spacing-sm, 0.5rem) 0;
background: none;
border: none;
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
line-height: 24px;
color: var(--color-purple, #951d51);
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
transition: opacity var(--transition-fast, 150ms ease);
position: relative;
}
.tab-button:first-child {
color: var(--color-text, #1e293b);
text-decoration: none;
font-weight: 400;
}
.tab-button.active {
color: var(--color-text, #1e293b);
text-decoration: none;
font-weight: 400;
}
.tab-button:hover {
opacity: 0.7;
}
.tab-panels {
min-height: 100px;
}
</style>
<div class="tabs-container">
<div class="tab-list" role="tablist">
${tabsHtml}
</div>
<div class="tab-panels">
<slot name="panel-0"></slot>
<slot name="panel-1"></slot>
<slot name="panel-2"></slot>
</div>
</div>
`;
// Initialize panel visibility
setTimeout(() => this.updateTabs(), 0);
}
}
customElements.define("content-tabs", ContentTabs);

View File

@@ -0,0 +1,368 @@
/**
* Image Gallery Component
* Grid of images that open in a fullscreen modal on click
*/
class ImageGallery extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
// Zoom and pan state
this.currentZoom = 1;
this.translateX = 0;
this.translateY = 0;
this.isDragging = false;
this.startX = 0;
this.startY = 0;
this.lastTranslateX = 0;
this.lastTranslateY = 0;
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
get images() {
const imagesAttr = this.getAttribute("images");
return imagesAttr ? imagesAttr.split(",").map((img) => img.trim()) : [];
}
setupEventListeners() {
const imageItems = this.shadowRoot.querySelectorAll(".gallery-image");
imageItems.forEach((img, index) => {
img.addEventListener("click", () => {
this.openModal(index);
});
});
// Modal controls
const closeBtn = this.shadowRoot.querySelector(".modal-close");
const modal = this.shadowRoot.querySelector(".modal-overlay");
if (closeBtn) {
closeBtn.addEventListener("click", () => this.closeModal());
}
if (modal) {
modal.addEventListener("click", (e) => {
if (e.target === modal) {
this.closeModal();
}
});
}
// Keyboard support
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
this.closeModal();
}
});
// Zoom controls
const zoomIn = this.shadowRoot.querySelector(".zoom-in");
const zoomOut = this.shadowRoot.querySelector(".zoom-out");
const resetZoom = this.shadowRoot.querySelector(".zoom-reset");
if (zoomIn) {
zoomIn.addEventListener("click", () => this.zoom(0.25));
}
if (zoomOut) {
zoomOut.addEventListener("click", () => this.zoom(-0.25));
}
if (resetZoom) {
resetZoom.addEventListener("click", () => this.resetZoom());
}
// Drag/pan functionality
const modalImage = this.shadowRoot.querySelector(".modal-image");
if (modalImage) {
// Mouse events
modalImage.addEventListener("mousedown", (e) => this.startDrag(e));
modalImage.addEventListener("mousemove", (e) => this.drag(e));
modalImage.addEventListener("mouseup", () => this.endDrag());
modalImage.addEventListener("mouseleave", () => this.endDrag());
// Touch events for mobile
modalImage.addEventListener("touchstart", (e) => this.startDrag(e), { passive: false });
modalImage.addEventListener("touchmove", (e) => this.drag(e), { passive: false });
modalImage.addEventListener("touchend", () => this.endDrag());
// Prevent default drag behavior
modalImage.addEventListener("dragstart", (e) => e.preventDefault());
}
}
startDrag(e) {
// Only enable drag when zoomed in
if (this.currentZoom <= 1) return;
this.isDragging = true;
if (e.type === "touchstart") {
this.startX = e.touches[0].clientX - this.translateX;
this.startY = e.touches[0].clientY - this.translateY;
} else {
this.startX = e.clientX - this.translateX;
this.startY = e.clientY - this.translateY;
}
const modalImage = this.shadowRoot.querySelector(".modal-image");
if (modalImage) {
modalImage.style.cursor = "grabbing";
modalImage.style.transition = "none";
}
}
drag(e) {
if (!this.isDragging) return;
e.preventDefault();
let clientX, clientY;
if (e.type === "touchmove") {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
this.translateX = clientX - this.startX;
this.translateY = clientY - this.startY;
this.updateImageTransform();
}
endDrag() {
if (!this.isDragging) return;
this.isDragging = false;
const modalImage = this.shadowRoot.querySelector(".modal-image");
if (modalImage) {
modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default";
modalImage.style.transition = "transform 0.2s ease";
}
}
updateImageTransform() {
const modalImage = this.shadowRoot.querySelector(".modal-image");
if (modalImage) {
modalImage.style.transform = `scale(${this.currentZoom}) translate(${this.translateX / this.currentZoom}px, ${this.translateY / this.currentZoom}px)`;
}
}
openModal(index) {
const modal = this.shadowRoot.querySelector(".modal-overlay");
const modalImage = this.shadowRoot.querySelector(".modal-image");
if (modal && modalImage && this.images[index]) {
modalImage.src = this.images[index];
// Reset zoom and pan
this.currentZoom = 1;
this.translateX = 0;
this.translateY = 0;
this.updateImageTransform();
modalImage.style.cursor = "default";
modal.classList.add("open");
document.body.style.overflow = "hidden";
}
}
closeModal() {
const modal = this.shadowRoot.querySelector(".modal-overlay");
if (modal) {
modal.classList.remove("open");
document.body.style.overflow = "";
}
}
zoom(delta) {
const modalImage = this.shadowRoot.querySelector(".modal-image");
if (modalImage) {
const prevZoom = this.currentZoom;
this.currentZoom = Math.max(1, Math.min(4, this.currentZoom + delta));
// If zooming out to 1x, reset position
if (this.currentZoom === 1) {
this.translateX = 0;
this.translateY = 0;
}
this.updateImageTransform();
modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default";
}
}
resetZoom() {
const modalImage = this.shadowRoot.querySelector(".modal-image");
if (modalImage) {
this.currentZoom = 1;
this.translateX = 0;
this.translateY = 0;
this.updateImageTransform();
modalImage.style.cursor = "default";
}
}
render() {
const imagesHtml = this.images
.map(
(img, index) => `
<button class="gallery-item" type="button" aria-label="View image ${index + 1}">
<img src="${img}" alt="Book preview ${index + 1}" class="gallery-image" />
</button>
`
)
.join("");
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-sm, 0.5rem);
}
.gallery-item {
aspect-ratio: 3/4;
overflow: hidden;
border-radius: var(--radius-sm, 0.25rem);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity var(--transition-fast, 150ms ease);
}
.gallery-item:hover {
opacity: 0.8;
}
.gallery-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Modal Styles */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.95);
z-index: 1000;
flex-direction: column;
}
.modal-overlay.open {
display: flex;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md, 1rem);
background-color: rgba(0, 0, 0, 0.5);
}
.modal-close {
width: 40px;
height: 40px;
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
}
.modal-close svg {
width: 32px;
height: 32px;
color: #ffffff;
}
.zoom-controls {
display: flex;
gap: var(--spacing-sm, 0.5rem);
}
.zoom-btn {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: var(--radius-sm, 0.25rem);
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: background var(--transition-fast, 150ms ease);
}
.zoom-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.modal-content {
flex: 1;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 1rem);
}
.modal-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.2s ease;
cursor: default;
user-select: none;
-webkit-user-drag: none;
}
</style>
<div class="gallery-grid">
${imagesHtml}
</div>
<div class="modal-overlay">
<div class="modal-header">
<div class="zoom-controls">
<button class="zoom-btn zoom-out" type="button" aria-label="Zoom out"></button>
<button class="zoom-btn zoom-reset" type="button" aria-label="Reset zoom">⟲</button>
<button class="zoom-btn zoom-in" type="button" aria-label="Zoom in">+</button>
</div>
<button class="modal-close" type="button" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-content">
<img class="modal-image" src="" alt="Full size preview" />
</div>
</div>
`;
}
}
customElements.define("image-gallery", ImageGallery);