{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "magazine-layout",
  "title": "Magazine Layout",
  "author": "ilinxa",
  "description": "Slot-based magazine layout — hero, filter bar, sidebar, and a mixed-size article grid with infinite scroll and a filter hook.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "src/registry/components/layout/magazine-layout/magazine-layout.tsx",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { InfiniteLoader } from \"./parts/infinite-loader\";\nimport { MagazineTower } from \"./parts/magazine-tower\";\nimport {\n  DEFAULT_GRID_LABELS,\n  type MagazineLayoutProps,\n} from \"./types\";\n\n/**\n * MagazineLayout — slot-based magazine layout. Composes the news-domain\n * components (page-hero / filter-bar / news-cards / category-cloud /\n * newsletter-signup) at the consumer level via slots; the layout itself\n * imports none of them at runtime.\n *\n * Generic over `T` — the item shape. Pair with `useMagazineFilter` for\n * filter + page state, or drive `displayedItems` / `featuredItem` /\n * `hasMore` / `isLoading` / `onLoadMore` directly.\n */\nexport function MagazineLayout<T>(props: MagazineLayoutProps<T>) {\n  const {\n    displayedItems,\n    featuredItem,\n    hasMore = false,\n    isLoading = false,\n    onLoadMore,\n    renderItem,\n    renderFeatured,\n    hero,\n    filterBar,\n    sidebar,\n    emptyState,\n    labels: labelsProp,\n    className,\n    mainClassName,\n    sidebarClassName,\n  } = props;\n\n  const labels = useMemo(\n    () => ({ ...DEFAULT_GRID_LABELS, ...labelsProp }),\n    [labelsProp],\n  );\n\n  const hasContent = displayedItems.length > 0 || featuredItem !== undefined;\n  const isEmpty = !hasContent && !isLoading;\n\n  const showSidebar = sidebar !== undefined;\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      {hero ? <div className=\"mb-12\">{hero}</div> : null}\n\n      {filterBar ? <div className=\"mb-8\">{filterBar}</div> : null}\n\n      {isEmpty ? (\n        <div className=\"py-20 text-center\">\n          {emptyState ?? (\n            <p className=\"text-muted-foreground\">{labels.emptyStateText}</p>\n          )}\n        </div>\n      ) : (\n        <div className=\"grid grid-cols-12 gap-6\">\n          <div\n            className={cn(\n              \"space-y-8\",\n              showSidebar ? \"col-span-12 lg:col-span-8\" : \"col-span-12\",\n              mainClassName,\n            )}\n          >\n            {featuredItem !== undefined ? (\n              <div>\n                {renderFeatured\n                  ? renderFeatured(featuredItem)\n                  : renderItem({ item: featuredItem, slot: \"large\", index: 0 })}\n              </div>\n            ) : null}\n\n            <MagazineTower items={displayedItems} renderItem={renderItem} />\n\n            <InfiniteLoader\n              hasMore={hasMore}\n              isLoading={isLoading}\n              onLoadMore={onLoadMore}\n              hasItems={hasContent}\n              labels={labels}\n            />\n          </div>\n\n          {showSidebar ? (\n            <aside\n              className={cn(\n                \"col-span-12 lg:col-span-4\",\n                sidebarClassName,\n              )}\n            >\n              <div className=\"sticky top-24 space-y-8\">{sidebar}</div>\n            </aside>\n          ) : null}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default MagazineLayout;\n",
      "type": "registry:component",
      "target": "components/magazine-layout/magazine-layout.tsx"
    },
    {
      "path": "src/registry/components/layout/magazine-layout/index.ts",
      "content": "export { MagazineLayout, default } from \"./magazine-layout\";\nexport { useMagazineFilter } from \"./hooks/use-magazine-filter\";\nexport { useInfiniteScroll } from \"./hooks/use-infinite-scroll\";\nexport type {\n  MagazineLayoutItemSlot,\n  MagazineLayoutLabels,\n  MagazineLayoutProps,\n  UseMagazineFilterArgs,\n  UseMagazineFilterReturn,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/magazine-layout/index.ts"
    },
    {
      "path": "src/registry/components/layout/magazine-layout/types.ts",
      "content": "import type { ReactNode } from \"react\";\n\n/**\n * Slot identifier passed to `renderItem`. The layout always renders one\n * or more `medium` slots; `large` is the lead slot in the main column\n * when there are enough items.\n */\nexport type MagazineLayoutItemSlot = \"large\" | \"medium\";\n\nexport interface MagazineLayoutLabels {\n  /** Visually-hidden loader announcement. Default: 'Loading more items…'. */\n  loadingLabel?: string;\n  /** Terminal end-of-list text. Default: 'All items shown'. */\n  endOfListText?: string;\n  /** Default empty-state body when `emptyState` slot is not provided. Default: 'No items to show.'. */\n  emptyStateText?: string;\n}\n\nexport interface MagazineLayoutProps<T> {\n  /** Current page of items (filtered + paged by the consumer or `useMagazineFilter`). */\n  displayedItems: T[];\n  /** Optional featured item rendered above the main column (whenever provided). */\n  featuredItem?: T;\n\n  /** Whether more items are available beyond the current page. */\n  hasMore?: boolean;\n  /** Whether `onLoadMore` is currently fetching. */\n  isLoading?: boolean;\n  /** Callback fired when the loader sentinel enters the viewport. */\n  onLoadMore?: () => void;\n\n  /**\n   * How to render one item in a given slot. Receives the slot identifier\n   * plus the item's `index` in the displayed list. Featured-item call site\n   * passes `index: 0`. Required.\n   *\n   * v0.2 cutover from positional `(item, slot)`. The deprecated positional\n   * shape was kept as `renderItemArgs` during the v0.1.x transition and is\n   * now removed; the canonical name `renderItem` carries the object shape.\n   */\n  renderItem: (args: {\n    item: T;\n    slot: MagazineLayoutItemSlot;\n    index: number;\n  }) => ReactNode;\n  /** How to render the featured item. Defaults to `renderItem(featuredItem, 'large')`. */\n  renderFeatured?: (item: T) => ReactNode;\n\n  /** Optional hero band rendered full-width above the filter bar. */\n  hero?: ReactNode;\n  /** Optional filter bar rendered above the grid. */\n  filterBar?: ReactNode;\n  /** Optional sidebar rendered to the right (col-span-4) of the main column. */\n  sidebar?: ReactNode;\n\n  /** Optional empty-state node. Rendered when displayedItems is empty AND not loading. */\n  emptyState?: ReactNode;\n\n  labels?: MagazineLayoutLabels;\n  className?: string;\n  mainClassName?: string;\n  sidebarClassName?: string;\n}\n\nexport const DEFAULT_GRID_LABELS: Required<MagazineLayoutLabels> = {\n  loadingLabel: \"Loading more items…\",\n  endOfListText: \"All items shown\",\n  emptyStateText: \"No items to show.\",\n};\n\nexport interface UseMagazineFilterArgs<T> {\n  /** Source items (consumer's full dataset). */\n  items: T[];\n  /** Per-page size. Default: 6. */\n  pageSize?: number;\n  /** Predicate to derive the featured item (returns true for the one to feature). */\n  isFeatured?: (item: T) => boolean;\n  /** Filter / search predicate. Default: include everything. */\n  filterPredicate?: (item: T) => boolean;\n  /** Sort comparator. Default: no sort. */\n  sortComparator?: (a: T, b: T) => number;\n  /** Artificial loading delay in ms (for demos). Default: 0. */\n  simulatedLoadingMs?: number;\n}\n\nexport interface UseMagazineFilterReturn<T> {\n  displayedItems: T[];\n  featuredItem: T | undefined;\n  hasMore: boolean;\n  isLoading: boolean;\n  filteredCount: number;\n  loadMore: () => void;\n  reset: () => void;\n}\n",
      "type": "registry:component",
      "target": "components/magazine-layout/types.ts"
    },
    {
      "path": "src/registry/components/layout/magazine-layout/parts/magazine-tower.tsx",
      "content": "import type { ReactNode } from \"react\";\nimport type { MagazineLayoutItemSlot } from \"../types\";\n\n/**\n * The main column's \"tower\" of cards: 1 large card on top, 2-up medium row,\n * then a 3-up grid of remaining medium cards. The descending-density\n * pattern is the magazine intuition baked in.\n *\n * If there are 1 or 2 items, the layout adapts: one item = just the large;\n * two items = large + a single medium row.\n */\nexport function MagazineTower<T>({\n  items,\n  renderItem,\n}: {\n  items: T[];\n  /** Same object-shape signature as the parent component's `renderItem` prop. */\n  renderItem: (args: { item: T; slot: MagazineLayoutItemSlot; index: number }) => ReactNode;\n}) {\n  if (items.length === 0) return null;\n\n  const lead = items[0];\n  const twoUp = items.slice(1, 3);\n  const rest = items.slice(3);\n\n  return (\n    <div className=\"space-y-8\">\n      {renderItem({ item: lead, slot: \"large\", index: 0 })}\n\n      {twoUp.length > 0 ? (\n        <div className=\"grid gap-6 md:grid-cols-2\">\n          {twoUp.map((item, i) => (\n            <div key={i}>{renderItem({ item, slot: \"medium\", index: i + 1 })}</div>\n          ))}\n        </div>\n      ) : null}\n\n      {rest.length > 0 ? (\n        <div className=\"grid gap-6 md:grid-cols-2 lg:grid-cols-3\">\n          {rest.map((item, i) => (\n            <div key={i}>{renderItem({ item, slot: \"medium\", index: i + 3 })}</div>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/magazine-layout/parts/magazine-tower.tsx"
    },
    {
      "path": "src/registry/components/layout/magazine-layout/parts/infinite-loader.tsx",
      "content": "\"use client\";\n\nimport { useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useInfiniteScroll } from \"../hooks/use-infinite-scroll\";\nimport type { MagazineLayoutLabels } from \"../types\";\n\n/**\n * Loader region. Holds the IntersectionObserver sentinel + visible status:\n *\n * - Loading → 3 bouncing dots (visually) + visually-hidden text label in\n *   an `aria-live=\"polite\"` region.\n * - End-of-list (no more items, but at least one rendered) → muted text\n *   announcing completion.\n * - Otherwise → invisible sentinel only.\n */\nexport function InfiniteLoader({\n  hasMore,\n  isLoading,\n  onLoadMore,\n  hasItems,\n  labels,\n}: {\n  hasMore: boolean;\n  isLoading: boolean;\n  onLoadMore: (() => void) | undefined;\n  hasItems: boolean;\n  labels: Required<MagazineLayoutLabels>;\n}) {\n  const sentinelRef = useRef<HTMLDivElement>(null);\n  useInfiniteScroll({\n    ref: sentinelRef,\n    hasMore,\n    isLoading,\n    onLoadMore,\n  });\n\n  const showEndOfList = !hasMore && hasItems && !isLoading;\n\n  return (\n    <div ref={sentinelRef} className=\"flex justify-center py-8\">\n      {isLoading ? (\n        <div\n          aria-live=\"polite\"\n          className=\"flex items-center gap-3\"\n        >\n          <span className=\"sr-only\">{labels.loadingLabel}</span>\n          {[0, 1, 2].map((i) => (\n            <span\n              key={i}\n              aria-hidden=\"true\"\n              className={cn(\n                \"h-2 w-2 rounded-full bg-primary\",\n                \"motion-safe:animate-bounce\",\n              )}\n              style={{ animationDelay: `${-300 + i * 150}ms` }}\n            />\n          ))}\n        </div>\n      ) : null}\n\n      {showEndOfList ? (\n        <p\n          aria-live=\"polite\"\n          className=\"text-sm text-muted-foreground\"\n        >\n          {labels.endOfListText}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/magazine-layout/parts/infinite-loader.tsx"
    },
    {
      "path": "src/registry/components/layout/magazine-layout/hooks/use-infinite-scroll.ts",
      "content": "\"use client\";\n\nimport { useEffect, type RefObject } from \"react\";\n\n/**\n * Wires an IntersectionObserver to a sentinel element. When the sentinel\n * enters the viewport AND `hasMore` is true AND `isLoading` is false,\n * `onLoadMore` is fired.\n */\nexport function useInfiniteScroll({\n  ref,\n  hasMore,\n  isLoading,\n  onLoadMore,\n  threshold = 0.1,\n}: {\n  ref: RefObject<HTMLDivElement | null>;\n  hasMore: boolean;\n  isLoading: boolean;\n  onLoadMore: (() => void) | undefined;\n  threshold?: number;\n}) {\n  useEffect(() => {\n    const sentinel = ref.current;\n    if (!sentinel) return;\n    if (!onLoadMore) return;\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries[0].isIntersecting && hasMore && !isLoading) {\n          onLoadMore();\n        }\n      },\n      { threshold },\n    );\n\n    observer.observe(sentinel);\n    return () => observer.disconnect();\n  }, [ref, hasMore, isLoading, onLoadMore, threshold]);\n}\n",
      "type": "registry:component",
      "target": "components/magazine-layout/hooks/use-infinite-scroll.ts"
    },
    {
      "path": "src/registry/components/layout/magazine-layout/hooks/use-magazine-filter.ts",
      "content": "\"use client\";\n\nimport { useCallback, useMemo, useState } from \"react\";\nimport type {\n  UseMagazineFilterArgs,\n  UseMagazineFilterReturn,\n} from \"../types\";\n\n/**\n * Companion hook for the simple consumer who wants filter + sort + paging\n * + loading state without writing it from scratch. Sophisticated consumers\n * skip this and drive the layout's props directly.\n *\n * Reset semantics: any change to `items`, `filterPredicate`, or\n * `sortComparator` reference resets the page back to 1.\n */\nexport function useMagazineFilter<T>({\n  items,\n  pageSize = 6,\n  isFeatured,\n  filterPredicate,\n  sortComparator,\n  simulatedLoadingMs = 0,\n}: UseMagazineFilterArgs<T>): UseMagazineFilterReturn<T> {\n  const [page, setPage] = useState(1);\n  const [isLoading, setIsLoading] = useState(false);\n\n  // Documented reset semantics — any change to items/filterPredicate/\n  // sortComparator reference snaps page back to 1. Consumers MUST memoize\n  // these references (or accept the reset cost) — that contract is part of\n  // the hook's API surface.\n  //\n  // Implemented via React's \"adjust state during render\" pattern (NOT an\n  // effect): track the prior dep refs in state; if any have changed, snap\n  // page to 1 in the same pass. React batches the setStates and reruns\n  // immediately — no extra commit. This avoids react-hooks/set-state-in-effect\n  // (the React Compiler rejects setPage(1) inside a useEffect).\n  // useState wraps function values with `() => fn` to bypass useState's\n  // lazy-initializer treatment + updater-fn treatment for function-typed state.\n  const [prevItems, setPrevItems] = useState(items);\n  const [prevFilterPredicate, setPrevFilterPredicate] = useState<\n    typeof filterPredicate\n  >(() => filterPredicate);\n  const [prevSortComparator, setPrevSortComparator] = useState<\n    typeof sortComparator\n  >(() => sortComparator);\n  if (\n    prevItems !== items ||\n    prevFilterPredicate !== filterPredicate ||\n    prevSortComparator !== sortComparator\n  ) {\n    setPrevItems(items);\n    setPrevFilterPredicate(() => filterPredicate);\n    setPrevSortComparator(() => sortComparator);\n    setPage(1);\n  }\n\n  const sortedFiltered = useMemo(() => {\n    let result = filterPredicate ? items.filter(filterPredicate) : items.slice();\n    if (sortComparator) result = result.sort(sortComparator);\n    return result;\n  }, [items, filterPredicate, sortComparator]);\n\n  const featuredItem = useMemo(() => {\n    if (!isFeatured) return undefined;\n    return sortedFiltered.find(isFeatured);\n  }, [sortedFiltered, isFeatured]);\n\n  const regularItems = useMemo(\n    () =>\n      featuredItem\n        ? sortedFiltered.filter((item) => item !== featuredItem)\n        : sortedFiltered,\n    [sortedFiltered, featuredItem],\n  );\n\n  const displayedItems = useMemo(\n    () => regularItems.slice(0, page * pageSize),\n    [regularItems, page, pageSize],\n  );\n\n  const hasMore = displayedItems.length < regularItems.length;\n  const filteredCount = sortedFiltered.length;\n\n  const loadMore = useCallback(() => {\n    if (isLoading || !hasMore) return;\n    if (simulatedLoadingMs > 0) {\n      setIsLoading(true);\n      setTimeout(() => {\n        setPage((p) => p + 1);\n        setIsLoading(false);\n      }, simulatedLoadingMs);\n    } else {\n      setPage((p) => p + 1);\n    }\n  }, [isLoading, hasMore, simulatedLoadingMs]);\n\n  const reset = useCallback(() => {\n    setPage(1);\n    setIsLoading(false);\n  }, []);\n\n  return {\n    displayedItems,\n    // Always returned (v0.2.1): gating on `page === 1` made the lead story\n    // vanish after the first \"load more\". `regularItems` already excludes it,\n    // so it renders exactly once regardless of how many pages are shown.\n    featuredItem,\n    hasMore,\n    isLoading,\n    filteredCount,\n    loadMore,\n    reset,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/magazine-layout/hooks/use-magazine-filter.ts"
    }
  ],
  "categories": [
    "layout",
    "news"
  ],
  "type": "registry:block"
}