diff options
Diffstat (limited to 'src/components/SubpostsHeader.astro')
| -rw-r--r-- | src/components/SubpostsHeader.astro | 314 |
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> |
