// ===========================
// SINGLE PAGE VIEWER COMPONENT
// ===========================
// Single Page Viewer Web Component
export class SinglePageViewer extends HTMLElement {
constructor() {
super();
// No shadow DOM - use regular DOM to allow Tailwind CSS
this.resizeObserver = null;
}
// Dynamically detect sidebar width in pixels
detectSidebarWidth() {
// Find the actual sidebar element in the current layout
const sidebar = document.querySelector('.lg\\:w-1\\/4, .lg\\:w-1\\/3, [class*="lg:w-1/"]');
if (sidebar) {
const sidebarRect = sidebar.getBoundingClientRect();
const sidebarWidth = sidebarRect.width;
console.log("Detected sidebar width:", sidebarWidth, "px");
return `${sidebarWidth}px`;
}
// Fallback: calculate based on viewport width and responsive breakpoints
const viewportWidth = window.innerWidth;
if (viewportWidth < 1024) {
// Below lg breakpoint - no sidebar space needed
return "0px";
} else if (viewportWidth < 1280) {
// lg breakpoint: assume 1/4 of viewport (similar to both layouts)
return `${Math.floor(viewportWidth * 0.25)}px`;
} else {
// xl breakpoint: assume 1/5 of viewport (narrower on larger screens)
return `${Math.floor(viewportWidth * 0.2)}px`;
}
}
connectedCallback() {
// Detect sidebar width dynamically
const sidebarWidth = this.detectSidebarWidth();
this.innerHTML = `
`;
// Set up resize observer to handle window resizing
this.setupResizeObserver();
// Set up keyboard navigation
this.setupKeyboardNavigation();
}
// Set up resize observer to dynamically update sidebar width
setupResizeObserver() {
// Clean up existing observer
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
// Create new resize observer
this.resizeObserver = new ResizeObserver(() => {
this.updateSidebarWidth();
});
// Observe window resizing by watching the document body
this.resizeObserver.observe(document.body);
}
// Update sidebar width when window is resized
updateSidebarWidth() {
const sidebarSpacer = this.querySelector("#sidebar-spacer");
if (sidebarSpacer && !sidebarSpacer.style.width.includes("0px")) {
// Only update if sidebar is not collapsed (not 0px width)
const newWidth = this.detectSidebarWidth();
sidebarSpacer.style.width = newWidth;
console.log("Updated sidebar width to:", newWidth);
}
}
show(
imgSrc,
imgAlt,
pageNumber,
isBeilage = false,
targetPage = 0,
partNumber = null,
extractedIconType = null,
extractedHeading = null,
) {
const img = this.querySelector("#single-page-image");
const pageNumberSpan = this.querySelector("#page-number");
const pageIconSpan = this.querySelector("#page-icon");
const pageIndicator = this.querySelector("#page-indicator");
img.src = imgSrc;
img.alt = imgAlt;
// Store current page info for button actions
this.currentPageNumber = pageNumber;
this.currentIsBeilage = isBeilage;
this.currentPartNumber = partNumber;
// Use extracted heading or fallback to generated heading
let headingText;
if (extractedHeading) {
headingText = extractedHeading;
} else {
// Fallback: generate heading text
const issueContext = this.getIssueContext(pageNumber);
headingText = issueContext ? `${issueContext}, ${pageNumber}` : `${pageNumber}`;
}
// Set page number with heading text in the box
pageNumberSpan.innerHTML = headingText;
// Add red dot if this is the target page
if (targetPage && pageNumber === targetPage) {
pageNumberSpan.style.position = "relative";
// Remove any existing red dot
const existingDot = pageNumberSpan.querySelector(".target-page-dot");
if (existingDot) {
existingDot.remove();
}
// Add new red dot
const redDot = document.createElement("span");
redDot.className =
"target-page-dot absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full z-10";
redDot.title = "verlinkte Seite";
pageNumberSpan.appendChild(redDot);
}
// Use extracted icon type or fallback to generated icon
if (extractedIconType) {
if (extractedIconType === "part-number" && partNumber !== null) {
// Piece view: Show part number instead of icon
pageIconSpan.innerHTML = `${partNumber}. Teil`;
} else {
// Use icon type from Go templates
pageIconSpan.innerHTML = this.generateIconFromType(extractedIconType);
}
} else {
// Fallback: generate simple icon
pageIconSpan.innerHTML = this.generateFallbackIcon(pageNumber, isBeilage, partNumber);
}
// Page indicator styling is now consistent (white background)
// Update navigation button visibility
this.updateNavigationButtons();
this.style.display = "block";
// Scroll to top of the single page viewer (no smooth scrolling)
const scrollContainer = this.querySelector(".flex-1.overflow-auto");
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
// Prevent background scrolling but allow scrolling within the viewer
document.body.style.overflow = "hidden";
}
close() {
this.style.display = "none";
// Restore background scrolling
document.body.style.overflow = "";
}
disconnectedCallback() {
// Clean up resize observer
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// Clean up keyboard event listener
if (this.keyboardHandler) {
document.removeEventListener('keydown', this.keyboardHandler);
this.keyboardHandler = null;
}
// Restore background scrolling
document.body.style.overflow = "";
}
// Generate icon HTML from Go icon type - matches templating/engine.go PageIcon function
generateIconFromType(iconType) {
switch (iconType) {
case "first":
return ``;
case "last":
return ``;
case "even":
return ``;
case "odd":
return ``;
case "single":
return ``;
default:
return ``;
}
}
// Set up keyboard navigation
setupKeyboardNavigation() {
// Remove any existing listener to avoid duplicates
if (this.keyboardHandler) {
document.removeEventListener('keydown', this.keyboardHandler);
}
// Create bound handler
this.keyboardHandler = (event) => {
// Only handle keyboard events when the viewer is visible
if (this.style.display === 'none') return;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
this.goToPreviousPage();
break;
case 'ArrowRight':
event.preventDefault();
this.goToNextPage();
break;
case 'Escape':
event.preventDefault();
this.close();
break;
}
};
// Add event listener
document.addEventListener('keydown', this.keyboardHandler);
}
// Share current page
shareCurrentPage() {
if (typeof copyPagePermalink === "function") {
// Use the actual button element
const shareBtn = this.querySelector("#share-btn");
copyPagePermalink(this.currentPageNumber, shareBtn, this.currentIsBeilage);
}
}
// Generate citation for current page
generatePageCitation() {
if (typeof generatePageCitation === "function") {
// Use the actual button element
const citeBtn = this.querySelector("#cite-btn");
generatePageCitation(this.currentPageNumber, citeBtn);
}
}
// Update navigation button visibility based on available pages
updateNavigationButtons() {
const prevBtn = this.querySelector("#prev-page-btn");
const nextBtn = this.querySelector("#next-page-btn");
const { prevPage, nextPage } = this.getAdjacentPages();
// Enable/disable previous page button
if (prevPage !== null) {
prevBtn.disabled = false;
prevBtn.className = prevBtn.className.replace("opacity-50 cursor-not-allowed", "");
prevBtn.className = prevBtn.className.replace(
"bg-gray-50 text-gray-400",
"bg-gray-100 text-gray-700",
);
} else {
prevBtn.disabled = true;
if (!prevBtn.className.includes("opacity-50")) {
prevBtn.className += " opacity-50 cursor-not-allowed";
}
prevBtn.className = prevBtn.className.replace(
"bg-gray-100 text-gray-700",
"bg-gray-50 text-gray-400",
);
}
// Enable/disable next page button
if (nextPage !== null) {
nextBtn.disabled = false;
nextBtn.className = nextBtn.className.replace("opacity-50 cursor-not-allowed", "");
nextBtn.className = nextBtn.className.replace(
"bg-gray-50 text-gray-400",
"bg-gray-100 text-gray-700",
);
} else {
nextBtn.disabled = true;
if (!nextBtn.className.includes("opacity-50")) {
nextBtn.className += " opacity-50 cursor-not-allowed";
}
nextBtn.className = nextBtn.className.replace(
"bg-gray-100 text-gray-700",
"bg-gray-50 text-gray-400",
);
}
}
// Get previous and next page numbers
getAdjacentPages() {
// Get all page containers of the same type (main or beilage)
let containerSelector;
if (this.currentIsBeilage) {
containerSelector = '.newspaper-page-container[data-beilage="true"]';
} else {
containerSelector = ".newspaper-page-container:not([data-beilage])";
}
const pageContainers = Array.from(document.querySelectorAll(containerSelector));
console.log(
"Found containers:",
pageContainers.length,
"for",
this.currentIsBeilage ? "beilage" : "main",
);
// Extract page numbers and sort them
const allPages = pageContainers
.map((container) => {
const pageAttr = container.getAttribute("data-page-container");
const pageNum = pageAttr ? parseInt(pageAttr) : null;
console.log("Container page:", pageAttr, "parsed:", pageNum);
return pageNum;
})
.filter((p) => p !== null)
.sort((a, b) => a - b);
console.log("All pages found:", allPages);
console.log("Current page:", this.currentPageNumber);
const currentIndex = allPages.indexOf(this.currentPageNumber);
console.log("Current index:", currentIndex);
let prevPage = null;
let nextPage = null;
if (currentIndex > 0) {
prevPage = allPages[currentIndex - 1];
}
if (currentIndex < allPages.length - 1) {
nextPage = allPages[currentIndex + 1];
}
console.log("Adjacent pages - prev:", prevPage, "next:", nextPage);
return { prevPage, nextPage };
}
// Navigate to previous page
goToPreviousPage() {
const { prevPage } = this.getAdjacentPages();
if (prevPage !== null) {
this.navigateToPage(prevPage);
}
}
// Navigate to next page
goToNextPage() {
const { nextPage } = this.getAdjacentPages();
if (nextPage !== null) {
this.navigateToPage(nextPage);
}
}
// Navigate to a specific page
navigateToPage(pageNumber) {
// Find the image element for the target page
const containerSelector = this.currentIsBeilage
? '.newspaper-page-container[data-beilage="true"]'
: ".newspaper-page-container:not([data-beilage])";
const targetContainer = document.querySelector(
`${containerSelector}[data-page-container="${pageNumber}"]`,
);
if (targetContainer) {
const imgElement = targetContainer.querySelector(".newspaper-page-image, .piece-page-image");
if (imgElement) {
// Determine part number for piece view
let newPartNumber = null;
if (this.currentPartNumber !== null) {
// We're in piece view, try to find the part number for the new page
newPartNumber = this.getPartNumberForPage(pageNumber);
}
// Extract icon type and heading for the new page
let extractedIconType = null;
let extractedHeading = null;
// Extract icon type from data attribute
extractedIconType = targetContainer.getAttribute("data-page-icon-type");
// For piece view: check if part number should override icon
const partNumberElement = targetContainer.querySelector(".part-number");
if (partNumberElement) {
extractedIconType = "part-number";
}
// Extract heading text from page indicator
const pageIndicator = targetContainer.querySelector(".page-indicator");
if (pageIndicator) {
// Clone the page indicator to extract text without buttons/icons
const indicatorClone = pageIndicator.cloneNode(true);
// Remove any icons to get just the text
const icons = indicatorClone.querySelectorAll("i");
icons.forEach((icon) => icon.remove());
// Remove any link indicators
const linkIndicators = indicatorClone.querySelectorAll(
'[class*="target-page-dot"], .target-page-indicator',
);
linkIndicators.forEach((indicator) => indicator.remove());
extractedHeading = indicatorClone.textContent.trim();
}
// Update the current view with the new page
this.show(
imgElement.src,
imgElement.alt,
pageNumber,
this.currentIsBeilage,
0,
newPartNumber,
extractedIconType,
extractedHeading,
);
}
}
}
// Get part number for a specific page in piece view
getPartNumberForPage(pageNumber) {
// Try to find the part number from the page container in piece view
const pageContainer = document.querySelector(`[data-page-container="${pageNumber}"]`);
if (pageContainer) {
const partNumberElement = pageContainer.querySelector(".part-number");
if (partNumberElement) {
// Extract just the number from "X. Teil" format
const match = partNumberElement.textContent.match(/(\d+)\./);
if (match) {
return parseInt(match[1]);
}
}
}
// Fallback: if we can't find it, return null to show icon instead
return null;
}
// Legacy fallback icon generation (only used when extraction fails)
generateFallbackIcon(pageNumber, isBeilage, partNumber) {
if (partNumber !== null) {
// Piece view: Show part number instead of icon
return `${partNumber}. Teil`;
} else {
// Issue view: Simple fallback icon
const baseClass = "ri-file-text-line text-lg";
const iconColor = isBeilage ? "text-amber-600" : "text-black";
return ``;
}
}
// Toggle sidebar visibility
toggleSidebar() {
const sidebarSpacer = this.querySelector("#sidebar-spacer");
const toggleBtn = this.querySelector("#sidebar-toggle-btn");
const toggleIcon = toggleBtn.querySelector("i");
// Check if sidebar is currently collapsed by looking at width
const currentWidth = sidebarSpacer.style.width;
const isCollapsed = currentWidth === "0px" || currentWidth === "0";
console.log("Current state - isCollapsed:", isCollapsed);
console.log("Current width:", currentWidth);
if (isCollapsed) {
// Sidebar is collapsed, expand it
const fullWidth = this.detectSidebarWidth();
sidebarSpacer.style.width = fullWidth;
// Update button to normal state (sidebar visible)
toggleBtn.className =
"w-10 h-10 bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-300 rounded flex items-center justify-center transition-colors duration-200 cursor-pointer";
toggleIcon.className = "ri-sidebar-fold-line text-lg font-bold";
toggleBtn.title = "Inhaltsverzeichnis ausblenden";
console.log("Expanding sidebar to:", fullWidth);
} else {
// Sidebar is expanded, collapse it
sidebarSpacer.style.width = "0px";
// Update button to active state (sidebar hidden - orange highlight)
toggleBtn.className =
"w-10 h-10 bg-orange-100 hover:bg-orange-200 text-orange-700 border border-orange-300 rounded flex items-center justify-center transition-colors duration-200 cursor-pointer";
toggleIcon.className = "ri-sidebar-unfold-line text-lg font-bold";
toggleBtn.title = "Inhaltsverzeichnis einblenden";
console.log("Collapsing sidebar");
}
console.log("New width:", sidebarSpacer.style.width);
}
// Extract issue context from document title, URL, or page container
getIssueContext(pageNumber) {
// Determine if we're in a piece view (beitrag) or issue view (ausgabe)
const path = window.location.pathname;
const isPieceView = path.includes("/beitrag/");
if (isPieceView) {
// For piece view: Return full format "1765 Nr. 2"
// Try to get context from page container first (for piece view)
const pageContainer = document.querySelector(`[data-page-container="${pageNumber}"]`);
if (pageContainer) {
// Look for existing page indicator with context
const pageIndicator = pageContainer.querySelector(".page-indicator");
if (pageIndicator) {
const text = pageIndicator.textContent.trim();
// Extract full date and issue from text like "3.7.1767 Nr. 53, 213"
const fullDateMatch = text.match(/(\d{1,2}\.\d{1,2}\.\d{4}\s+Nr\.\s+\d+)/);
if (fullDateMatch) {
return fullDateMatch[1];
}
// Fallback: Extract year and issue from text like "1768 Nr. 20, 79"
const yearMatch = text.match(/(\d{4})\s+Nr\.\s+(\d+)/);
if (yearMatch) {
return `${yearMatch[1]} Nr. ${yearMatch[2]}`;
}
}
}
// Fallback: Try to extract from document title
const title = document.title;
const titleMatch = title.match(/(\d{4}).*Nr\.\s*(\d+)/);
if (titleMatch) {
return `${titleMatch[1]} Nr. ${titleMatch[2]}`;
}
} else {
// For issue view: Return just empty string (page number only)
return "";
}
// Final fallback: Try to extract from URL path
const urlMatch = path.match(/\/(\d{4})\/(\d+)/);
if (urlMatch) {
return isPieceView ? `${urlMatch[1]} Nr. ${urlMatch[2]}` : "";
}
// Fallback - try to get from any visible page context
const anyPageIndicator = document.querySelector(".page-indicator");
if (anyPageIndicator) {
const text = anyPageIndicator.textContent.trim();
const match = text.match(/(\d{4})\s+Nr\.\s+(\d+)/);
if (match) {
return `${match[1]} Nr. ${match[2]}`;
}
}
// Ultimate fallback
return "KGPZ";
}
}
// Register the web component
customElements.define("single-page-viewer", SinglePageViewer);
// Clean up single page viewer on HTMX navigation
document.body.addEventListener("htmx:beforeRequest", function (event) {
// Find any active single page viewer
const viewer = document.querySelector("single-page-viewer");
if (viewer && viewer.style.display !== "none") {
console.log("Cleaning up single page viewer before HTMX navigation");
viewer.close();
}
});
// Also clean up on page unload as fallback
window.addEventListener("beforeunload", function () {
const viewer = document.querySelector("single-page-viewer");
if (viewer) {
viewer.close();
}
});