Make tags collapsible on hack page

This commit is contained in:
jschoeny
2026-01-01 12:53:54 -10:00
parent 3817889f7d
commit 1c40f47404
2 changed files with 100 additions and 8 deletions

View File

@@ -22,6 +22,7 @@ import { TbProgressCheck } from "react-icons/tb";
import { isInformationalArchiveHack, isDownloadableArchiveHack, isArchiveHack, checkEditPermission } from "@/utils/hack";
import Avatar from "@/components/Account/Avatar";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import CollapsibleTags from "@/components/Hack/CollapsibleTags";
import { getHackMetadata, getHackDownloads } from "@/app/hack/[slug]/actions";
interface HackDetailProps {
@@ -327,14 +328,8 @@ export default async function HackDetail({ params }: HackDetailProps) {
)}
<p className={`${!isArchive && (!hack.completion_status || hack.completion_status === "Complete") ? "mt-4" : "mt-2"} text-sm text-foreground/75`}>{hack.summary}</p>
</div>
<div className="w-full mt-2 flex flex-col justify-between gap-6 md:flex-row md:items-end">
<div className="flex flex-wrap gap-2">
{tags.map((t) => (
<span key={t} className="rounded-full bg-[var(--surface-2)] px-2.5 py-1 text-xs ring-1 ring-[var(--border)]">
{t}
</span>
))}
</div>
<div className="w-full mt-2 flex flex-col justify-between gap-2 md:gap-6 md:flex-row md:items-end">
<CollapsibleTags tags={tags} />
<div className="flex items-center justify-end gap-2 self-end md:self-auto lg:min-w-[260px]">
{!isArchive && (
<div className="hidden md:inline-flex mr-2">

View File

@@ -0,0 +1,97 @@
"use client";
import { useState, useRef, useLayoutEffect, useEffect, useCallback } from "react";
import { FaChevronDown } from "react-icons/fa6";
interface CollapsibleTagsProps {
tags: string[];
}
// Tag height: ~28px (px-2.5 py-1 text-xs), gap: 8px
// Max height: 2.5 × 28px + 2 × 8px = 86px (rounded to 88px for safety)
const MAX_HEIGHT = 72;
export default function CollapsibleTags({ tags }: CollapsibleTagsProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [needsExpansion, setNeedsExpansion] = useState(false);
const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const checkIfExpansionNeeded = useCallback(() => {
if (!contentRef.current) return;
const height = contentRef.current.scrollHeight;
setNaturalHeight(height);
setNeedsExpansion(height > MAX_HEIGHT);
}, []);
// Use useLayoutEffect to check synchronously before paint
useLayoutEffect(() => {
checkIfExpansionNeeded();
}, [tags, checkIfExpansionNeeded]);
// Also check on window resize
useEffect(() => {
window.addEventListener("resize", checkIfExpansionNeeded);
return () => {
window.removeEventListener("resize", checkIfExpansionNeeded);
};
}, [checkIfExpansionNeeded]);
if (tags.length === 0) return null;
return (
<div className="flex flex-col">
<div
className="grid transition-all duration-300 ease-in-out"
style={
!isExpanded
? {
gridTemplateRows: `${MAX_HEIGHT}px`,
borderBottom: "2px solid var(--border)",
}
: naturalHeight
? {
gridTemplateRows: `${naturalHeight}px`,
borderBottom: "0px solid transparent",
}
: {
gridTemplateRows: "1fr",
borderBottom: "0px solid transparent",
}
}
>
<div className="overflow-hidden">
<div
ref={contentRef}
className="flex flex-wrap gap-2"
>
{tags.map((t) => (
<span
key={t}
className="rounded-full bg-[var(--surface-2)] px-2.5 py-1 text-xs ring-1 ring-[var(--border)]"
>
{t}
</span>
))}
</div>
</div>
</div>
{needsExpansion && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center justify-center gap-2 w-full md:w-auto md:justify-start md:mt-2 px-4 md:px-0 pt-2 pb-4 md:py-0 text-sm md:text-xs font-medium text-foreground/70 hover:text-foreground transition-colors active:opacity-70 md:active:opacity-100"
aria-expanded={isExpanded}
>
<span>{isExpanded ? "Show less" : "Show more"}</span>
<span
className={`text-foreground/60 shrink-0 transition-transform duration-300 ease-in-out ${
isExpanded ? "rotate-180" : ""
}`}
>
<FaChevronDown className="w-3.5 h-3.5 md:w-3 md:h-3" />
</span>
</button>
)}
</div>
);
}