diff --git a/content/crafts/qtip-cube.md b/content/crafts/qtip-cube.md index 8da1f2b..add9b50 100644 --- a/content/crafts/qtip-cube.md +++ b/content/crafts/qtip-cube.md @@ -7,4 +7,20 @@ date = 2025-05-12 tags = ["6-sided", "crafty"] +++ -I'm so sorry for whoever opened this page expecting to see such a object. Unfortunately I have not yet uploaded the image. Please yell at me if needed \ No newline at end of file +As promised here is the cube. + +{{image(src="images/qtip/cube.jpg", caption="A near cube made entirely out of QTips that is 10x10x11 (-2) in size. No glue added")}} + +You might wonder how to construct such an object yourself. + +What ended up working for me was laying out a bottom row of QTips with a width just slightly longer than the length of the QTips' shaft. + +{{image(src="images/qtip/step.jpg", caption="A bottom layer of QTips showing the width compared to the shaft length of a QTip")}} + +Continue alternating the direction in which you align the QTips until you have stacked enough layers to be slightly higher than the length of the QTips' shaft. + +It is very important that the grid is aligned well at this point, as you will begin inserting QTips downward through each opening in the formed grid. + +I started with the 4 outer corners, then filled the outer edges, then filled the center. + +If all goes well, you will have a lovely cube which is fairly sturdy. \ No newline at end of file diff --git a/default.nix b/default.nix index e82bfd8..0f506a4 100644 --- a/default.nix +++ b/default.nix @@ -14,5 +14,5 @@ let }; in pkgs.mkShell { - buildInputs = [ unstable.zola ]; + buildInputs = [ unstable.zola pkgs.exiftool ]; } diff --git a/remove_metadata.sh b/remove_metadata.sh new file mode 100755 index 0000000..2aa449f --- /dev/null +++ b/remove_metadata.sh @@ -0,0 +1 @@ +exiftool -all= -tagsfromfile @ -Orientation -overwrite_original -r ./static/images ./static/videos \ No newline at end of file diff --git a/sass/img.scss b/sass/img.scss new file mode 100644 index 0000000..b8881f9 --- /dev/null +++ b/sass/img.scss @@ -0,0 +1,56 @@ +/* Prevent page scroll when lightbox is open */ +body.lb-open { + overflow: hidden; + touch-action: none; /* disables background gestures */ +} + +figure.image img.zoomable { + cursor: pointer; +} + +// figure.image img.zoomable:hover { +// transform: scale(1.02); +// } + + +/* Overlay */ +.lightbox { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.92); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + z-index: 1000; +} + +.lightbox.open { + opacity: 1; + pointer-events: auto; /* blocks clicks/scroll to the page behind */ +} + +/* Stage fills viewport; it’s the only interaction surface */ +.lightbox__stage { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; /* hide panned edges */ + cursor: zoom-out; /* click outside to close */ +} + +.lightbox__img { + max-width: 95vw; + max-height: 95vh; + user-select: none; + -webkit-user-drag: none; + cursor: grab; + transform-origin: 50% 50%; + will-change: transform; + touch-action: none; +} + +.lightbox__img.dragging { + cursor: grabbing; +} \ No newline at end of file diff --git a/sass/style.scss b/sass/style.scss index ffae118..95454f4 100644 --- a/sass/style.scss +++ b/sass/style.scss @@ -3,6 +3,7 @@ @use "footer.scss"; @use "blog.scss"; @use "fonts.scss"; +@use "img.scss"; .z-code{ overflow-x: auto; @@ -90,4 +91,31 @@ code { time { padding-bottom: 1em; color: var(--fg-2); -} \ No newline at end of file +} + + +figure { + align-items: center; + display: flex; + flex-direction: column; +} + +figure.image { + margin: 2rem 0; + text-align: center; +} + +figure.image img { + max-width: 80%; + height: auto; + border-radius: 6px; +} + +figure.image figcaption { + max-width: 80%; + margin-top: 0.5rem; + font-size: 0.9rem; + color: var(--fg-muted); +} + + diff --git a/static/images/qtip/cube.jpg b/static/images/qtip/cube.jpg new file mode 100644 index 0000000..37e2dc2 Binary files /dev/null and b/static/images/qtip/cube.jpg differ diff --git a/static/images/qtip/step.jpg b/static/images/qtip/step.jpg new file mode 100644 index 0000000..b9ddfa0 Binary files /dev/null and b/static/images/qtip/step.jpg differ diff --git a/static/js/script.js b/static/js/script.js new file mode 100644 index 0000000..33aab95 --- /dev/null +++ b/static/js/script.js @@ -0,0 +1,302 @@ +(() => { + let lightboxHistoryActive = false; + + const lightbox = document.getElementById("lightbox"); + const stage = lightbox?.querySelector(".lightbox__stage"); + const img = document.getElementById("lightbox-img"); + if (!lightbox || !stage || !img) return; + + let open = false; + + // Transform state (relative to centered position) + let scale = 1; + let tx = 0; + let ty = 0; + + // Base rendered size of the image at scale=1 (in px) + let baseW = 0; + let baseH = 0; + + // Dragging + let lastX = 0; + let lastY = 0; + + // Touch pinch + let touchMode = null; + let pinchStartDist = 0; + let pinchStartScale = 1; + let pinchMid = { x: 0, y: 0 }; + + const MIN_SCALE = 1; + const MAX_SCALE = 6; + + const clamp = (n, a, b) => Math.max(a, Math.min(b, n)); + + const rect = (el) => el.getBoundingClientRect(); + + function measureBaseSize() { + // IMPORTANT: measure when tx/ty=0 and scale=1 so we get the centered base size. + const prev = img.style.transform; + img.style.transform = "translate(0px, 0px) scale(1)"; + const r = rect(img); + baseW = r.width; + baseH = r.height; + img.style.transform = prev; + } + + function clampPan() { + const s = rect(stage); + + const scaledW = baseW * scale; + const scaledH = baseH * scale; + + // When the image is smaller than the stage, keep it centered (no panning). + const maxX = Math.max(0, (scaledW - s.width) / 2); + const maxY = Math.max(0, (scaledH - s.height) / 2); + + tx = clamp(tx, -maxX, maxX); + ty = clamp(ty, -maxY, maxY); + } + + function applyTransform() { + clampPan(); + img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; + } + + function resetView() { + scale = 1; + tx = 0; + ty = 0; + applyTransform(); + } + + function openLightbox(src, alt) { + img.onload = null; + img.src = src; + img.alt = alt || ""; + + lightbox.classList.add("open"); + lightbox.setAttribute("aria-hidden", "false"); + document.body.classList.add("lb-open"); + open = true; + + if (!lightboxHistoryActive) { + history.pushState({ lightbox: true }, ""); + lightboxHistoryActive = true; + } + + // Block background scroll/gestures while open (especially iOS) + document.addEventListener("touchmove", preventTouchScroll, { passive: false }); + + img.onload = () => { + // Wait for layout to settle, then measure and reset. + requestAnimationFrame(() => { + measureBaseSize(); + resetView(); + }); + }; + + // If cached and instantly available + if (img.complete) { + requestAnimationFrame(() => { + measureBaseSize(); + resetView(); + }); + } + } + + function closeLightbox(fromPopState = false) { + lightbox.classList.remove("open"); + lightbox.setAttribute("aria-hidden", "true"); + document.body.classList.remove("lb-open"); + open = false; + + img.classList.remove("dragging"); + touchMode = null; + + document.removeEventListener("touchmove", preventTouchScroll, { passive: false }); + + if (lightboxHistoryActive && !fromPopState) { + history.back(); + } + + lightboxHistoryActive = false; + } + + function preventTouchScroll(e) { + if (open) e.preventDefault(); + } + + function zoomAt(clientX, clientY, newScale) { + const s = rect(stage); + + const cx = clientX - s.left - s.width / 2; + const cy = clientY - s.top - s.height / 2; + + const prevScale = scale; + scale = clamp(newScale, MIN_SCALE, MAX_SCALE); + + // Keep point under cursor stable (relative to center-based coordinates) + tx = cx - ((cx - tx) / prevScale) * scale; + ty = cy - ((cy - ty) / prevScale) * scale; + + applyTransform(); + } + + // Open on thumbnail click; close on backdrop click + document.addEventListener("click", (e) => { + const thumb = e.target.closest("img.zoomable"); + if (thumb) { + openLightbox(thumb.dataset.full || thumb.src, thumb.alt); + return; + } + if (open && (e.target === lightbox || e.target === stage)) closeLightbox(); + }); + + // Prevent clicking the image from closing + img.addEventListener("click", (e) => { + if (open) e.stopPropagation(); + }); + + // ESC to close + document.addEventListener("keydown", (e) => { + if (open && e.key === "Escape") closeLightbox(); + }); + + // Wheel / trackpad zoom + stage.addEventListener("wheel", (e) => { + if (!open) return; + e.preventDefault(); + const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; + zoomAt(e.clientX, e.clientY, scale * factor); + }, { passive: false }); + + // --- CLICK vs DRAG (Pointer Events) --- + let pointerId = null; + let downX = 0, downY = 0; + let startTx = 0, startTy = 0; + + let moved = false; + const DRAG_THRESHOLD = 6; // px + const ZOOM_IN = 2.5; + + function setZoomedClass() { + img.classList.toggle("is-zoomed", scale > 1); + } + + img.addEventListener("pointerdown", (e) => { + if (!open) return; + + pointerId = e.pointerId; + img.setPointerCapture(pointerId); + + downX = e.clientX; + downY = e.clientY; + startTx = tx; + startTy = ty; + moved = false; + + // Don’t let the browser start image dragging/scrolling + e.preventDefault(); + }); + + img.addEventListener("pointermove", (e) => { + if (!open || pointerId !== e.pointerId) return; + + const dx = e.clientX - downX; + const dy = e.clientY - downY; + + // Decide if this gesture is a pan + if (!moved && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) { + moved = true; + img.classList.add("dragging"); + } + + // If panning, update translate (pan) from the start position + if (moved && scale > 1) { + tx = startTx + dx; + ty = startTy + dy; + applyTransform(); + } + }); + + img.addEventListener("pointerup", (e) => { + if (!open || pointerId !== e.pointerId) return; + + img.classList.remove("dragging"); + img.releasePointerCapture(pointerId); + pointerId = null; + + // If we didn't move enough, treat as a click: toggle zoom + if (!moved) { + // Zoom around where they clicked + const target = (scale === 1) ? ZOOM_IN : 1; + zoomAt(e.clientX, e.clientY, target); + setZoomedClass(); + } + + moved = false; + }); + + img.addEventListener("pointercancel", () => { + img.classList.remove("dragging"); + pointerId = null; + moved = false; + }); + + // Touch: pan + pinch + stage.addEventListener("touchstart", (e) => { + if (!open) return; + + if (e.touches.length === 1) { + touchMode = "pan"; + lastX = e.touches[0].clientX; + lastY = e.touches[0].clientY; + } else if (e.touches.length === 2) { + touchMode = "pinch"; + const [a, b] = e.touches; + pinchStartDist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); + pinchStartScale = scale; + pinchMid = { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; + } + }, { passive: false }); + + stage.addEventListener("touchmove", (e) => { + if (!open) return; + e.preventDefault(); + + if (touchMode === "pan" && e.touches.length === 1) { + const x = e.touches[0].clientX; + const y = e.touches[0].clientY; + tx += x - lastX; + ty += y - lastY; + lastX = x; + lastY = y; + applyTransform(); + } + + if (touchMode === "pinch" && e.touches.length === 2) { + const [a, b] = e.touches; + const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); + const ratio = dist / pinchStartDist; + zoomAt(pinchMid.x, pinchMid.y, pinchStartScale * ratio); + } + }, { passive: false }); + + stage.addEventListener("touchend", (e) => { + if (!open) return; + if (e.touches.length === 0) touchMode = null; + if (e.touches.length === 1) { + touchMode = "pan"; + lastX = e.touches[0].clientX; + lastY = e.touches[0].clientY; + } + }); + + window.addEventListener("popstate", (e) => { + // If the lightbox is open, Back should close it — not leave the page + if (open) { + closeLightbox(true); + } + }); +})(); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index dc52857..c001829 100644 --- a/templates/base.html +++ b/templates/base.html @@ -117,6 +117,7 @@ {%- endif -%} +