From 999ff25fc3dccf92eace0988e91a624abbc6cd37 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 4 Jan 2026 00:37:25 -0500 Subject: [PATCH] custom scrollbar for blog page - hate this --- src/components/CustomScrollbar.tsx | 207 +++++++++++++++++++++++++++++ src/routes/blog/[title]/index.tsx | 141 ++++++++++---------- 2 files changed, 280 insertions(+), 68 deletions(-) create mode 100644 src/components/CustomScrollbar.tsx diff --git a/src/components/CustomScrollbar.tsx b/src/components/CustomScrollbar.tsx new file mode 100644 index 0000000..122c5e0 --- /dev/null +++ b/src/components/CustomScrollbar.tsx @@ -0,0 +1,207 @@ +import { createSignal, onMount, onCleanup, JSX, Show } from "solid-js"; + +export interface CustomScrollbarProps { + autoHide?: boolean; + autoHideDelay?: number; + rightOffset?: number; + children: JSX.Element; +} + +export default function CustomScrollbar(props: CustomScrollbarProps) { + const [scrollPercentage, setScrollPercentage] = createSignal(0); + const [thumbHeight, setThumbHeight] = createSignal(100); + const [isDragging, setIsDragging] = createSignal(false); + const [isVisible, setIsVisible] = createSignal(true); + const [isHovering, setIsHovering] = createSignal(false); + const [windowWidth, setWindowWidth] = createSignal( + typeof window !== "undefined" ? window.innerWidth : 1024 + ); + let containerRef: HTMLDivElement | undefined; + let scrollbarRef: HTMLDivElement | undefined; + let thumbRef: HTMLDivElement | undefined; + let hideTimeout: NodeJS.Timeout | undefined; + + const updateScrollbar = () => { + if (!containerRef) return; + + const scrollTop = containerRef.scrollTop; + const scrollHeight = containerRef.scrollHeight; + const clientHeight = containerRef.clientHeight; + + // Calculate thumb height as percentage of visible area + const viewportRatio = clientHeight / scrollHeight; + const calculatedThumbHeight = Math.max(viewportRatio * 100, 5); + setThumbHeight(calculatedThumbHeight); + + // Calculate scroll percentage + const maxScroll = scrollHeight - clientHeight; + const percentage = maxScroll > 0 ? (scrollTop / maxScroll) * 100 : 0; + setScrollPercentage(percentage); + + // Show scrollbar on scroll if autoHide enabled + if (props.autoHide) { + setIsVisible(true); + clearTimeout(hideTimeout); + hideTimeout = setTimeout(() => { + if (!isDragging()) { + setIsVisible(false); + } + }, props.autoHideDelay || 1500); + } + }; + + const handleTrackClick = (e: MouseEvent) => { + if (!scrollbarRef || !containerRef || e.target === thumbRef) return; + + const rect = scrollbarRef.getBoundingClientRect(); + const clickY = e.clientY - rect.top; + const trackHeight = rect.height; + const targetPercentage = (clickY / trackHeight) * 100; + + const scrollHeight = containerRef.scrollHeight; + const clientHeight = containerRef.clientHeight; + const maxScroll = scrollHeight - clientHeight; + + const targetScroll = (targetPercentage / 100) * maxScroll; + containerRef.scrollTop = targetScroll; + }; + + const handleThumbMouseDown = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + + const startY = e.clientY; + const startScrollPercentage = scrollPercentage(); + + const handleMouseMove = (moveEvent: MouseEvent) => { + if (!containerRef || !scrollbarRef) return; + + const deltaY = moveEvent.clientY - startY; + const trackHeight = scrollbarRef.getBoundingClientRect().height || 0; + const deltaPercentage = (deltaY / trackHeight) * 100; + const newPercentage = Math.max( + 0, + Math.min(100, startScrollPercentage + deltaPercentage) + ); + + const scrollHeight = containerRef.scrollHeight; + const clientHeight = containerRef.clientHeight; + const maxScroll = scrollHeight - clientHeight; + + const targetScroll = (newPercentage / 100) * maxScroll; + containerRef.scrollTop = targetScroll; + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + onMount(() => { + if (!containerRef) return; + + // Initial update + updateScrollbar(); + + // Update after delays to catch dynamically loaded content + setTimeout(() => updateScrollbar(), 100); + setTimeout(() => updateScrollbar(), 500); + + const handleResize = () => { + setWindowWidth(window.innerWidth); + updateScrollbar(); + }; + + // Debounced mutation observer + let mutationTimeout: NodeJS.Timeout; + const observer = new MutationObserver(() => { + clearTimeout(mutationTimeout); + mutationTimeout = setTimeout(() => { + updateScrollbar(); + }, 150); + }); + + observer.observe(containerRef, { + childList: true, + subtree: true + }); + + // Use passive scroll listener for better performance + containerRef.addEventListener("scroll", updateScrollbar, { passive: true }); + window.addEventListener("resize", handleResize); + + onCleanup(() => { + observer.disconnect(); + clearTimeout(mutationTimeout); + containerRef?.removeEventListener("scroll", updateScrollbar); + window.removeEventListener("resize", handleResize); + if (hideTimeout) clearTimeout(hideTimeout); + }); + }); + + const getRightOffset = () => { + const baseOffset = props.rightOffset || 0; + return windowWidth() >= 768 ? baseOffset : 0; + }; + + return ( +
+ {/* Hide default scrollbar */} + + + {props.children} + + {/* Custom scrollbar */} + +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + class="fixed top-0 h-full w-3 transition-opacity duration-300" + classList={{ + "opacity-0": + props.autoHide && !isVisible() && !isDragging() && !isHovering(), + "opacity-100": + !props.autoHide || isVisible() || isDragging() || isHovering() + }} + style={{ + right: `${getRightOffset()}px`, + "z-index": "9999" + }} + > +
+
+ +
+ ); +} diff --git a/src/routes/blog/[title]/index.tsx b/src/routes/blog/[title]/index.tsx index dd75071..9ac37e1 100644 --- a/src/routes/blog/[title]/index.tsx +++ b/src/routes/blog/[title]/index.tsx @@ -17,6 +17,7 @@ import PostBodyClient from "~/components/blog/PostBodyClient"; import type { Comment, CommentReaction, UserPublicData } from "~/types/comment"; import { Spinner } from "~/components/Spinner"; import { api } from "~/lib/api"; +import CustomScrollbar from "~/components/CustomScrollbar"; import "../post.css"; import { Post } from "~/db/types"; @@ -299,73 +300,77 @@ export default function PostPage() { }; return ( - <> - - -
+ + + + } + > + {(loadedData) => { + // Handle redirect for by-id route + if ("redirect" in loadedData()) { + return ; } - > - {(loadedData) => { - // Handle redirect for by-id route - if ("redirect" in loadedData()) { - return ; - } - return ( - } - > - {(p) => { - const postData = loadedData(); + return ( + } + > + {(p) => { + const postData = loadedData(); - // Convert arrays back to Maps for component - const userCommentMap = new Map( - postData.userCommentArray || [] - ); - const reactionMap = new Map( - postData.reactionArray || [] - ); + // Convert arrays back to Maps for component + const userCommentMap = new Map( + postData.userCommentArray || [] + ); + const reactionMap = new Map( + postData.reactionArray || [] + ); - return ( - <> - - {p().title.replaceAll("_", " ")} | Michael Freno - - + return ( + <> + + {p().title.replaceAll("_", " ")} | Michael Freno + + -
- {/* Fixed banner image background */} -
- post-cover -
-
- {p().title.replaceAll("_", " ")} - -
- {p().subtitle} -
-
-
+
+ {/* Fixed banner image background */} +
+ post-cover +
+
+ {p().title.replaceAll("_", " ")} + +
+ {p().subtitle} +
+
+
+
{/* Content that slides over the fixed image */}
@@ -501,14 +506,14 @@ export default function PostPage() {
-
- - ); - }} - - ); - }} - - + +
+ + ); + }} + + ); + }} + ); }