diff --git a/samples/order.yml b/samples/order.yml new file mode 100644 index 0000000000..fca6e2d1b0 --- /dev/null +++ b/samples/order.yml @@ -0,0 +1,14 @@ +# Curated display order for the /samples hub. +# +# Each entry is a sample id — the file name without its .mdx extension +# (e.g. "verity" for samples/verity.mdx). Samples are rendered top-to-bottom +# in this order. Any sample not listed here is shown afterwards, sorted +# alphabetically by title. Reorder freely; this is the single source of truth +# for the gallery order. +- verity +- human-resources-assistant +- fit-assistant +- the-ravens-library +- brain-slop +- hugin +- yabt diff --git a/src/components/Common/CardWithImage.tsx b/src/components/Common/CardWithImage.tsx index 64634d3dcf..21feac7742 100644 --- a/src/components/Common/CardWithImage.tsx +++ b/src/components/Common/CardWithImage.tsx @@ -7,8 +7,7 @@ import Badge from "@site/src/components/Common/Badge"; import LazyImage from "@site/src/components/Common/LazyImage"; import isInternalUrl from "@docusaurus/isInternalUrl"; import clsx from "clsx"; -import { useTagLimit } from "@site/src/hooks/useTagLimit"; -import Tag from "@site/src/theme/Tag"; +import OverflowTagRow from "@site/src/components/Common/OverflowTagRow"; export interface CardWithImageProps { title: string; @@ -39,10 +38,6 @@ export default function CardWithImage({ const hasTags = tags.length > 0; const hasDate = date !== undefined; - const { visibleTags, hiddenCount, isExpanded, expandTags } = useTagLimit({ - tags, - }); - return (
{description}

{(hasTags || hasDate) && ( -
- {hasTags && ( -
- {visibleTags.map((tag) => ( - - {tag.label} - - ))} - {!isExpanded && hiddenCount > 0 && ( - { - e.preventDefault(); - e.stopPropagation(); - expandTags(); - }} - title="Show all tags" - className="pointer-events-auto" - > - +{hiddenCount} more - - )} -
- )} +
+ {hasTags && } {hasDate &&

{date}

}
)} diff --git a/src/components/Common/OverflowTagRow.tsx b/src/components/Common/OverflowTagRow.tsx new file mode 100644 index 0000000000..8cc0697fa6 --- /dev/null +++ b/src/components/Common/OverflowTagRow.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import Tag from "@site/src/theme/Tag"; + +const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +export interface OverflowTagItem { + label: string; + key?: string; + permalink?: string; + category?: string; +} + +interface OverflowTagRowProps { + tags: OverflowTagItem[]; + onTagClick?: (e: React.MouseEvent, tag: OverflowTagItem) => void; + isTagSelected?: (tag: OverflowTagItem) => boolean; + className?: string; +} + +const GAP_PX = 4; + +const tagKey = (tag: OverflowTagItem) => tag.key ?? tag.permalink ?? tag.label; + +export default function OverflowTagRow({ tags, onTagClick, isTagSelected, className }: OverflowTagRowProps) { + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(tags.length); + const [expanded, setExpanded] = useState(false); + + const recompute = useCallback(() => { + const el = measureRef.current; + if (!el) { + return; + } + + const containerWidth = el.clientWidth; + if (containerWidth === 0) { + return; + } + + const nodes = Array.from(el.children) as HTMLElement[]; + const pill = nodes[nodes.length - 1]; + const tagNodes = nodes.slice(0, tags.length); + const widths = tagNodes.map((node) => node.offsetWidth); + + const totalWithGaps = widths.reduce((sum, w, i) => sum + w + (i > 0 ? GAP_PX : 0), 0); + + if (totalWithGaps <= containerWidth) { + setVisibleCount(tags.length); + return; + } + + const available = containerWidth - pill.offsetWidth - GAP_PX; + let used = 0; + let count = 0; + for (let i = 0; i < widths.length; i++) { + const next = widths[i] + (count > 0 ? GAP_PX : 0); + if (used + next <= available) { + used += next; + count += 1; + } else { + break; + } + } + + setVisibleCount(Math.max(count, 1)); + }, [tags.length]); + + useIsomorphicLayoutEffect(() => { + const el = measureRef.current; + if (!el) { + return undefined; + } + + let cancelled = false; + const run = () => { + if (!cancelled) { + recompute(); + } + }; + + // The mirror's box width tracks the container, but its contents reflow + // when fonts finish loading after hydration — and that reflow doesn't + // resize the mirror, so recompute now, next frame, and once fonts settle. + const observer = new ResizeObserver(run); + observer.observe(el); + run(); + const raf = requestAnimationFrame(run); + if (typeof document !== "undefined" && document.fonts?.ready) { + document.fonts.ready.then(run).catch(() => {}); + } + + return () => { + cancelled = true; + cancelAnimationFrame(raf); + observer.disconnect(); + }; + }, [recompute, tags]); + + useEffect(() => { + setExpanded(false); + }, [tags]); + + const renderTag = (tag: OverflowTagItem) => ( + onTagClick(e, tag) : undefined} + className={clsx( + "shrink-0 whitespace-nowrap pointer-events-auto", + onTagClick && "cursor-pointer", + isTagSelected && !isTagSelected(tag) && "opacity-50" + )} + > + {tag.label} + + ); + + const hiddenCount = tags.length - visibleCount; + const visibleTags = expanded ? tags : tags.slice(0, visibleCount); + const hiddenTags = tags.slice(visibleCount); + + return ( +
+ {/* Off-flow mirror used only for measurement: every tag + a worst-case pill. */} + + +
+ {visibleTags.map(renderTag)} + + {!expanded && hiddenCount > 0 && ( + + )} +
+
+ ); +} diff --git a/src/components/Samples/Hub/Partials/SampleCard.tsx b/src/components/Samples/Hub/Partials/SampleCard.tsx index 62ab8252bf..7a1aefd12f 100644 --- a/src/components/Samples/Hub/Partials/SampleCard.tsx +++ b/src/components/Samples/Hub/Partials/SampleCard.tsx @@ -3,8 +3,8 @@ import Link from "@docusaurus/Link"; import Heading from "@theme/Heading"; import LazyImage from "@site/src/components/Common/LazyImage"; import clsx from "clsx"; -import Tag from "@site/src/theme/Tag"; import LanguageTag from "@site/src/components/Samples/Hub/Partials/LanguageTag"; +import OverflowTagRow from "@site/src/components/Common/OverflowTagRow"; interface TagWithCategory { label: string; @@ -56,6 +56,16 @@ export default function SampleCard({ return selectedTags.has(tag.key); }; + const selectedFirst = (list: TagWithCategory[]) => { + if (!selectedTags || selectedTags.size === 0) { + return list; + } + return [...list].sort((a, b) => Number(selectedTags.has(b.key)) - Number(selectedTags.has(a.key))); + }; + + const orderedChallengesSolutionsTags = selectedFirst(challengesSolutionsTags); + const orderedFeatureTags = selectedFirst(featureTags); + return (
{description}

- {challengesSolutionsTags.length > 0 && ( + {orderedChallengesSolutionsTags.length > 0 && (
Challenges & Solutions -
- {challengesSolutionsTags.map((tag) => ( - handleTagClick(e, tag) : undefined} - className={clsx( - onTagClick && "cursor-pointer", - !isTagSelected(tag) && "opacity-50" - )} - > - {tag.label} - - ))} -
+
)} - {featureTags.length > 0 && ( + {orderedFeatureTags.length > 0 && (
Features -
- {featureTags.map((tag) => ( - handleTagClick(e, tag) : undefined} - className={clsx( - onTagClick && "cursor-pointer", - !isTagSelected(tag) && "opacity-50" - )} - > - {tag.label} - - ))} -
+
)}
diff --git a/src/plugins/recent-samples-plugin.ts b/src/plugins/recent-samples-plugin.ts index 7c67c0e89a..121f7f24a8 100644 --- a/src/plugins/recent-samples-plugin.ts +++ b/src/plugins/recent-samples-plugin.ts @@ -146,6 +146,28 @@ export default function recentSamplesPlugin(context, _options): Plugin { }; }); + let orderList: string[] = []; + const orderFilePath = path.join(samplesDir, "order.yml"); + if (fs.existsSync(orderFilePath)) { + try { + const loaded = yaml.load(fs.readFileSync(orderFilePath, "utf8")); + if (Array.isArray(loaded)) { + orderList = loaded.map((id) => String(id)); + } + } catch (e) { + console.error("Failed to load samples/order.yml", e); + } + } + + const orderIndex = new Map(); + orderList.forEach((id, index) => orderIndex.set(id, index)); + const rankOf = (id: string) => (orderIndex.has(id) ? orderIndex.get(id)! : Number.MAX_SAFE_INTEGER); + + samples.sort((a, b) => { + const rankDiff = rankOf(a.id) - rankOf(b.id); + return rankDiff !== 0 ? rankDiff : (a.title || "").localeCompare(b.title || ""); + }); + const allTags: Array<{ label: string; key: string;