aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/SubpostsHeader.astro
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/SubpostsHeader.astro')
-rw-r--r--src/components/SubpostsHeader.astro314
1 files changed, 314 insertions, 0 deletions
diff --git a/src/components/SubpostsHeader.astro b/src/components/SubpostsHeader.astro
new file mode 100644
index 0000000..0f1c597
--- /dev/null
+++ b/src/components/SubpostsHeader.astro
@@ -0,0 +1,314 @@
+---
+import { ScrollArea } from '@/components/ui/scroll-area'
+import {
+ getCombinedReadingTime,
+ getParentId,
+ getParentPost,
+ getPostById,
+ getPostReadingTime,
+ getSubpostsForParent,
+ isSubpost,
+} from '@/lib/data-utils'
+import { Icon } from 'astro-icon/components'
+
+const { parentId } = Astro.props
+const currentPostId = Astro.params.id as string
+const isCurrentSubpost = isSubpost(currentPostId)
+const rootParentId = isCurrentSubpost ? getParentId(currentPostId) : parentId
+
+const currentPost = !isCurrentSubpost ? await getPostById(currentPostId) : null
+const subposts = await getSubpostsForParent(rootParentId)
+const parentPost = isCurrentSubpost ? await getParentPost(currentPostId) : null
+
+const activePost = parentPost || currentPost
+const isActivePost = activePost?.id === currentPostId
+
+const activePostReadingTime = activePost
+ ? await getPostReadingTime(activePost.id)
+ : null
+const activePostCombinedReadingTime =
+ activePost && subposts.length > 0
+ ? await getCombinedReadingTime(activePost.id)
+ : null
+const subpostsWithReadingTime = await Promise.all(
+ subposts.map(async (subpost) => ({
+ ...subpost,
+ readingTime: await getPostReadingTime(subpost.id),
+ })),
+)
+
+const currentSubpostDetails = isCurrentSubpost
+ ? subpostsWithReadingTime.find((subpost) => subpost.id === currentPostId)
+ : null
+---
+
+{
+ activePost && subposts.length > 0 && (
+ <div id="mobile-subposts-container" class="w-full xl:hidden">
+ <details class="group">
+ <summary class="flex w-full cursor-pointer items-center justify-between">
+ <div class="mx-auto flex w-full max-w-3xl items-center px-4 py-3">
+ <div class="relative mr-2 size-4">
+ <Icon
+ name={
+ currentSubpostDetails
+ ? 'lucide:file-text'
+ : isActivePost
+ ? 'lucide:book-open-text'
+ : 'lucide:book-open'
+ }
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ </div>
+ <div class="flex flex-grow flex-col truncate text-sm">
+ <span class="text-muted-foreground truncate">
+ {currentSubpostDetails
+ ? currentSubpostDetails.data.title
+ : activePost?.data.title}
+ </span>
+ </div>
+ <span class="text-muted-foreground ml-2">
+ <Icon
+ name="lucide:chevron-down"
+ class="h-4 w-4 transition-transform duration-200 group-open:rotate-180"
+ />
+ </span>
+ </div>
+ </summary>
+
+ <ScrollArea
+ client:load
+ className="mx-auto max-w-3xl"
+ data-subposts-header-scroll
+ >
+ <div class="max-h-[30vh]">
+ <ul class="flex list-none flex-col gap-y-1 px-4 pb-4">
+ {activePost && (
+ <li>
+ {isActivePost ? (
+ <div class="text-foreground bg-muted mobile-subposts-active-item flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium">
+ <Icon
+ name="lucide:book-open-text"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2">
+ {activePost.data.title}
+ </span>
+ {activePostReadingTime && (
+ <span class="text-muted-foreground/80 text-xs">
+ {activePostReadingTime}
+ {activePostCombinedReadingTime &&
+ activePostCombinedReadingTime !==
+ activePostReadingTime && (
+ <span>
+ {' '}
+ ({activePostCombinedReadingTime} total)
+ </span>
+ )}
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <a
+ href={`/blog/${activePost.id}`}
+ class="mobile-subposts-link hover:text-foreground text-muted-foreground hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors"
+ >
+ <Icon
+ name="lucide:book-open"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2">
+ {activePost.data.title}
+ </span>
+ {activePostReadingTime && (
+ <span class="text-muted-foreground/80 hover:text-foreground/80 text-xs">
+ {activePostReadingTime}
+ {activePostCombinedReadingTime &&
+ activePostCombinedReadingTime !==
+ activePostReadingTime && (
+ <span>
+ {' '}
+ ({activePostCombinedReadingTime} total)
+ </span>
+ )}
+ </span>
+ )}
+ </div>
+ </a>
+ )}
+ </li>
+ )}
+
+ {subpostsWithReadingTime.length > 0 && (
+ <div class="ml-4 space-y-1">
+ {subpostsWithReadingTime.map((subpost) =>
+ currentPostId === subpost.id ? (
+ <div class="text-foreground bg-muted mobile-subposts-active-item flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium">
+ <Icon
+ name="lucide:file-text"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2">{subpost.data.title}</span>
+ <span class="text-muted-foreground/80 text-xs">
+ {subpost.readingTime}
+ </span>
+ </div>
+ </div>
+ ) : (
+ <a
+ href={`/blog/${subpost.id}`}
+ class="mobile-subposts-link hover:text-foreground text-muted-foreground hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors"
+ >
+ <Icon
+ name="lucide:file"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2">{subpost.data.title}</span>
+ <span class="text-muted-foreground/80 hover:text-foreground/80 text-xs">
+ {subpost.readingTime}
+ </span>
+ </div>
+ </a>
+ ),
+ )}
+ </div>
+ )}
+ </ul>
+ </div>
+ </ScrollArea>
+ </details>
+ </div>
+ )
+}
+
+<script>
+ class SubpostsState {
+ scrollArea: HTMLElement | null = null
+ detailsElement: HTMLDetailsElement | null = null
+ headerScrollArea: HTMLElement | null = null
+
+ reset() {
+ this.scrollArea = null
+ this.detailsElement = null
+ this.headerScrollArea = null
+ }
+ }
+
+ const state = new SubpostsState()
+
+ class SubpostsHeaderScrollMask {
+ static update() {
+ if (!state.scrollArea || !state.headerScrollArea) return
+
+ const { scrollTop, scrollHeight, clientHeight } = state.scrollArea
+ const threshold = 5
+ const isAtTop = scrollTop <= threshold
+ const isAtBottom = scrollTop >= scrollHeight - clientHeight - threshold
+
+ state.headerScrollArea.classList.toggle('mask-t-from-80%', !isAtTop)
+ state.headerScrollArea.classList.toggle('mask-b-from-80%', !isAtBottom)
+ }
+ }
+
+ class SubpostsScroll {
+ static toActive() {
+ if (!state.scrollArea) return
+
+ const activeItem = state.scrollArea.querySelector(
+ '.mobile-subposts-active-item',
+ )
+ if (!activeItem) return
+
+ const { top: areaTop, height: areaHeight } =
+ state.scrollArea.getBoundingClientRect()
+ const { top: itemTop, height: itemHeight } =
+ activeItem.getBoundingClientRect()
+
+ const currentItemTop = itemTop - areaTop + state.scrollArea.scrollTop
+ const targetScroll = Math.max(
+ 0,
+ Math.min(
+ currentItemTop - (areaHeight - itemHeight) / 2,
+ state.scrollArea.scrollHeight - state.scrollArea.clientHeight,
+ ),
+ )
+
+ state.scrollArea.scrollTop = targetScroll
+ }
+
+ static setupInteraction() {
+ const container = document.getElementById('mobile-subposts-container')
+ if (!container) return
+
+ state.scrollArea = container.querySelector(
+ '[data-radix-scroll-area-viewport]',
+ )
+ state.detailsElement = container.querySelector('details')
+ state.headerScrollArea = container.querySelector(
+ '[data-subposts-header-scroll]',
+ )
+ const links = container.querySelectorAll('.mobile-subposts-link')
+
+ if (state.scrollArea) {
+ state.scrollArea.addEventListener(
+ 'scroll',
+ SubpostsHeaderScrollMask.update,
+ { passive: true },
+ )
+ }
+
+ if (state.detailsElement) {
+ state.detailsElement.addEventListener('toggle', () => {
+ if (state.detailsElement?.open) {
+ requestAnimationFrame(() => {
+ SubpostsScroll.toActive()
+ setTimeout(SubpostsHeaderScrollMask.update, 100)
+ })
+ }
+ })
+ }
+
+ links.forEach((link) => {
+ link.addEventListener('click', () => {
+ if (state.detailsElement) state.detailsElement.open = false
+ })
+ })
+ }
+ }
+
+ class SubpostsController {
+ static init() {
+ state.reset()
+ SubpostsScroll.setupInteraction()
+ }
+
+ static cleanup() {
+ if (state.scrollArea) {
+ state.scrollArea.removeEventListener(
+ 'scroll',
+ SubpostsHeaderScrollMask.update,
+ )
+ }
+ state.reset()
+ }
+ }
+
+ document.addEventListener('astro:page-load', () => SubpostsController.init())
+ document.addEventListener('astro:after-swap', () => {
+ SubpostsController.cleanup()
+ SubpostsController.init()
+ })
+ document.addEventListener('astro:before-swap', () =>
+ SubpostsController.cleanup(),
+ )
+</script>