mirror of
				https://github.com/Theodor-Springmann-Stiftung/kgpz_web.git
				synced 2025-10-30 01:25:30 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			805 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			805 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| // ===========================
 | |
| // 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;
 | |
| 		this.hasStartedPreloading = false;
 | |
| 	}
 | |
| 
 | |
| 	// 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 = `
 | |
| 			<div class="fixed inset-0 z-50 flex pointer-events-none">
 | |
| 				<!-- Keep Inhaltsverzeichnis area empty/transparent (collapsible) -->
 | |
| 				<div id="sidebar-spacer" style="width: ${sidebarWidth};" class="flex-shrink-0 transition-all duration-300"></div>
 | |
| 
 | |
| 				<!-- Cover the right columns with the zoomed view -->
 | |
| 				<div class="flex-1 bg-slate-50 overflow-auto pointer-events-auto">
 | |
| 					<div class="relative min-h-full flex flex-col">
 | |
| 						<!-- Header with page info and buttons -->
 | |
| 						<div class="sticky top-0 z-30 bg-slate-50 flex items-center justify-between p-4">
 | |
| 							<!-- Left: Sidebar toggle and page indicator -->
 | |
| 							<div class="flex items-center gap-3">
 | |
| 								<!-- Sidebar toggle button -->
 | |
| 								<button
 | |
| 									id="sidebar-toggle-btn"
 | |
| 									onclick="this.closest('single-page-viewer').toggleSidebar()"
 | |
| 									class="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"
 | |
| 									title="Inhaltsverzeichnis ein-/ausblenden">
 | |
| 									<i class="ri-sidebar-fold-line text-lg font-bold"></i>
 | |
| 								</button>
 | |
| 
 | |
| 								<!-- Page indicator with icon -->
 | |
| 								<div id="page-indicator" class="text-slate-800 flex items-center gap-3">
 | |
| 									<span id="page-icon" class="text-lg"></span>
 | |
| 									<span id="page-number" class="text-lg font-bold bg-blue-50 px-2 py-1 rounded flex items-center gap-1"></span>
 | |
| 								</div>
 | |
| 							</div>
 | |
| 
 | |
| 							<!-- Right: Action buttons -->
 | |
| 							<div class="flex items-center gap-3">
 | |
| 								<!-- Previous page button -->
 | |
| 								<button
 | |
| 									id="prev-page-btn"
 | |
| 									onclick="this.closest('single-page-viewer').goToPreviousPage()"
 | |
| 									class="w-10 h-10 bg-gray-100 hover:bg-gray-200 text-gray-700 border border-gray-300 rounded flex items-center justify-center transition-colors duration-200 cursor-pointer"
 | |
| 									title="Vorherige Seite">
 | |
| 									<i class="ri-arrow-left-line text-lg font-bold"></i>
 | |
| 								</button>
 | |
| 
 | |
| 								<!-- Next page button -->
 | |
| 								<button
 | |
| 									id="next-page-btn"
 | |
| 									onclick="this.closest('single-page-viewer').goToNextPage()"
 | |
| 									class="w-10 h-10 bg-gray-100 hover:bg-gray-200 text-gray-700 border border-gray-300 rounded flex items-center justify-center transition-colors duration-200 cursor-pointer"
 | |
| 									title="Nächste Seite">
 | |
| 									<i class="ri-arrow-right-line text-lg font-bold"></i>
 | |
| 								</button>
 | |
| 
 | |
| 								<!-- Separator -->
 | |
| 								<div class="w-px h-6 bg-gray-300"></div>
 | |
| 
 | |
| 								<!-- Share button -->
 | |
| 								<button
 | |
| 									id="share-btn"
 | |
| 									onclick="this.closest('single-page-viewer').shareCurrentPage()"
 | |
| 									class="w-10 h-10 bg-blue-100 hover:bg-blue-200 text-blue-700 border border-blue-300 rounded flex items-center justify-center transition-colors duration-200 cursor-pointer"
 | |
| 									title="Link zu dieser Seite kopieren">
 | |
| 									<i class="ri-share-line text-lg font-bold"></i>
 | |
| 								</button>
 | |
| 
 | |
| 								<!-- Cite button -->
 | |
| 								<button
 | |
| 									id="cite-btn"
 | |
| 									onclick="this.closest('single-page-viewer').generatePageCitation()"
 | |
| 									class="w-10 h-10 bg-green-100 hover:bg-green-200 text-green-700 border border-green-300 rounded flex items-center justify-center transition-colors duration-200 cursor-pointer"
 | |
| 									title="Zitation für diese Seite generieren">
 | |
| 									<i class="ri-file-text-line text-lg font-bold"></i>
 | |
| 								</button>
 | |
| 
 | |
| 								<!-- Close button -->
 | |
| 								<button
 | |
| 									onclick="this.closest('single-page-viewer').close()"
 | |
| 									class="w-10 h-10 bg-gray-100 hover:bg-gray-200 text-gray-700 border border-gray-300 rounded flex items-center justify-center transition-colors duration-200 cursor-pointer"
 | |
| 									title="Schließen">
 | |
| 									<i class="ri-close-line text-lg font-bold"></i>
 | |
| 								</button>
 | |
| 							</div>
 | |
| 						</div>
 | |
| 
 | |
| 						<!-- Image display container -->
 | |
| 						<div id="image-container" class="flex-1 flex items-center justify-center p-4 pb-8 overflow-auto">
 | |
| 							<img
 | |
| 								id="single-page-image"
 | |
| 								src=""
 | |
| 								alt=""
 | |
| 								class="rounded-lg shadow-2xl cursor-pointer select-none max-w-full max-h-full"
 | |
| 								title="Klicken zum Schließen"
 | |
| 								draggable="false"
 | |
| 								onclick="this.closest('single-page-viewer').close()"
 | |
| 							/>
 | |
| 						</div>
 | |
| 					</div>
 | |
| 				</div>
 | |
| 			</div>
 | |
| 		`;
 | |
| 
 | |
| 		// 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 = `<span class="part-number bg-slate-100 text-slate-800 font-bold px-1.5 py-0.5 rounded border border-slate-400 flex items-center justify-center">${partNumber}. Teil</span>`;
 | |
| 			} 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";
 | |
| 		this.setAttribute("active", "true");
 | |
| 
 | |
| 		// 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";
 | |
| 
 | |
| 		// Dispatch event for scrollspy
 | |
| 		document.dispatchEvent(
 | |
| 			new CustomEvent("singlepageviewer:opened", {
 | |
| 				detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage },
 | |
| 			}),
 | |
| 		);
 | |
| 
 | |
| 		// Start preloading all high-quality images in the background
 | |
| 		this.startImagePreloading();
 | |
| 	}
 | |
| 
 | |
| 	close() {
 | |
| 		this.style.display = "none";
 | |
| 		this.removeAttribute("active");
 | |
| 
 | |
| 		// Restore background scrolling
 | |
| 		document.body.style.overflow = "";
 | |
| 
 | |
| 		// Dispatch event for scrollspy
 | |
| 		document.dispatchEvent(
 | |
| 			new CustomEvent("singlepageviewer:closed", {
 | |
| 				detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage },
 | |
| 			}),
 | |
| 		);
 | |
| 	}
 | |
| 
 | |
| 	disconnectedCallback() {
 | |
| 		// Clean up resize observer
 | |
| 		if (this.resizeObserver) {
 | |
| 			this.resizeObserver.disconnect();
 | |
| 			this.resizeObserver = null;
 | |
| 		}
 | |
| 
 | |
| 		// Clean up keyboard event listeners
 | |
| 		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 `<i class="ri-file-text-line text-black text-lg" display: inline-block;"></i>`;
 | |
| 			case "last":
 | |
| 				return `<i class="ri-file-text-line text-black text-lg" style="transform: scaleX(-1); display: inline-block;"></i>`;
 | |
| 			case "even":
 | |
| 				return `<i class="ri-file-text-line text-black text-lg" style="margin-left: 1px; transform: scaleX(-1); display: inline-block;"></i><i class="ri-file-text-line text-slate-400 text-lg"></i>`;
 | |
| 			case "odd":
 | |
| 				return `<i class="ri-file-text-line text-slate-400 text-lg" style="margin-left: 1px; transform: scaleX(-1); display: inline-block;"></i><i class="ri-file-text-line text-black text-lg"></i>`;
 | |
| 			case "single":
 | |
| 				return `<i class="ri-file-text-line text-black text-lg"></i>`;
 | |
| 			default:
 | |
| 				return `<i class="ri-file-text-line text-black text-lg"></i>`;
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// 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;
 | |
| 		containerSelector = ".newspaper-page-container";
 | |
| 		// 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);
 | |
| 
 | |
| 		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();
 | |
| 				}
 | |
| 
 | |
| 				// Use full-quality image for single page viewer (not the preview src)
 | |
| 				const fullImageSrc = imgElement.getAttribute('data-full-image') || imgElement.src;
 | |
| 
 | |
| 				// Update the current view with the new page
 | |
| 				this.show(
 | |
| 					fullImageSrc,
 | |
| 					imgElement.alt,
 | |
| 					pageNumber,
 | |
| 					this.currentIsBeilage,
 | |
| 					0,
 | |
| 					newPartNumber,
 | |
| 					extractedIconType,
 | |
| 					extractedHeading,
 | |
| 				);
 | |
| 
 | |
| 				// Dispatch event for scrollspy to update highlighting
 | |
| 				document.dispatchEvent(
 | |
| 					new CustomEvent("singlepageviewer:pagechanged", {
 | |
| 						detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage },
 | |
| 					}),
 | |
| 				);
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// 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 `<span class="part-number bg-slate-100 text-slate-800 font-bold px-1.5 py-0.5 rounded border border-slate-400 flex items-center justify-center">${partNumber}. Teil</span>`;
 | |
| 		} else {
 | |
| 			// Issue view: Simple fallback icon
 | |
| 			const baseClass = "ri-file-text-line text-lg";
 | |
| 			const iconColor = isBeilage ? "text-amber-600" : "text-black";
 | |
| 			return `<i class="${baseClass} ${iconColor}"></i>`;
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// 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";
 | |
| 	}
 | |
| 
 | |
| 	// Start preloading all high-quality images in the background
 | |
| 	startImagePreloading() {
 | |
| 		// Only preload once per session
 | |
| 		if (this.hasStartedPreloading) {
 | |
| 			return;
 | |
| 		}
 | |
| 		this.hasStartedPreloading = true;
 | |
| 
 | |
| 		// Collect all high-quality image URLs from the current page
 | |
| 		const imageUrls = this.collectAllImageUrls();
 | |
| 
 | |
| 		if (imageUrls.length === 0) {
 | |
| 			console.log("No images to preload");
 | |
| 			return;
 | |
| 		}
 | |
| 
 | |
| 		console.log(`Starting background preload of ${imageUrls.length} high-quality images`);
 | |
| 
 | |
| 		// Start preloading with a slight delay to not interfere with the current image load
 | |
| 		setTimeout(() => {
 | |
| 			this.preloadImages(imageUrls);
 | |
| 		}, 500);
 | |
| 	}
 | |
| 
 | |
| 	// Collect all high-quality image URLs from the current page
 | |
| 	collectAllImageUrls() {
 | |
| 		const imageUrls = [];
 | |
| 
 | |
| 		// Find all newspaper page images (both main pages and beilage)
 | |
| 		const pageImages = document.querySelectorAll('.newspaper-page-image, .piece-page-image');
 | |
| 
 | |
| 		pageImages.forEach(img => {
 | |
| 			// Get the high-quality image URL from data-full-image attribute
 | |
| 			const fullImageUrl = img.getAttribute('data-full-image');
 | |
| 			if (fullImageUrl && !imageUrls.includes(fullImageUrl)) {
 | |
| 				imageUrls.push(fullImageUrl);
 | |
| 			}
 | |
| 		});
 | |
| 
 | |
| 		return imageUrls;
 | |
| 	}
 | |
| 
 | |
| 	// Preload images with throttling to avoid overwhelming the browser
 | |
| 	preloadImages(imageUrls) {
 | |
| 		const CONCURRENT_LOADS = 3; // Maximum concurrent image loads
 | |
| 		const DELAY_BETWEEN_BATCHES = 1000; // 1 second between batches
 | |
| 
 | |
| 		let currentIndex = 0;
 | |
| 		let activeLoads = 0;
 | |
| 		const preloadedImages = new Set();
 | |
| 
 | |
| 		const loadNextBatch = () => {
 | |
| 			while (activeLoads < CONCURRENT_LOADS && currentIndex < imageUrls.length) {
 | |
| 				const imageUrl = imageUrls[currentIndex];
 | |
| 				currentIndex++;
 | |
| 
 | |
| 				// Skip if already preloaded
 | |
| 				if (preloadedImages.has(imageUrl)) {
 | |
| 					continue;
 | |
| 				}
 | |
| 
 | |
| 				activeLoads++;
 | |
| 				this.preloadSingleImage(imageUrl)
 | |
| 					.then(() => {
 | |
| 						preloadedImages.add(imageUrl);
 | |
| 						console.log(`Preloaded: ${imageUrl} (${preloadedImages.size}/${imageUrls.length})`);
 | |
| 					})
 | |
| 					.catch((error) => {
 | |
| 						console.warn(`Failed to preload: ${imageUrl}`, error);
 | |
| 					})
 | |
| 					.finally(() => {
 | |
| 						activeLoads--;
 | |
| 						// Continue loading if more images remain
 | |
| 						if (currentIndex < imageUrls.length || activeLoads > 0) {
 | |
| 							setTimeout(loadNextBatch, 100);
 | |
| 						} else {
 | |
| 							console.log(`Preloading complete: ${preloadedImages.size}/${imageUrls.length} images loaded`);
 | |
| 						}
 | |
| 					});
 | |
| 			}
 | |
| 		};
 | |
| 
 | |
| 		// Start the first batch
 | |
| 		loadNextBatch();
 | |
| 	}
 | |
| 
 | |
| 	// Preload a single image
 | |
| 	preloadSingleImage(imageUrl) {
 | |
| 		return new Promise((resolve, reject) => {
 | |
| 			const img = new Image();
 | |
| 
 | |
| 			img.onload = () => resolve(img);
 | |
| 			img.onerror = () => reject(new Error(`Failed to load ${imageUrl}`));
 | |
| 
 | |
| 			// Set src to start loading
 | |
| 			img.src = imageUrl;
 | |
| 		});
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // 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();
 | |
| 	}
 | |
| });
 | 
