@goodfellow-cms/registry 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,7 +71,7 @@ A block is a `registry:block` item with `meta.goodfellow`:
71
71
 
72
72
  ### Packages
73
73
 
74
- The admin panel can't install npm packages, so every site includes the same set and blocks can use only those. Besides `react`, `@goodfellow-cms/react` and `@puckeditor/core`, which every site has, they are `class-variance-authority`, `clsx`, `cn`, `embla-carousel-react`, `lucide-react`, `radix-ui`, `tailwind-merge` and `tw-animate-css`. An item whose `dependencies` name anything else can't be added. To suggest a package for the list, open an issue.
74
+ The admin panel can't install npm packages, so every site includes the same set and blocks can use only those. Besides `react`, `@goodfellow-cms/core`, `@goodfellow-cms/react` and `@puckeditor/core`, which every site has, they are `class-variance-authority`, `clsx`, `cn`, `embla-carousel-react`, `lucide-react`, `radix-ui`, `tailwind-merge` and `tw-animate-css`. An item whose `dependencies` name anything else can't be added. To suggest a package for the list, open an issue.
75
75
 
76
76
  ### Writing the blocks
77
77
 
@@ -79,6 +79,8 @@ Registry blocks follow the same rules as any Goodfellow block (see [AGENTS.md](h
79
79
 
80
80
  - Every block has a `className` prop (`className: classNameField`), applied to its outermost element and combined with `cx()` from `@goodfellow-cms/react`, last.
81
81
  - Use the theme's classes (`bg-primary`, `text-muted-foreground`, `rounded-lg`), which shadcn's components use too, so blocks follow the site's colors and fonts.
82
+ - **Give it words to be found by** in the editor's block search, besides its label: `export default withKeywords(block, ["faq", "questions"])` from `@goodfellow-cms/react`, lowercase. It can wrap `withParts()`, or the other way round.
83
+ - **Name the block's parts**, so a site's theme can restyle them: `const part = useParts()` at the start of `render`, then `className={part("gf-my-block-title", "text-xl font-bold", className)}` on each part (the block's own classes, then anything an editor chose, `className` last), and declare them with what owners call them: `export default withParts(block, { "gf-my-block-title": "Title" })`. Start every name with `gf-` and the item's name, so they never clash with another block's, and never rename or remove one once it's published, since themes refer to them. Client Components get their parts' classes as plain props from the block, as `shadcn-faq` does.
82
84
  - Render links and images with `SiteLink` and `SiteImage` from `@goodfellow-cms/react`, and image fields with `mediaField()`. To style a link as a button, give `SiteLink` the classes from shadcn's `buttonVariants()`.
83
85
  - Labels and options are for non-technical people: "Space above and below", not "padding-y".
84
86
  - `render` uses no hooks besides `useSite()`. Interactive parts go in a file starting with `"use client"`, which gets plain values and content (`children`, rich text) only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/registry",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Goodfellow's block registry: blocks built with shadcn/ui, which sites install from the admin panel.",
5
5
  "keywords": [
6
6
  "goodfellow",
@@ -28,8 +28,8 @@
28
28
  "./package.json": "./package.json"
29
29
  },
30
30
  "devDependencies": {
31
- "@goodfellow-cms/core": "0.5.0",
32
- "@goodfellow-cms/react": "0.5.0",
31
+ "@goodfellow-cms/core": "0.7.0",
32
+ "@goodfellow-cms/react": "0.7.0",
33
33
  "@puckeditor/core": "0.23.0",
34
34
  "@types/react": "^19.2.0",
35
35
  "@types/react-dom": "^19.2.0",
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "path": "blocks/installed/custom-html/block.tsx",
10
10
  "type": "registry:block",
11
- "content": "import { classNameField, sanitizeHtml } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { HtmlPreview } from \"./html-preview\";\nimport { HtmlWithScripts } from \"./html-scripts\";\n\nexport interface CustomHtmlProps {\n html: string;\n sanitize: boolean;\n className: string;\n}\n\n/**\n * HTML written by hand, such as a widget's embed code. It's made safe by\n * default: scripts, styles, frames, forms and inline styles are left out. Used\n * as it's written, it runs on the site, but in the editor it's only shown,\n * inside a sandboxed frame, where it can't reach the admin panel, and its\n * scripts don't run. On the site they run whenever its page is shown, also\n * after moving to it on a Next.js site.\n */\nconst CustomHtml: ComponentConfig<CustomHtmlProps> = {\n label: \"Custom HTML\",\n fields: {\n html: { type: \"textarea\", label: \"HTML\" },\n sanitize: {\n type: \"radio\",\n label: \"Make it safe\",\n options: [\n { label: \"Yes: leave out scripts, styles and frames\", value: true },\n { label: \"No: use it exactly as written\", value: false },\n ],\n },\n className: classNameField,\n },\n defaultProps: { html: \"<p>Your HTML goes here.</p>\", sanitize: true, className: \"\" },\n render: ({ html, sanitize, className, puck }) => {\n if (!sanitize && puck.isEditing) return <HtmlPreview html={html} className={className} />;\n // Only HTML with scripts needs the browser's help, which loads React on the page.\n if (!sanitize && /<script[\\s>]/i.test(html)) return <HtmlWithScripts html={html} className={className} />;\n return (\n <div\n className={className || undefined}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized unless the editor chose to use it as written\n dangerouslySetInnerHTML={{ __html: sanitize ? sanitizeHtml(html) : html }}\n />\n );\n },\n};\n\nexport default CustomHtml;\n"
11
+ "content": "import { classNameField, sanitizeHtml, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { HtmlPreview } from \"./html-preview\";\nimport { HtmlWithScripts } from \"./html-scripts\";\n\nexport interface CustomHtmlProps {\n html: string;\n sanitize: boolean;\n className: string;\n}\n\n/**\n * HTML written by hand, such as a widget's embed code. It's made safe by\n * default: scripts, styles, frames, forms and inline styles are left out. Used\n * as it's written, it runs on the site, but in the editor it's only shown,\n * inside a sandboxed frame, where it can't reach the admin panel, and its\n * scripts don't run. On the site they run whenever its page is shown, also\n * after moving to it on a Next.js site.\n */\nconst CustomHtml: ComponentConfig<CustomHtmlProps> = withParts(\n {\n label: \"Custom HTML\",\n fields: {\n html: { type: \"textarea\", label: \"HTML\" },\n sanitize: {\n type: \"radio\",\n label: \"Make it safe\",\n options: [\n { label: \"Yes: leave out scripts, styles and frames\", value: true },\n { label: \"No: use it exactly as written\", value: false },\n ],\n },\n className: classNameField,\n },\n defaultProps: { html: \"<p>Your HTML goes here.</p>\", sanitize: true, className: \"\" },\n render: ({ html, sanitize, className: chosen, puck }) => {\n const part = useParts();\n const className = part(\"gf-custom-html\", undefined, chosen);\n if (!sanitize && puck.isEditing) return <HtmlPreview html={html} className={className} />;\n // Only HTML with scripts needs the browser's help, which loads React on the page.\n if (!sanitize && /<script[\\s>]/i.test(html)) return <HtmlWithScripts html={html} className={className} />;\n return (\n <div\n className={className}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized unless the editor chose to use it as written\n dangerouslySetInnerHTML={{ __html: sanitize ? sanitizeHtml(html) : html }}\n />\n );\n },\n },\n {\n \"gf-custom-html\": \"Custom HTML\",\n },\n);\n\nexport default CustomHtml;\n"
12
12
  },
13
13
  {
14
14
  "path": "blocks/installed/custom-html/html-preview.tsx",
@@ -24,7 +24,7 @@
24
24
  "meta": {
25
25
  "goodfellow": {
26
26
  "category": "Advanced",
27
- "version": "0.5.0"
27
+ "version": "0.7.0"
28
28
  }
29
29
  },
30
30
  "dependencies": []
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "feed-list",
4
+ "type": "registry:block",
5
+ "title": "News feed",
6
+ "description": "The latest items of another site's RSS or Atom feed, read when the site is built: titles linking to them, with optional dates.",
7
+ "files": [
8
+ {
9
+ "path": "blocks/installed/feed-list/block.tsx",
10
+ "type": "registry:block",
11
+ "content": "import { addDays, changesOn, todayIn } from \"@goodfellow-cms/core\";\nimport { classNameField, SiteLink, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\n\nexport interface FeedItem {\n title: string;\n link: string;\n date: string;\n}\n\nexport interface FeedListProps {\n heading: string;\n feed: string;\n limit: number;\n showDates: boolean;\n moreLabel: string;\n moreHref: string;\n emptyText: string;\n className: string;\n /** Read from the feed when the site is built. */\n items?: FeedItem[];\n}\n\n/** How long a build reuses a feed it has read. */\nconst FEED_CACHE_MS = 600_000;\nconst FEED_LIMIT = 5_242_880;\nconst feedCache = new Map<string, { at: number; text: Promise<string> }>();\n\n/** Fetches an RSS or Atom feed from an `https://` address, once every few minutes. */\nasync function fetchFeed(address: string): Promise<string> {\n const url = address.trim();\n if (!/^https?:\\/\\//i.test(url)) throw new Error(\"The feed's address must start with https://.\");\n const cached = feedCache.get(url);\n if (cached && Date.now() - cached.at < FEED_CACHE_MS) return cached.text;\n const text = (async () => {\n const response = await fetch(url, {\n headers: { accept: \"application/rss+xml, application/atom+xml, application/xml, text/xml\" },\n signal: AbortSignal.timeout(20_000),\n });\n if (!response.ok) throw new Error(`The feed's address answered ${response.status}.`);\n const body = await response.text();\n if (body.length > FEED_LIMIT) throw new Error(\"The feed is too large to read.\");\n return body;\n })();\n feedCache.set(url, { at: Date.now(), text });\n text.catch(() => feedCache.delete(url));\n return text;\n}\n\nconst entities: Record<string, string> = { amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \" };\n\n/** A tag's text, without CDATA markers, tags or entities. */\nfunction decode(text: string): string {\n return text\n .replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g, \"$1\")\n .replace(/<[^>]*>/g, \"\")\n .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))\n .replace(/&#(\\d+);/g, (_, code) => String.fromCodePoint(Number(code)))\n .replace(/&([a-z]+);/gi, (match, name) => entities[name.toLowerCase()] ?? match)\n .trim();\n}\n\nfunction tag(xml: string, name: string): string {\n const match = xml.match(new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, \"i\"));\n return decode(match?.[1] ?? \"\");\n}\n\n/** An Atom entry's link: its `alternate` one, or the first. */\nfunction atomLink(xml: string): string {\n const links = [...xml.matchAll(/<link\\b([^>]*)\\/?>/gi)].map((match) => match[1] ?? \"\");\n const chosen = links.find((attrs) => !/rel=[\"'](?!alternate)/i.test(attrs)) ?? links[0] ?? \"\";\n return chosen.match(/href=[\"']([^\"']+)[\"']/i)?.[1] ?? \"\";\n}\n\n/** The titles, links and dates of an RSS or Atom feed's items. Their text and descriptions are left out. */\nexport function readFeedItems(xml: string): FeedItem[] {\n const rss = [...xml.matchAll(/<item\\b[^>]*>([\\s\\S]*?)<\\/item>/gi)].map(([, item = \"\"]) => ({\n title: tag(item, \"title\"),\n link: tag(item, \"link\") || tag(item, \"guid\"),\n date: tag(item, \"pubDate\") || tag(item, \"dc:date\"),\n }));\n const items = rss.length\n ? rss\n : [...xml.matchAll(/<entry\\b[^>]*>([\\s\\S]*?)<\\/entry>/gi)].map(([, entry = \"\"]) => ({\n title: tag(entry, \"title\"),\n link: atomLink(entry),\n date: tag(entry, \"published\") || tag(entry, \"updated\"),\n }));\n return items.filter((item) => item.title && /^https?:\\/\\//i.test(item.link));\n}\n\nfunction formatDate(date: string, timeZone: string | undefined, language: string | undefined): string {\n const parsed = new Date(date);\n if (Number.isNaN(parsed.getTime())) return \"\";\n return parsed.toLocaleDateString(language || \"en\", { month: \"long\", day: \"numeric\", year: \"numeric\", timeZone });\n}\n\n/**\n * The latest items of another site's news feed (RSS or Atom), such as a podcast's episodes or a\n * news site's headlines: each one's title, linking to it, and its date. The feed is read when the\n * site is built, and the block asks for a rebuild the next day, so overnight builds keep it current.\n * Only titles, links and dates are shown, never the items' text.\n */\nconst FeedList: ComponentConfig<FeedListProps> = withParts(\n {\n label: \"News feed\",\n fields: {\n heading: { type: \"text\", label: \"Heading (leave empty for none)\", contentEditable: true },\n feed: { type: \"text\", label: \"The feed's address (RSS or Atom)\", placeholder: \"https://example.org/feed.xml\" },\n limit: { type: \"number\", label: \"How many\", min: 1, max: 50 },\n showDates: {\n type: \"radio\",\n label: \"Show dates\",\n options: [\n { label: \"Yes\", value: true },\n { label: \"No\", value: false },\n ],\n },\n moreLabel: { type: \"text\", label: 'Link under the list, such as \"View all\" (leave empty for none)' },\n moreHref: { type: \"text\", label: \"Where the link goes\" },\n emptyText: { type: \"text\", label: \"Text when the feed can't be read\" },\n className: classNameField,\n },\n defaultProps: {\n heading: \"\",\n feed: \"\",\n limit: 5,\n showDates: true,\n moreLabel: \"\",\n moreHref: \"\",\n emptyText: \"Nothing to show right now.\",\n className: \"\",\n },\n resolveData: async ({ props }, { metadata }) => {\n if (typeof document !== \"undefined\" || !props.feed?.trim()) return { props: { ...props, items: undefined } };\n const site = metadata.site as { timeZone?: string; language?: string } | undefined;\n try {\n // Feeds change daily, so the overnight build should run again tomorrow.\n changesOn(addDays(todayIn(site?.timeZone), 1));\n const items = readFeedItems(await fetchFeed(props.feed))\n .slice(0, Math.max(1, props.limit || 5))\n .map((item) => ({ ...item, date: formatDate(item.date, site?.timeZone, site?.language) }));\n return { props: { ...props, items } };\n } catch (error) {\n console.warn(\n `The feed at ${props.feed} couldn't be read, so the page is built without it: ${(error as Error).message}`,\n );\n return { props: { ...props, items: [] } };\n }\n },\n render: ({ heading, feed, items, showDates, moreLabel, moreHref, emptyText, className, puck }) => {\n const part = useParts();\n return (\n <div className={part(\"gf-feed-list\", \"text-foreground\", className)}>\n {heading && (\n <h2 className={part(\"gf-feed-list-heading\", \"font-heading text-2xl font-semibold\")}>{heading}</h2>\n )}\n {items && items.length > 0 ? (\n <ul className={part(\"gf-feed-list-items\", \"mt-3 divide-y divide-border\")}>\n {items.map((item, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: two items can link to the same address\n <li key={`${index}-${item.link}`} className={part(\"gf-feed-list-item\", \"py-2\")}>\n <SiteLink href={item.link} className={part(\"gf-feed-list-link\", \"font-medium hover:underline\")}>\n {item.title}\n </SiteLink>\n {showDates && item.date && (\n <span className={part(\"gf-feed-list-date\", \"block text-sm text-muted-foreground\")}>\n {item.date}\n </span>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <p className={part(\"gf-feed-list-empty\", \"mt-3 text-muted-foreground\")}>\n {puck?.isEditing && feed ? `The latest items from ${feed} appear here on the site.` : emptyText}\n </p>\n )}\n {moreLabel && moreHref && (\n <SiteLink\n href={moreHref}\n className={part(\n \"gf-feed-list-more\",\n \"mt-3 inline-block text-sm font-semibold text-primary hover:underline\",\n )}\n >\n {moreLabel}\n </SiteLink>\n )}\n </div>\n );\n },\n },\n {\n \"gf-feed-list\": \"News feed\",\n \"gf-feed-list-heading\": \"Heading\",\n \"gf-feed-list-items\": \"List\",\n \"gf-feed-list-item\": \"Item\",\n \"gf-feed-list-link\": \"Item's title\",\n \"gf-feed-list-date\": \"Item's date\",\n \"gf-feed-list-empty\": \"Text when empty\",\n \"gf-feed-list-more\": \"Link under the list\",\n },\n);\n\nexport default withKeywords(FeedList, [\"rss\", \"atom\", \"feed\", \"news\", \"podcast\", \"headlines\"]);\n"
12
+ }
13
+ ],
14
+ "meta": {
15
+ "goodfellow": {
16
+ "category": "Page sections",
17
+ "version": "0.7.0"
18
+ }
19
+ },
20
+ "dependencies": []
21
+ }
package/r/map.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "map",
4
+ "type": "registry:block",
5
+ "title": "Map",
6
+ "description": "A Google map of an address or place, or one from Google Maps' embed code. No API key needed.",
7
+ "files": [
8
+ {
9
+ "path": "blocks/installed/map/block.tsx",
10
+ "type": "registry:block",
11
+ "content": "import { classNameField, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\n\nexport interface MapProps {\n query: string;\n title: string;\n zoom: number;\n height: number;\n className: string;\n}\n\n/**\n * The map's address: Google Maps' own embed address, from the embed code its\n * Share button gives (or that address itself), or else a map of the address\n * or place, which needs no key. Nothing but Google Maps' embedded maps is shown.\n */\nexport function mapAddress(input: string, zoom: number): string | undefined {\n const text = input.trim();\n if (!text) return undefined;\n const embedded = /<iframe\\b[^>]*\\bsrc=[\"']([^\"']+)[\"']/i.exec(text)?.[1]?.replaceAll(\"&amp;\", \"&\") ?? text;\n if (/^https:\\/\\//i.test(embedded)) {\n try {\n const url = new URL(embedded);\n return url.hostname === \"www.google.com\" && url.pathname === \"/maps/embed\" ? url.href : undefined;\n } catch {\n return undefined;\n }\n }\n if (text.includes(\"<\")) return undefined;\n const params = new URLSearchParams({ q: text, z: String(Math.min(21, Math.max(1, zoom || 15))), output: \"embed\" });\n return `https://maps.google.com/maps?${params}`;\n}\n\nfunction MapView({ query, title, zoom, height, className, puck }: MapProps & { puck?: { isEditing?: boolean } }) {\n const part = useParts();\n const src = mapAddress(query, zoom);\n if (!src) {\n // Only editors see why there's no map; visitors see nothing.\n if (!puck?.isEditing) return null;\n return (\n <div className={part(\"gf-map\", \"rounded-lg bg-muted p-6 text-muted-foreground\", className)}>\n {query.trim()\n ? \"This isn't an address or Google Maps' embed code, so no map is shown.\"\n : \"Enter an address to show a map.\"}\n </div>\n );\n }\n return (\n <iframe\n src={src}\n title={title || \"Map\"}\n loading=\"lazy\"\n referrerPolicy=\"no-referrer-when-downgrade\"\n // Google's map needs its scripts and its own address; it can open Google Maps, but nothing else.\n sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\"\n className={part(\"gf-map\", \"w-full rounded-lg border border-border\", className)}\n style={{ height: Math.max(200, height || 400) }}\n />\n );\n}\n\n/** A Google map of an address or place, which visitors can move around and open in Google Maps. No API key needed. */\nconst MapBlock: ComponentConfig<MapProps> = withParts(\n {\n label: \"Map\",\n fields: {\n query: {\n type: \"textarea\",\n label: \"Address or place, or the embed code from Google Maps' Share button\",\n placeholder: \"1600 Pennsylvania Ave NW, Washington, DC\",\n },\n title: { type: \"text\", label: \"What the map shows, for screen readers\" },\n zoom: { type: \"number\", label: \"Zoom, for an address (1 is the world, 21 a building)\", min: 1, max: 21 },\n height: { type: \"number\", label: \"Height in pixels\", min: 200, max: 900 },\n className: classNameField,\n },\n defaultProps: { query: \"\", title: \"Map\", zoom: 15, height: 400, className: \"\" },\n render: (props) => <MapView {...props} />,\n },\n { \"gf-map\": \"Map\" },\n);\n\nexport default withKeywords(MapBlock, [\"map\", \"google maps\", \"address\", \"location\", \"directions\"]);\n"
12
+ }
13
+ ],
14
+ "meta": {
15
+ "goodfellow": {
16
+ "category": "Page sections",
17
+ "version": "0.7.0"
18
+ }
19
+ },
20
+ "dependencies": []
21
+ }
package/r/panel.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "panel",
4
+ "type": "registry:block",
5
+ "title": "Panel",
6
+ "description": "A box with its title in a colored bar, other blocks inside it, and an optional button at its foot.",
7
+ "files": [
8
+ {
9
+ "path": "blocks/installed/panel/block.tsx",
10
+ "type": "registry:block",
11
+ "content": "import { classNameField, SiteLink, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig, Slot } from \"@puckeditor/core\";\n\nexport interface PanelProps {\n title: string;\n tone: \"primary\" | \"secondary\";\n content: Slot;\n buttonLabel: string;\n buttonHref: string;\n className: string;\n}\n\nconst titleClasses: Record<PanelProps[\"tone\"], string> = {\n primary: \"bg-primary text-primary-foreground\",\n secondary: \"bg-secondary text-secondary-foreground\",\n};\n\nconst buttonClasses: Record<PanelProps[\"tone\"], string> = {\n primary: \"bg-secondary text-secondary-foreground\",\n secondary: \"bg-primary text-primary-foreground\",\n};\n\n/** A box with its title in a colored bar, other blocks inside it, and an optional button at its foot. */\nconst Panel: ComponentConfig<PanelProps> = withParts(\n {\n label: \"Panel\",\n fields: {\n title: { type: \"text\", label: \"Title\", contentEditable: true },\n tone: {\n type: \"radio\",\n label: \"Title bar's color\",\n options: [\n { value: \"primary\", label: \"Primary\" },\n { value: \"secondary\", label: \"Secondary\" },\n ],\n },\n content: { type: \"slot\" },\n buttonLabel: { type: \"text\", label: \"Button at the foot (leave empty for none)\" },\n buttonHref: { type: \"text\", label: \"Button links to\" },\n className: classNameField,\n },\n defaultProps: { title: \"Panel\", tone: \"primary\", content: [], buttonLabel: \"\", buttonHref: \"\", className: \"\" },\n render: ({ title, tone, content: Content, buttonLabel, buttonHref, className }) => {\n const part = useParts();\n return (\n <div\n className={part(\n \"gf-panel\",\n \"flex h-full flex-col overflow-hidden rounded-md bg-background text-foreground shadow-md\",\n className,\n )}\n >\n {title && (\n <h2\n className={part(\"gf-panel-title\", [\n \"px-4 py-3 text-center font-heading text-2xl font-semibold\",\n titleClasses[tone],\n ])}\n >\n {title}\n </h2>\n )}\n <Content className={part(\"gf-panel-content\", \"flex-1 p-5\")} />\n {buttonLabel && buttonHref && (\n <div className={part(\"gf-panel-foot\", \"px-5 pb-5 text-center\")}>\n <SiteLink\n href={buttonHref}\n className={part(\"gf-panel-button\", [\n \"inline-block rounded-md px-5 py-2 text-sm font-semibold hover:opacity-90\",\n buttonClasses[tone],\n ])}\n >\n {buttonLabel}\n </SiteLink>\n </div>\n )}\n </div>\n );\n },\n },\n {\n \"gf-panel\": \"Panel\",\n \"gf-panel-title\": \"Title bar\",\n \"gf-panel-content\": \"Content\",\n \"gf-panel-foot\": \"Foot\",\n \"gf-panel-button\": \"Button\",\n },\n);\n\nexport default withKeywords(Panel, [\"panel\", \"box\", \"card\", \"title bar\", \"sidebar\"]);\n"
12
+ }
13
+ ],
14
+ "meta": {
15
+ "goodfellow": {
16
+ "category": "Page sections",
17
+ "version": "0.7.0"
18
+ }
19
+ },
20
+ "dependencies": []
21
+ }
package/r/registry.json CHANGED
@@ -21,7 +21,7 @@
21
21
  "goodfellow": {
22
22
  "category": "Page sections",
23
23
  "recommended": true,
24
- "version": "0.5.0"
24
+ "version": "0.7.0"
25
25
  }
26
26
  },
27
27
  "dependencies": []
@@ -45,7 +45,7 @@
45
45
  "goodfellow": {
46
46
  "category": "Page sections",
47
47
  "recommended": true,
48
- "version": "0.5.0"
48
+ "version": "0.7.0"
49
49
  }
50
50
  },
51
51
  "dependencies": []
@@ -69,7 +69,7 @@
69
69
  "goodfellow": {
70
70
  "category": "Page sections",
71
71
  "recommended": true,
72
- "version": "0.5.0"
72
+ "version": "0.7.0"
73
73
  }
74
74
  },
75
75
  "dependencies": []
@@ -92,7 +92,7 @@
92
92
  "goodfellow": {
93
93
  "category": "Page sections",
94
94
  "recommended": true,
95
- "version": "0.5.0"
95
+ "version": "0.7.0"
96
96
  }
97
97
  },
98
98
  "dependencies": []
@@ -119,7 +119,7 @@
119
119
  "meta": {
120
120
  "goodfellow": {
121
121
  "category": "Page sections",
122
- "version": "0.5.0"
122
+ "version": "0.7.0"
123
123
  }
124
124
  }
125
125
  },
@@ -144,10 +144,128 @@
144
144
  "goodfellow": {
145
145
  "category": "Page sections",
146
146
  "recommended": true,
147
- "version": "0.5.0"
147
+ "version": "0.7.0"
148
148
  }
149
149
  }
150
150
  },
151
+ {
152
+ "name": "feed-list",
153
+ "type": "registry:block",
154
+ "title": "News feed",
155
+ "description": "The latest items of another site's RSS or Atom feed, read when the site is built: titles linking to them, with optional dates.",
156
+ "files": [
157
+ {
158
+ "path": "blocks/installed/feed-list/block.tsx",
159
+ "type": "registry:block"
160
+ }
161
+ ],
162
+ "meta": {
163
+ "goodfellow": {
164
+ "category": "Page sections",
165
+ "version": "0.7.0"
166
+ }
167
+ },
168
+ "dependencies": []
169
+ },
170
+ {
171
+ "name": "map",
172
+ "type": "registry:block",
173
+ "title": "Map",
174
+ "description": "A Google map of an address or place, or one from Google Maps' embed code. No API key needed.",
175
+ "files": [
176
+ {
177
+ "path": "blocks/installed/map/block.tsx",
178
+ "type": "registry:block"
179
+ }
180
+ ],
181
+ "meta": {
182
+ "goodfellow": {
183
+ "category": "Page sections",
184
+ "version": "0.7.0"
185
+ }
186
+ },
187
+ "dependencies": []
188
+ },
189
+ {
190
+ "name": "social-links",
191
+ "type": "registry:block",
192
+ "title": "Social links",
193
+ "description": "Links to social networks, each shown as the network's mark in the text's color.",
194
+ "files": [
195
+ {
196
+ "path": "blocks/installed/social-links/block.tsx",
197
+ "type": "registry:block"
198
+ }
199
+ ],
200
+ "meta": {
201
+ "goodfellow": {
202
+ "category": "Page sections",
203
+ "version": "0.7.0"
204
+ }
205
+ },
206
+ "dependencies": []
207
+ },
208
+ {
209
+ "name": "panel",
210
+ "type": "registry:block",
211
+ "title": "Panel",
212
+ "description": "A box with its title in a colored bar, other blocks inside it, and an optional button at its foot.",
213
+ "files": [
214
+ {
215
+ "path": "blocks/installed/panel/block.tsx",
216
+ "type": "registry:block"
217
+ }
218
+ ],
219
+ "meta": {
220
+ "goodfellow": {
221
+ "category": "Page sections",
222
+ "version": "0.7.0"
223
+ }
224
+ },
225
+ "dependencies": []
226
+ },
227
+ {
228
+ "name": "slideshow-hero",
229
+ "type": "registry:block",
230
+ "title": "Slideshow with quick links",
231
+ "description": "Full-width pictures that fade from one to the next, with a badge and a column of quick links over them.",
232
+ "files": [
233
+ {
234
+ "path": "blocks/installed/slideshow-hero/block.tsx",
235
+ "type": "registry:block"
236
+ },
237
+ {
238
+ "path": "blocks/installed/slideshow-hero/slideshow-pictures.tsx",
239
+ "type": "registry:component"
240
+ }
241
+ ],
242
+ "meta": {
243
+ "goodfellow": {
244
+ "category": "Interactive",
245
+ "version": "0.7.0"
246
+ }
247
+ },
248
+ "dependencies": []
249
+ },
250
+ {
251
+ "name": "section-links",
252
+ "type": "registry:block",
253
+ "title": "Content with section links",
254
+ "description": "A page's blocks with a sidebar of links to the other pages in its part of the menu.",
255
+ "files": [
256
+ {
257
+ "path": "blocks/installed/section-links/block.tsx",
258
+ "type": "registry:block"
259
+ }
260
+ ],
261
+ "meta": {
262
+ "goodfellow": {
263
+ "category": "Navigation",
264
+ "version": "0.7.0"
265
+ }
266
+ },
267
+ "dependencies": []
268
+ },
151
269
  {
152
270
  "name": "custom-html",
153
271
  "type": "registry:block",
@@ -170,7 +288,7 @@
170
288
  "meta": {
171
289
  "goodfellow": {
172
290
  "category": "Advanced",
173
- "version": "0.5.0"
291
+ "version": "0.7.0"
174
292
  }
175
293
  },
176
294
  "dependencies": []
@@ -197,7 +315,7 @@
197
315
  "goodfellow": {
198
316
  "category": "Interactive",
199
317
  "recommended": true,
200
- "version": "0.5.0"
318
+ "version": "0.7.0"
201
319
  }
202
320
  },
203
321
  "dependencies": []
@@ -224,7 +342,7 @@
224
342
  "goodfellow": {
225
343
  "category": "Interactive",
226
344
  "recommended": true,
227
- "version": "0.5.0"
345
+ "version": "0.7.0"
228
346
  }
229
347
  },
230
348
  "dependencies": []
@@ -250,7 +368,7 @@
250
368
  "meta": {
251
369
  "goodfellow": {
252
370
  "category": "Interactive",
253
- "version": "0.5.0"
371
+ "version": "0.7.0"
254
372
  }
255
373
  },
256
374
  "dependencies": []
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "section-links",
4
+ "type": "registry:block",
5
+ "title": "Content with section links",
6
+ "description": "A page's blocks with a sidebar of links to the other pages in its part of the menu.",
7
+ "files": [
8
+ {
9
+ "path": "blocks/installed/section-links/block.tsx",
10
+ "type": "registry:block",
11
+ "content": "import { pageTree } from \"@goodfellow-cms/core\";\nimport { classNameField, SiteLink, useParts, useSite, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig, Slot } from \"@puckeditor/core\";\n\nexport interface SectionLinksProps {\n content: Slot;\n menu: string;\n section: string;\n side: \"right\" | \"left\";\n className: string;\n}\n\ninterface MenuLink {\n label: string;\n href: string;\n children?: MenuLink[];\n}\n\n/** Whether `href` is the page at `path`, or a section it's in. */\nfunction isCurrent(href: string, path: string): boolean {\n if (!href.startsWith(\"/\")) return false;\n return href === \"/\" ? path === \"/\" : path === href || path.startsWith(`${href}/`);\n}\n\n/**\n * The page's own blocks, with links beside them to the other pages in its part of the menu: the\n * top-level item it's under, and that item's submenu. On a page under one of those, such as one\n * ministry under \"Ministries\", the pages beside it instead. On a collection item's page, it can\n * show the links as if on another page, such as the list of news.\n */\nconst SectionLinks: ComponentConfig<SectionLinksProps> = withParts(\n {\n label: \"Content with section links\",\n fields: {\n content: { type: \"slot\" },\n menu: { type: \"text\", label: \"Menu the links come from\" },\n section: {\n type: \"text\",\n label: \"Show the links as if on this page (leave empty for the page itself)\",\n placeholder: \"/news\",\n },\n side: {\n type: \"radio\",\n label: \"Links' side\",\n options: [\n { value: \"right\", label: \"Right\" },\n { value: \"left\", label: \"Left\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: { content: [], menu: \"main\", section: \"\", side: \"right\", className: \"\" },\n render: ({ content: Content, menu, section, side, className }) => {\n const { menus, path, pages = [] } = useSite();\n const part = useParts();\n const at = section.trim() || path;\n const top = ((menus[menu] ?? []) as MenuLink[]).find(\n (item) => isCurrent(item.href, at) || item.children?.some((child) => isCurrent(child.href, at)),\n );\n const parent = top?.children?.find((child) => at.startsWith(`${child.href}/`));\n // The pages directly under the submenu page the page is under, such as one ministry's.\n const subpages = parent && parent.href !== \"/\" ? pageTree(pages, parent.href) : [];\n const links =\n parent && subpages.length > 0\n ? subpages.map((page) => ({ label: page.title, href: page.path }))\n : (top?.children ?? []).map((child) => ({ label: child.label, href: child.href }));\n return (\n <div\n className={part(\n \"gf-section-links\",\n [\n \"grid items-start gap-10\",\n side === \"left\" ? \"md:grid-cols-[16rem_minmax(0,1fr)]\" : \"md:grid-cols-[minmax(0,1fr)_16rem]\",\n ],\n className,\n )}\n >\n <Content className={part(\"gf-section-links-content\", [\"min-w-0\", side === \"left\" && \"md:order-2\"])} />\n {links.length > 0 && (\n <nav\n aria-label={parent?.label ?? top?.label ?? menu}\n className={side === \"left\" ? \"md:order-1\" : undefined}\n >\n <ul\n className={part(\n \"gf-section-links-list\",\n \"divide-y divide-primary-foreground/20 overflow-hidden rounded-md bg-primary text-primary-foreground shadow-md\",\n )}\n >\n {links.map((link) => {\n const current = link.href === at || (link.href !== \"/\" && path.startsWith(`${link.href}/`));\n return (\n <li key={`${link.label}-${link.href}`}>\n <SiteLink\n href={link.href}\n aria-current={link.href === path ? \"page\" : undefined}\n data-current={current ? \"\" : undefined}\n className={part(\n \"gf-section-links-link\",\n \"block px-4 py-3 hover:bg-secondary hover:text-secondary-foreground data-current:bg-secondary data-current:text-secondary-foreground\",\n )}\n >\n {link.label}\n </SiteLink>\n </li>\n );\n })}\n </ul>\n </nav>\n )}\n </div>\n );\n },\n },\n {\n \"gf-section-links\": \"Layout\",\n \"gf-section-links-content\": \"Content\",\n \"gf-section-links-list\": \"Links\",\n \"gf-section-links-link\": \"Link\",\n },\n);\n\nexport default withKeywords(SectionLinks, [\"sidebar\", \"section\", \"subpages\", \"navigation\", \"submenu\"]);\n"
12
+ }
13
+ ],
14
+ "meta": {
15
+ "goodfellow": {
16
+ "category": "Navigation",
17
+ "version": "0.7.0"
18
+ }
19
+ },
20
+ "dependencies": []
21
+ }
@@ -12,14 +12,14 @@
12
12
  {
13
13
  "path": "blocks/installed/shadcn-call-to-action/block.tsx",
14
14
  "type": "registry:block",
15
- "content": "import { classNameField, cx, SiteLink } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\n\ntype Style = \"highlight\" | \"subtle\";\n\nexport interface CallToActionProps {\n title: string;\n text: string;\n buttonLabel: string;\n buttonLink: string;\n style: Style;\n className: string;\n}\n\n/** A highlighted box asking visitors to do something, with a button. */\nconst CallToAction: ComponentConfig<CallToActionProps> = {\n label: \"Call to action\",\n fields: {\n title: { type: \"text\", label: \"Heading\", contentEditable: true },\n text: { type: \"textarea\", label: \"Text\", contentEditable: true },\n buttonLabel: { type: \"text\", label: \"Button\" },\n buttonLink: { type: \"text\", label: \"Button links to\" },\n style: {\n type: \"radio\",\n label: \"Style\",\n options: [\n { label: \"In the main color\", value: \"highlight\" },\n { label: \"Subtle\", value: \"subtle\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"Ready to get started?\",\n text: \"Tell visitors what to do next.\",\n buttonLabel: \"Get in touch\",\n buttonLink: \"/contact\",\n style: \"highlight\",\n className: \"\",\n },\n render: ({ title, text, buttonLabel, buttonLink, style, className }) => {\n const highlight = style === \"highlight\";\n return (\n <section className={cx(\"mx-auto w-full max-w-5xl px-4 py-12\", className)}>\n <Card className={cx(\"border-0\", highlight ? \"bg-primary text-primary-foreground\" : \"bg-muted\")}>\n <CardContent className=\"flex flex-col items-start gap-6 p-8 md:flex-row md:items-center md:justify-between md:p-12\">\n <div className=\"flex flex-col gap-2\">\n <h2 className=\"text-2xl font-bold tracking-tight md:text-3xl\">{title}</h2>\n {text && <p className={cx(\"text-lg\", highlight ? \"opacity-90\" : \"text-muted-foreground\")}>{text}</p>}\n </div>\n {buttonLabel && (\n <SiteLink\n href={buttonLink || \"/\"}\n className={buttonVariants({ size: \"lg\", variant: highlight ? \"secondary\" : \"default\" })}\n >\n {buttonLabel}\n </SiteLink>\n )}\n </CardContent>\n </Card>\n </section>\n );\n },\n};\n\nexport default CallToAction;\n"
15
+ "content": "import { classNameField, SiteLink, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\n\ntype Style = \"highlight\" | \"subtle\";\n\nexport interface CallToActionProps {\n title: string;\n text: string;\n buttonLabel: string;\n buttonLink: string;\n style: Style;\n className: string;\n}\n\n/** A highlighted box asking visitors to do something, with a button. */\nconst CallToAction: ComponentConfig<CallToActionProps> = withParts(\n {\n label: \"Call to action\",\n fields: {\n title: { type: \"text\", label: \"Heading\", contentEditable: true },\n text: { type: \"textarea\", label: \"Text\", contentEditable: true },\n buttonLabel: { type: \"text\", label: \"Button\" },\n buttonLink: { type: \"text\", label: \"Button links to\" },\n style: {\n type: \"radio\",\n label: \"Style\",\n options: [\n { label: \"In the main color\", value: \"highlight\" },\n { label: \"Subtle\", value: \"subtle\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"Ready to get started?\",\n text: \"Tell visitors what to do next.\",\n buttonLabel: \"Get in touch\",\n buttonLink: \"/contact\",\n style: \"highlight\",\n className: \"\",\n },\n render: ({ title, text, buttonLabel, buttonLink, style, className }) => {\n const part = useParts();\n const highlight = style === \"highlight\";\n return (\n <section className={part(\"gf-shadcn-call-to-action\", \"mx-auto w-full max-w-5xl px-4 py-12\", className)}>\n <Card\n className={part(\"gf-shadcn-call-to-action-box\", [\n \"border-0\",\n highlight ? \"bg-primary text-primary-foreground\" : \"bg-muted\",\n ])}\n >\n <CardContent className=\"flex flex-col items-start gap-6 p-8 md:flex-row md:items-center md:justify-between md:p-12\">\n <div className=\"flex flex-col gap-2\">\n <h2\n className={part(\"gf-shadcn-call-to-action-heading\", \"text-2xl font-bold tracking-tight md:text-3xl\")}\n >\n {title}\n </h2>\n {text && (\n <p\n className={part(\"gf-shadcn-call-to-action-text\", [\n \"text-lg\",\n highlight ? \"opacity-90\" : \"text-muted-foreground\",\n ])}\n >\n {text}\n </p>\n )}\n </div>\n {buttonLabel && (\n <SiteLink\n href={buttonLink || \"/\"}\n className={part(\n \"gf-shadcn-call-to-action-button\",\n buttonVariants({ size: \"lg\", variant: highlight ? \"secondary\" : \"default\" }),\n )}\n >\n {buttonLabel}\n </SiteLink>\n )}\n </CardContent>\n </Card>\n </section>\n );\n },\n },\n {\n \"gf-shadcn-call-to-action\": \"Call to action\",\n \"gf-shadcn-call-to-action-box\": \"Box\",\n \"gf-shadcn-call-to-action-heading\": \"Heading\",\n \"gf-shadcn-call-to-action-text\": \"Text\",\n \"gf-shadcn-call-to-action-button\": \"Button\",\n },\n);\n\nexport default CallToAction;\n"
16
16
  }
17
17
  ],
18
18
  "meta": {
19
19
  "goodfellow": {
20
20
  "category": "Page sections",
21
21
  "recommended": true,
22
- "version": "0.5.0"
22
+ "version": "0.7.0"
23
23
  }
24
24
  },
25
25
  "dependencies": []
@@ -12,14 +12,14 @@
12
12
  {
13
13
  "path": "blocks/installed/shadcn-cards/block.tsx",
14
14
  "type": "registry:block",
15
- "content": "import { classNameField, cx, mediaField, SiteImage, SiteLink } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from \"@/components/ui/card\";\n\ntype Columns = \"2\" | \"3\" | \"4\";\n\ninterface CardItem {\n title: string;\n text: string;\n image: string;\n imageAlt: string;\n linkLabel: string;\n link: string;\n}\n\nexport interface CardsProps {\n title: string;\n intro: string;\n columns: Columns;\n cards: CardItem[];\n className: string;\n}\n\nconst columnClasses: Record<Columns, string> = {\n \"2\": \"sm:grid-cols-2\",\n \"3\": \"sm:grid-cols-2 lg:grid-cols-3\",\n \"4\": \"sm:grid-cols-2 lg:grid-cols-4\",\n};\n\n/** A grid of cards, each with a picture, a heading, text and a link. */\nconst Cards: ComponentConfig<CardsProps> = {\n label: \"Cards\",\n fields: {\n title: { type: \"text\", label: \"Heading\" },\n intro: { type: \"textarea\", label: \"Introduction\" },\n columns: {\n type: \"radio\",\n label: \"Cards in a row\",\n options: [\n { label: \"2\", value: \"2\" },\n { label: \"3\", value: \"3\" },\n { label: \"4\", value: \"4\" },\n ],\n },\n cards: {\n type: \"array\",\n label: \"Cards\",\n arrayFields: {\n title: { type: \"text\", label: \"Heading\" },\n text: { type: \"textarea\", label: \"Text\" },\n image: mediaField(\"Picture\"),\n imageAlt: { type: \"text\", label: \"Description of the picture for screen readers\" },\n linkLabel: { type: \"text\", label: \"Link text\" },\n link: { type: \"text\", label: \"Links to\" },\n },\n defaultItemProps: {\n title: \"A card\",\n text: \"Say something about it.\",\n image: \"\",\n imageAlt: \"\",\n linkLabel: \"\",\n link: \"\",\n },\n getItemSummary: (card) => card.title || \"Card\",\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"\",\n intro: \"\",\n columns: \"3\",\n cards: [\n { title: \"First\", text: \"Say something about it.\", image: \"\", imageAlt: \"\", linkLabel: \"\", link: \"\" },\n { title: \"Second\", text: \"Say something about it.\", image: \"\", imageAlt: \"\", linkLabel: \"\", link: \"\" },\n { title: \"Third\", text: \"Say something about it.\", image: \"\", imageAlt: \"\", linkLabel: \"\", link: \"\" },\n ],\n className: \"\",\n },\n render: ({ title, intro, columns, cards, className }) => (\n <section className={cx(\"mx-auto w-full max-w-6xl px-4 py-12\", className)}>\n {(title || intro) && (\n <div className=\"mb-8 flex max-w-3xl flex-col gap-3\">\n {title && <h2 className=\"text-3xl font-bold tracking-tight\">{title}</h2>}\n {intro && <p className=\"text-lg text-muted-foreground\">{intro}</p>}\n </div>\n )}\n <div className={cx(\"grid gap-6\", columnClasses[columns])}>\n {cards.map((card, index) => (\n // Cards have no ids of their own, and their order is all that tells them apart.\n // biome-ignore lint/suspicious/noArrayIndexKey: see above\n <Card key={index} className=\"overflow-hidden pt-0\">\n {card.image ? (\n <SiteImage src={card.image} alt={card.imageAlt} className=\"aspect-video w-full object-cover\" />\n ) : (\n <div className=\"h-0\" />\n )}\n <CardHeader>\n <CardTitle className=\"text-xl\">{card.title}</CardTitle>\n {card.text && <CardDescription className=\"text-base\">{card.text}</CardDescription>}\n </CardHeader>\n <CardContent className=\"flex-1\" />\n {card.linkLabel && (\n <CardFooter>\n <SiteLink href={card.link || \"/\"} className={buttonVariants({ variant: \"outline\" })}>\n {card.linkLabel}\n </SiteLink>\n </CardFooter>\n )}\n </Card>\n ))}\n </div>\n </section>\n ),\n};\n\nexport default Cards;\n"
15
+ "content": "import { classNameField, mediaField, SiteImage, SiteLink, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from \"@/components/ui/card\";\n\ntype Columns = \"2\" | \"3\" | \"4\";\n\ninterface CardItem {\n title: string;\n text: string;\n image: string;\n imageAlt: string;\n linkLabel: string;\n link: string;\n}\n\nexport interface CardsProps {\n title: string;\n intro: string;\n columns: Columns;\n cards: CardItem[];\n className: string;\n}\n\nconst columnClasses: Record<Columns, string> = {\n \"2\": \"sm:grid-cols-2\",\n \"3\": \"sm:grid-cols-2 lg:grid-cols-3\",\n \"4\": \"sm:grid-cols-2 lg:grid-cols-4\",\n};\n\n/** A grid of cards, each with a picture, a heading, text and a link. */\nconst Cards: ComponentConfig<CardsProps> = withParts(\n {\n label: \"Cards\",\n fields: {\n title: { type: \"text\", label: \"Heading\" },\n intro: { type: \"textarea\", label: \"Introduction\" },\n columns: {\n type: \"radio\",\n label: \"Cards in a row\",\n options: [\n { label: \"2\", value: \"2\" },\n { label: \"3\", value: \"3\" },\n { label: \"4\", value: \"4\" },\n ],\n },\n cards: {\n type: \"array\",\n label: \"Cards\",\n arrayFields: {\n title: { type: \"text\", label: \"Heading\" },\n text: { type: \"textarea\", label: \"Text\" },\n image: mediaField(\"Picture\"),\n imageAlt: { type: \"text\", label: \"Description of the picture for screen readers\" },\n linkLabel: { type: \"text\", label: \"Link text\" },\n link: { type: \"text\", label: \"Links to\" },\n },\n defaultItemProps: {\n title: \"A card\",\n text: \"Say something about it.\",\n image: \"\",\n imageAlt: \"\",\n linkLabel: \"\",\n link: \"\",\n },\n getItemSummary: (card) => card.title || \"Card\",\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"\",\n intro: \"\",\n columns: \"3\",\n cards: [\n { title: \"First\", text: \"Say something about it.\", image: \"\", imageAlt: \"\", linkLabel: \"\", link: \"\" },\n { title: \"Second\", text: \"Say something about it.\", image: \"\", imageAlt: \"\", linkLabel: \"\", link: \"\" },\n { title: \"Third\", text: \"Say something about it.\", image: \"\", imageAlt: \"\", linkLabel: \"\", link: \"\" },\n ],\n className: \"\",\n },\n render: ({ title, intro, columns, cards, className }) => {\n const part = useParts();\n return (\n <section className={part(\"gf-shadcn-cards\", \"mx-auto w-full max-w-6xl px-4 py-12\", className)}>\n {(title || intro) && (\n <div className={part(\"gf-shadcn-cards-header\", \"mb-8 flex max-w-3xl flex-col gap-3\")}>\n {title && (\n <h2 className={part(\"gf-shadcn-cards-heading\", \"text-3xl font-bold tracking-tight\")}>{title}</h2>\n )}\n {intro && <p className={part(\"gf-shadcn-cards-intro\", \"text-lg text-muted-foreground\")}>{intro}</p>}\n </div>\n )}\n <div className={part(\"gf-shadcn-cards-grid\", \"grid gap-6\", columnClasses[columns])}>\n {cards.map((card, index) => (\n // Cards have no ids of their own, and their order is all that tells them apart.\n // biome-ignore lint/suspicious/noArrayIndexKey: see above\n <Card key={index} className={part(\"gf-shadcn-cards-card\", \"overflow-hidden pt-0\")}>\n {card.image ? (\n <SiteImage\n src={card.image}\n alt={card.imageAlt}\n className={part(\"gf-shadcn-cards-image\", \"aspect-video w-full object-cover\")}\n />\n ) : (\n <div className=\"h-0\" />\n )}\n <CardHeader>\n <CardTitle className={part(\"gf-shadcn-cards-title\", \"text-xl\")}>{card.title}</CardTitle>\n {card.text && (\n <CardDescription className={part(\"gf-shadcn-cards-text\", \"text-base\")}>{card.text}</CardDescription>\n )}\n </CardHeader>\n <CardContent className=\"flex-1\" />\n {card.linkLabel && (\n <CardFooter>\n <SiteLink\n href={card.link || \"/\"}\n className={part(\"gf-shadcn-cards-link\", buttonVariants({ variant: \"outline\" }))}\n >\n {card.linkLabel}\n </SiteLink>\n </CardFooter>\n )}\n </Card>\n ))}\n </div>\n </section>\n );\n },\n },\n {\n \"gf-shadcn-cards\": \"Cards\",\n \"gf-shadcn-cards-header\": \"Heading and introduction\",\n \"gf-shadcn-cards-heading\": \"Heading\",\n \"gf-shadcn-cards-intro\": \"Introduction\",\n \"gf-shadcn-cards-grid\": \"Grid of cards\",\n \"gf-shadcn-cards-card\": \"Card\",\n \"gf-shadcn-cards-image\": \"Card's picture\",\n \"gf-shadcn-cards-title\": \"Card's heading\",\n \"gf-shadcn-cards-text\": \"Card's text\",\n \"gf-shadcn-cards-link\": \"Card's link\",\n },\n);\n\nexport default Cards;\n"
16
16
  }
17
17
  ],
18
18
  "meta": {
19
19
  "goodfellow": {
20
20
  "category": "Page sections",
21
21
  "recommended": true,
22
- "version": "0.5.0"
22
+ "version": "0.7.0"
23
23
  }
24
24
  },
25
25
  "dependencies": []
@@ -11,18 +11,18 @@
11
11
  {
12
12
  "path": "blocks/installed/shadcn-carousel/block.tsx",
13
13
  "type": "registry:block",
14
- "content": "import { classNameField, cx, mediaField } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { ImageCarousel } from \"./image-carousel\";\n\ninterface Slide {\n image: string;\n alt: string;\n caption: string;\n}\n\nexport interface CarouselProps {\n slides: Slide[];\n loop: boolean;\n className: string;\n}\n\n/** Pictures shown one at a time, with buttons to move between them. */\nconst Carousel: ComponentConfig<CarouselProps> = {\n label: \"Image carousel\",\n fields: {\n slides: {\n type: \"array\",\n label: \"Pictures\",\n arrayFields: {\n image: mediaField(\"Picture\"),\n alt: { type: \"text\", label: \"Description for screen readers\" },\n caption: { type: \"text\", label: \"Caption\" },\n },\n defaultItemProps: { image: \"\", alt: \"\", caption: \"\" },\n getItemSummary: (slide, index) => slide.caption || slide.alt || `Picture ${(index ?? 0) + 1}`,\n },\n loop: {\n type: \"radio\",\n label: \"Start again after the last picture\",\n options: [\n { label: \"Yes\", value: true },\n { label: \"No\", value: false },\n ],\n },\n className: classNameField,\n },\n defaultProps: { slides: [], loop: true, className: \"\" },\n render: ({ slides, loop, className }) => (\n <section className={cx(\"mx-auto w-full max-w-4xl px-14 py-8\", className)}>\n {slides.length > 0 ? (\n <ImageCarousel slides={slides.filter((slide) => slide.image)} loop={loop} />\n ) : (\n <div className=\"aspect-video w-full rounded-xl bg-muted\" />\n )}\n </section>\n ),\n};\n\nexport default Carousel;\n"
14
+ "content": "import { classNameField, mediaField, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { ImageCarousel } from \"./image-carousel\";\n\ninterface Slide {\n image: string;\n alt: string;\n caption: string;\n}\n\nexport interface CarouselProps {\n slides: Slide[];\n loop: boolean;\n className: string;\n}\n\n/** Pictures shown one at a time, with buttons to move between them. */\nconst Carousel: ComponentConfig<CarouselProps> = withParts(\n {\n label: \"Image carousel\",\n fields: {\n slides: {\n type: \"array\",\n label: \"Pictures\",\n arrayFields: {\n image: mediaField(\"Picture\"),\n alt: { type: \"text\", label: \"Description for screen readers\" },\n caption: { type: \"text\", label: \"Caption\" },\n },\n defaultItemProps: { image: \"\", alt: \"\", caption: \"\" },\n getItemSummary: (slide, index) => slide.caption || slide.alt || `Picture ${(index ?? 0) + 1}`,\n },\n loop: {\n type: \"radio\",\n label: \"Start again after the last picture\",\n options: [\n { label: \"Yes\", value: true },\n { label: \"No\", value: false },\n ],\n },\n className: classNameField,\n },\n defaultProps: { slides: [], loop: true, className: \"\" },\n render: ({ slides, loop, className }) => {\n const part = useParts();\n const image = part(\"gf-shadcn-carousel-image\", \"aspect-video w-full rounded-xl object-cover\");\n return (\n <section className={part(\"gf-shadcn-carousel\", \"mx-auto w-full max-w-4xl px-14 py-8\", className)}>\n {slides.length > 0 ? (\n <ImageCarousel\n slides={slides.filter((slide) => slide.image)}\n loop={loop}\n classes={{\n image,\n caption: part(\"gf-shadcn-carousel-caption\", \"mt-2 text-center text-sm text-muted-foreground\"),\n }}\n />\n ) : (\n <div className={part(\"gf-shadcn-carousel-image\", \"aspect-video w-full rounded-xl bg-muted\")} />\n )}\n </section>\n );\n },\n },\n {\n \"gf-shadcn-carousel\": \"Carousel\",\n \"gf-shadcn-carousel-image\": \"Picture\",\n \"gf-shadcn-carousel-caption\": \"Caption\",\n },\n);\n\nexport default Carousel;\n"
15
15
  },
16
16
  {
17
17
  "path": "blocks/installed/shadcn-carousel/image-carousel.tsx",
18
18
  "type": "registry:component",
19
- "content": "\"use client\";\n\nimport { SiteImage } from \"@goodfellow-cms/react\";\nimport { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from \"@/components/ui/carousel\";\n\n/** The pictures, one at a time, with previous and next buttons. */\nexport function ImageCarousel({\n slides,\n loop,\n}: {\n slides: Array<{ image: string; alt: string; caption: string }>;\n loop: boolean;\n}) {\n return (\n <Carousel opts={{ loop }}>\n <CarouselContent>\n {slides.map((slide, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: pictures have no ids of their own\n <CarouselItem key={index}>\n <figure>\n <SiteImage src={slide.image} alt={slide.alt} className=\"aspect-video w-full rounded-xl object-cover\" />\n {slide.caption && (\n <figcaption className=\"mt-2 text-center text-sm text-muted-foreground\">{slide.caption}</figcaption>\n )}\n </figure>\n </CarouselItem>\n ))}\n </CarouselContent>\n <CarouselPrevious />\n <CarouselNext />\n </Carousel>\n );\n}\n"
19
+ "content": "\"use client\";\n\nimport { SiteImage } from \"@goodfellow-cms/react\";\nimport { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from \"@/components/ui/carousel\";\n\n/** The pictures, one at a time, with previous and next buttons, and their parts' classes from the block. */\nexport function ImageCarousel({\n slides,\n loop,\n classes,\n}: {\n slides: Array<{ image: string; alt: string; caption: string }>;\n loop: boolean;\n classes: { image: string; caption: string };\n}) {\n return (\n <Carousel opts={{ loop }}>\n <CarouselContent>\n {slides.map((slide, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: pictures have no ids of their own\n <CarouselItem key={index}>\n <figure>\n <SiteImage src={slide.image} alt={slide.alt} className={classes.image} />\n {slide.caption && <figcaption className={classes.caption}>{slide.caption}</figcaption>}\n </figure>\n </CarouselItem>\n ))}\n </CarouselContent>\n <CarouselPrevious />\n <CarouselNext />\n </Carousel>\n );\n}\n"
20
20
  }
21
21
  ],
22
22
  "meta": {
23
23
  "goodfellow": {
24
24
  "category": "Interactive",
25
- "version": "0.5.0"
25
+ "version": "0.7.0"
26
26
  }
27
27
  },
28
28
  "dependencies": []
package/r/shadcn-faq.json CHANGED
@@ -11,19 +11,19 @@
11
11
  {
12
12
  "path": "blocks/installed/shadcn-faq/block.tsx",
13
13
  "type": "registry:block",
14
- "content": "import { classNameField, cx } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig, RichText } from \"@puckeditor/core\";\nimport { FaqList } from \"./faq-list\";\n\ninterface Question {\n question: string;\n answer: RichText;\n}\n\nexport interface FaqProps {\n title: string;\n questions: Question[];\n className: string;\n}\n\n/** Questions that open to show their answers. Every answer is in the page, so search engines see them. */\nconst Faq: ComponentConfig<FaqProps> = {\n label: \"FAQ\",\n fields: {\n title: { type: \"text\", label: \"Heading\" },\n questions: {\n type: \"array\",\n label: \"Questions\",\n arrayFields: {\n question: { type: \"text\", label: \"Question\" },\n answer: { type: \"richtext\", label: \"Answer\" },\n },\n defaultItemProps: { question: \"A question\", answer: \"<p>Its answer.</p>\" },\n getItemSummary: (item) => item.question || \"Question\",\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"Questions and answers\",\n questions: [\n { question: \"A question people ask\", answer: \"<p>Its answer.</p>\" },\n { question: \"Another question\", answer: \"<p>Its answer.</p>\" },\n ],\n className: \"\",\n },\n render: ({ title, questions, className }) => (\n <section className={cx(\"mx-auto w-full max-w-3xl px-4 py-12\", className)}>\n {title && <h2 className=\"mb-6 text-3xl font-bold tracking-tight\">{title}</h2>}\n <FaqList questions={questions.map(({ question, answer }) => ({ question, answer }))} />\n </section>\n ),\n};\n\nexport default Faq;\n"
14
+ "content": "import { classNameField, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig, RichText } from \"@puckeditor/core\";\nimport { FaqList } from \"./faq-list\";\n\ninterface Question {\n question: string;\n answer: RichText;\n}\n\nexport interface FaqProps {\n title: string;\n questions: Question[];\n className: string;\n}\n\n/** Questions that open to show their answers. Every answer is in the page, so search engines see them. */\nconst Faq: ComponentConfig<FaqProps> = withParts(\n {\n label: \"FAQ\",\n fields: {\n title: { type: \"text\", label: \"Heading\" },\n questions: {\n type: \"array\",\n label: \"Questions\",\n arrayFields: {\n question: { type: \"text\", label: \"Question\" },\n answer: { type: \"richtext\", label: \"Answer\" },\n },\n defaultItemProps: { question: \"A question\", answer: \"<p>Its answer.</p>\" },\n getItemSummary: (item) => item.question || \"Question\",\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"Questions and answers\",\n questions: [\n { question: \"A question people ask\", answer: \"<p>Its answer.</p>\" },\n { question: \"Another question\", answer: \"<p>Its answer.</p>\" },\n ],\n className: \"\",\n },\n render: ({ title, questions, className }) => {\n const part = useParts();\n return (\n <section className={part(\"gf-shadcn-faq\", \"mx-auto w-full max-w-3xl px-4 py-12\", className)}>\n {title && (\n <h2 className={part(\"gf-shadcn-faq-heading\", \"mb-6 text-3xl font-bold tracking-tight\")}>{title}</h2>\n )}\n <FaqList\n questions={questions.map(({ question, answer }) => ({ question, answer }))}\n classes={{\n question: part(\n \"gf-shadcn-faq-question\",\n undefined,\n \"[&>[data-slot=accordion-content][data-state=closed]]:hidden\",\n ),\n trigger: part(\"gf-shadcn-faq-trigger\", \"text-base\"),\n answer: part(\"gf-shadcn-faq-answer\", \"gf-prose\"),\n }}\n />\n </section>\n );\n },\n },\n {\n \"gf-shadcn-faq\": \"Questions and answers\",\n \"gf-shadcn-faq-heading\": \"Heading\",\n \"gf-shadcn-faq-question\": \"Question and answer\",\n \"gf-shadcn-faq-trigger\": \"Question\",\n \"gf-shadcn-faq-answer\": \"Answer\",\n },\n);\n\nexport default Faq;\n"
15
15
  },
16
16
  {
17
17
  "path": "blocks/installed/shadcn-faq/faq-list.tsx",
18
18
  "type": "registry:component",
19
- "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from \"@/components/ui/accordion\";\n\n/** The questions, one open at a time. Closed answers stay in the page, hidden, rather than being left out. */\nexport function FaqList({ questions }: { questions: Array<{ question: string; answer: ReactNode }> }) {\n return (\n <Accordion type=\"single\" collapsible className=\"w-full\">\n {questions.map((item, index) => (\n <AccordionItem\n // biome-ignore lint/suspicious/noArrayIndexKey: questions have no ids of their own\n key={index}\n value={`question-${index}`}\n className=\"[&>[data-slot=accordion-content][data-state=closed]]:hidden\"\n >\n <AccordionTrigger className=\"text-base\">{item.question}</AccordionTrigger>\n <AccordionContent forceMount>\n <div className=\"gf-prose\">{item.answer}</div>\n </AccordionContent>\n </AccordionItem>\n ))}\n </Accordion>\n );\n}\n"
19
+ "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from \"@/components/ui/accordion\";\n\n/**\n * The questions, one open at a time, with their parts' classes from the block.\n * Closed answers stay in the page, hidden, rather than being left out.\n */\nexport function FaqList({\n questions,\n classes,\n}: {\n questions: Array<{ question: string; answer: ReactNode }>;\n classes: { question: string; trigger: string; answer: string };\n}) {\n return (\n <Accordion type=\"single\" collapsible className=\"w-full\">\n {questions.map((item, index) => (\n <AccordionItem\n // biome-ignore lint/suspicious/noArrayIndexKey: questions have no ids of their own\n key={index}\n value={`question-${index}`}\n className={classes.question}\n >\n <AccordionTrigger className={classes.trigger}>{item.question}</AccordionTrigger>\n <AccordionContent forceMount>\n <div className={classes.answer}>{item.answer}</div>\n </AccordionContent>\n </AccordionItem>\n ))}\n </Accordion>\n );\n}\n"
20
20
  }
21
21
  ],
22
22
  "meta": {
23
23
  "goodfellow": {
24
24
  "category": "Interactive",
25
25
  "recommended": true,
26
- "version": "0.5.0"
26
+ "version": "0.7.0"
27
27
  }
28
28
  },
29
29
  "dependencies": []
@@ -11,14 +11,14 @@
11
11
  {
12
12
  "path": "blocks/installed/shadcn-hero/block.tsx",
13
13
  "type": "registry:block",
14
- "content": "import { classNameField, cx, mediaField, SiteImage, SiteLink } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { buttonVariants } from \"@/components/ui/button\";\n\ntype Layout = \"centered\" | \"beside\";\n\nexport interface HeroProps {\n title: string;\n text: string;\n image: string;\n imageAlt: string;\n layout: Layout;\n primaryLabel: string;\n primaryLink: string;\n secondaryLabel: string;\n secondaryLink: string;\n className: string;\n}\n\n/** A large heading at the top of a page, with text, up to two buttons and a picture. */\nconst Hero: ComponentConfig<HeroProps> = {\n label: \"Hero\",\n fields: {\n title: { type: \"text\", label: \"Heading\", contentEditable: true },\n text: { type: \"textarea\", label: \"Text\", contentEditable: true },\n image: mediaField(\"Picture\"),\n imageAlt: { type: \"text\", label: \"Description of the picture for screen readers\" },\n layout: {\n type: \"radio\",\n label: \"Layout\",\n options: [\n { label: \"Centered, picture below\", value: \"centered\" },\n { label: \"Picture beside the text\", value: \"beside\" },\n ],\n },\n primaryLabel: { type: \"text\", label: \"Main button\" },\n primaryLink: { type: \"text\", label: \"Main button links to\" },\n secondaryLabel: { type: \"text\", label: \"Second button (optional)\" },\n secondaryLink: { type: \"text\", label: \"Second button links to\" },\n className: classNameField,\n },\n defaultProps: {\n title: \"Welcome\",\n text: \"Say in a sentence or two what this site is about.\",\n image: \"\",\n imageAlt: \"\",\n layout: \"centered\",\n primaryLabel: \"Find out more\",\n primaryLink: \"/about\",\n secondaryLabel: \"\",\n secondaryLink: \"\",\n className: \"\",\n },\n render: ({\n title,\n text,\n image,\n imageAlt,\n layout,\n primaryLabel,\n primaryLink,\n secondaryLabel,\n secondaryLink,\n className,\n }) => {\n const beside = layout === \"beside\";\n return (\n <section className={cx(\"mx-auto w-full max-w-6xl px-4 py-16 md:py-24\", className)}>\n <div\n className={cx(\"flex flex-col gap-10\", beside ? \"md:flex-row md:items-center\" : \"items-center text-center\")}\n >\n <div className={cx(\"flex flex-col gap-6\", beside ? \"md:flex-1\" : \"max-w-3xl items-center\")}>\n <h1 className=\"text-4xl font-bold tracking-tight text-balance md:text-6xl\">{title}</h1>\n {text && <p className=\"text-lg text-muted-foreground md:text-xl\">{text}</p>}\n {(primaryLabel || secondaryLabel) && (\n <div className=\"flex flex-wrap gap-3\">\n {primaryLabel && (\n <SiteLink href={primaryLink || \"/\"} className={buttonVariants({ size: \"lg\" })}>\n {primaryLabel}\n </SiteLink>\n )}\n {secondaryLabel && (\n <SiteLink href={secondaryLink || \"/\"} className={buttonVariants({ size: \"lg\", variant: \"outline\" })}>\n {secondaryLabel}\n </SiteLink>\n )}\n </div>\n )}\n </div>\n {image && (\n <SiteImage\n src={image}\n alt={imageAlt}\n className={cx(\"w-full rounded-xl object-cover\", beside ? \"md:w-1/2\" : \"max-w-4xl\")}\n />\n )}\n </div>\n </section>\n );\n },\n};\n\nexport default Hero;\n"
14
+ "content": "import { classNameField, mediaField, SiteImage, SiteLink, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { buttonVariants } from \"@/components/ui/button\";\n\ntype Layout = \"centered\" | \"beside\";\n\nexport interface HeroProps {\n title: string;\n text: string;\n image: string;\n imageAlt: string;\n layout: Layout;\n primaryLabel: string;\n primaryLink: string;\n secondaryLabel: string;\n secondaryLink: string;\n className: string;\n}\n\n/** A large heading at the top of a page, with text, up to two buttons and a picture. */\nconst Hero: ComponentConfig<HeroProps> = withParts(\n {\n label: \"Hero\",\n fields: {\n title: { type: \"text\", label: \"Heading\", contentEditable: true },\n text: { type: \"textarea\", label: \"Text\", contentEditable: true },\n image: mediaField(\"Picture\"),\n imageAlt: { type: \"text\", label: \"Description of the picture for screen readers\" },\n layout: {\n type: \"radio\",\n label: \"Layout\",\n options: [\n { label: \"Centered, picture below\", value: \"centered\" },\n { label: \"Picture beside the text\", value: \"beside\" },\n ],\n },\n primaryLabel: { type: \"text\", label: \"Main button\" },\n primaryLink: { type: \"text\", label: \"Main button links to\" },\n secondaryLabel: { type: \"text\", label: \"Second button (optional)\" },\n secondaryLink: { type: \"text\", label: \"Second button links to\" },\n className: classNameField,\n },\n defaultProps: {\n title: \"Welcome\",\n text: \"Say in a sentence or two what this site is about.\",\n image: \"\",\n imageAlt: \"\",\n layout: \"centered\",\n primaryLabel: \"Find out more\",\n primaryLink: \"/about\",\n secondaryLabel: \"\",\n secondaryLink: \"\",\n className: \"\",\n },\n render: ({\n title,\n text,\n image,\n imageAlt,\n layout,\n primaryLabel,\n primaryLink,\n secondaryLabel,\n secondaryLink,\n className,\n }) => {\n const part = useParts();\n const beside = layout === \"beside\";\n return (\n <section className={part(\"gf-shadcn-hero\", \"mx-auto w-full max-w-6xl px-4 py-16 md:py-24\", className)}>\n <div\n className={part(\n \"gf-shadcn-hero-layout\",\n \"flex flex-col gap-10\",\n beside ? \"md:flex-row md:items-center\" : \"items-center text-center\",\n )}\n >\n <div\n className={part(\n \"gf-shadcn-hero-text\",\n \"flex flex-col gap-6\",\n beside ? \"md:flex-1\" : \"max-w-3xl items-center\",\n )}\n >\n <h1\n className={part(\"gf-shadcn-hero-heading\", \"text-4xl font-bold tracking-tight text-balance md:text-6xl\")}\n >\n {title}\n </h1>\n {text && (\n <p className={part(\"gf-shadcn-hero-intro\", \"text-lg text-muted-foreground md:text-xl\")}>{text}</p>\n )}\n {(primaryLabel || secondaryLabel) && (\n <div className={part(\"gf-shadcn-hero-buttons\", \"flex flex-wrap gap-3\")}>\n {primaryLabel && (\n <SiteLink\n href={primaryLink || \"/\"}\n className={part(\"gf-shadcn-hero-button\", buttonVariants({ size: \"lg\" }))}\n >\n {primaryLabel}\n </SiteLink>\n )}\n {secondaryLabel && (\n <SiteLink\n href={secondaryLink || \"/\"}\n className={part(\"gf-shadcn-hero-button\", buttonVariants({ size: \"lg\", variant: \"outline\" }))}\n >\n {secondaryLabel}\n </SiteLink>\n )}\n </div>\n )}\n </div>\n {image && (\n <SiteImage\n src={image}\n alt={imageAlt}\n className={part(\n \"gf-shadcn-hero-image\",\n \"w-full rounded-xl object-cover\",\n beside ? \"md:w-1/2\" : \"max-w-4xl\",\n )}\n />\n )}\n </div>\n </section>\n );\n },\n },\n {\n \"gf-shadcn-hero\": \"Hero\",\n \"gf-shadcn-hero-layout\": \"Text and picture\",\n \"gf-shadcn-hero-text\": \"Text\",\n \"gf-shadcn-hero-heading\": \"Heading\",\n \"gf-shadcn-hero-intro\": \"Introduction\",\n \"gf-shadcn-hero-buttons\": \"Buttons\",\n \"gf-shadcn-hero-button\": \"Button\",\n \"gf-shadcn-hero-image\": \"Picture\",\n },\n);\n\nexport default Hero;\n"
15
15
  }
16
16
  ],
17
17
  "meta": {
18
18
  "goodfellow": {
19
19
  "category": "Page sections",
20
20
  "recommended": true,
21
- "version": "0.5.0"
21
+ "version": "0.7.0"
22
22
  }
23
23
  },
24
24
  "dependencies": []
@@ -14,14 +14,14 @@
14
14
  {
15
15
  "path": "blocks/installed/shadcn-notice/block.tsx",
16
16
  "type": "registry:block",
17
- "content": "import { classNameField, cx } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig, RichText } from \"@puckeditor/core\";\nimport { CircleCheckIcon, InfoIcon, TriangleAlertIcon } from \"lucide-react\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\n\ntype Kind = \"info\" | \"warning\" | \"success\" | \"urgent\";\n\nexport interface NoticeProps {\n kind: Kind;\n title: string;\n text: RichText;\n className: string;\n}\n\nconst icons = { info: InfoIcon, warning: TriangleAlertIcon, success: CircleCheckIcon, urgent: TriangleAlertIcon };\n\n/** A short message that stands out, such as a closure or a change of times. */\nconst Notice: ComponentConfig<NoticeProps> = {\n label: \"Notice\",\n fields: {\n kind: {\n type: \"select\",\n label: \"Kind\",\n options: [\n { label: \"Information\", value: \"info\" },\n { label: \"Warning\", value: \"warning\" },\n { label: \"Good news\", value: \"success\" },\n { label: \"Urgent\", value: \"urgent\" },\n ],\n },\n title: { type: \"text\", label: \"Heading\", contentEditable: true },\n text: { type: \"richtext\", label: \"Text\", contentEditable: true },\n className: classNameField,\n },\n defaultProps: { kind: \"info\", title: \"Please note\", text: \"<p>Say what's changed.</p>\", className: \"\" },\n render: ({ kind, title, text, className }) => {\n const Icon = icons[kind];\n return (\n <div className={cx(\"mx-auto w-full max-w-3xl px-4 py-4\", className)}>\n <Alert variant={kind === \"urgent\" ? \"destructive\" : \"default\"}>\n <Icon aria-hidden=\"true\" />\n {title && <AlertTitle>{title}</AlertTitle>}\n <AlertDescription>\n <div className=\"gf-prose\">{text}</div>\n </AlertDescription>\n </Alert>\n </div>\n );\n },\n};\n\nexport default Notice;\n"
17
+ "content": "import { classNameField, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig, RichText } from \"@puckeditor/core\";\nimport { CircleCheckIcon, InfoIcon, TriangleAlertIcon } from \"lucide-react\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\n\ntype Kind = \"info\" | \"warning\" | \"success\" | \"urgent\";\n\nexport interface NoticeProps {\n kind: Kind;\n title: string;\n text: RichText;\n className: string;\n}\n\nconst icons = { info: InfoIcon, warning: TriangleAlertIcon, success: CircleCheckIcon, urgent: TriangleAlertIcon };\n\n/** A short message that stands out, such as a closure or a change of times. */\nconst Notice: ComponentConfig<NoticeProps> = withParts(\n {\n label: \"Notice\",\n fields: {\n kind: {\n type: \"select\",\n label: \"Kind\",\n options: [\n { label: \"Information\", value: \"info\" },\n { label: \"Warning\", value: \"warning\" },\n { label: \"Good news\", value: \"success\" },\n { label: \"Urgent\", value: \"urgent\" },\n ],\n },\n title: { type: \"text\", label: \"Heading\", contentEditable: true },\n text: { type: \"richtext\", label: \"Text\", contentEditable: true },\n className: classNameField,\n },\n defaultProps: { kind: \"info\", title: \"Please note\", text: \"<p>Say what's changed.</p>\", className: \"\" },\n render: ({ kind, title, text, className }) => {\n const part = useParts();\n const Icon = icons[kind];\n return (\n <div className={part(\"gf-shadcn-notice\", \"mx-auto w-full max-w-3xl px-4 py-4\", className)}>\n <Alert variant={kind === \"urgent\" ? \"destructive\" : \"default\"} className={part(\"gf-shadcn-notice-box\")}>\n <Icon aria-hidden=\"true\" />\n {title && <AlertTitle className={part(\"gf-shadcn-notice-title\")}>{title}</AlertTitle>}\n <AlertDescription>\n <div className={part(\"gf-shadcn-notice-text\", \"gf-prose\")}>{text}</div>\n </AlertDescription>\n </Alert>\n </div>\n );\n },\n },\n {\n \"gf-shadcn-notice\": \"Notice\",\n \"gf-shadcn-notice-box\": \"Notice's box\",\n \"gf-shadcn-notice-title\": \"Notice's title\",\n \"gf-shadcn-notice-text\": \"Notice's text\",\n },\n);\n\nexport default Notice;\n"
18
18
  }
19
19
  ],
20
20
  "meta": {
21
21
  "goodfellow": {
22
22
  "category": "Page sections",
23
23
  "recommended": true,
24
- "version": "0.5.0"
24
+ "version": "0.7.0"
25
25
  }
26
26
  }
27
27
  }
@@ -16,13 +16,13 @@
16
16
  {
17
17
  "path": "blocks/installed/shadcn-pricing/block.tsx",
18
18
  "type": "registry:block",
19
- "content": "import { classNameField, cx, SiteLink } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { CheckIcon } from \"lucide-react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from \"@/components/ui/card\";\n\ninterface Plan {\n name: string;\n price: string;\n period: string;\n description: string;\n features: string;\n buttonLabel: string;\n buttonLink: string;\n badge: string;\n}\n\nexport interface PricingProps {\n title: string;\n intro: string;\n plans: Plan[];\n className: string;\n}\n\nconst emptyPlan: Plan = {\n name: \"Plan\",\n price: \"$10\",\n period: \"a month\",\n description: \"\",\n features: \"Something included\\nSomething else included\",\n buttonLabel: \"Choose\",\n buttonLink: \"/contact\",\n badge: \"\",\n};\n\n/** Plans side by side, with prices, what each includes and a button. */\nconst Pricing: ComponentConfig<PricingProps> = {\n label: \"Pricing table\",\n fields: {\n title: { type: \"text\", label: \"Heading\" },\n intro: { type: \"textarea\", label: \"Introduction\" },\n plans: {\n type: \"array\",\n label: \"Plans\",\n arrayFields: {\n name: { type: \"text\", label: \"Name\" },\n price: { type: \"text\", label: \"Price, such as $10\" },\n period: { type: \"text\", label: \"Per, such as a month\" },\n description: { type: \"textarea\", label: \"Description\" },\n features: { type: \"textarea\", label: \"What's included, one per line\" },\n buttonLabel: { type: \"text\", label: \"Button\" },\n buttonLink: { type: \"text\", label: \"Button links to\" },\n badge: { type: \"text\", label: \"Label at the top, such as Most popular (optional)\" },\n },\n defaultItemProps: emptyPlan,\n getItemSummary: (plan) => plan.name || \"Plan\",\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"Prices\",\n intro: \"\",\n plans: [\n { ...emptyPlan, name: \"Basic\" },\n { ...emptyPlan, name: \"Standard\", price: \"$25\", badge: \"Most popular\" },\n { ...emptyPlan, name: \"Premium\", price: \"$50\" },\n ],\n className: \"\",\n },\n render: ({ title, intro, plans, className }) => (\n <section className={cx(\"mx-auto w-full max-w-6xl px-4 py-12\", className)}>\n {(title || intro) && (\n <div className=\"mb-8 flex flex-col items-center gap-3 text-center\">\n {title && <h2 className=\"text-3xl font-bold tracking-tight\">{title}</h2>}\n {intro && <p className=\"max-w-2xl text-lg text-muted-foreground\">{intro}</p>}\n </div>\n )}\n <div className=\"grid gap-6 md:grid-cols-2 lg:grid-cols-3\">\n {plans.map((plan, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: plans have no ids of their own\n <Card key={index} className={cx(plan.badge && \"border-primary border-2\")}>\n <CardHeader>\n <div className=\"flex items-center justify-between gap-2\">\n <CardTitle className=\"text-xl\">{plan.name}</CardTitle>\n {plan.badge && <Badge>{plan.badge}</Badge>}\n </div>\n {plan.description && <CardDescription>{plan.description}</CardDescription>}\n </CardHeader>\n <CardContent className=\"flex flex-1 flex-col gap-6\">\n <p>\n <span className=\"text-4xl font-bold\">{plan.price}</span>\n {plan.period && <span className=\"text-muted-foreground\"> {plan.period}</span>}\n </p>\n <ul className=\"flex flex-col gap-2\">\n {plan.features\n .split(\"\\n\")\n .map((feature) => feature.trim())\n .filter(Boolean)\n .map((feature) => (\n <li key={feature} className=\"flex items-start gap-2\">\n <CheckIcon aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0 text-primary\" />\n {feature}\n </li>\n ))}\n </ul>\n </CardContent>\n {plan.buttonLabel && (\n <CardFooter>\n <SiteLink\n href={plan.buttonLink || \"/\"}\n className={cx(buttonVariants({ variant: plan.badge ? \"default\" : \"outline\" }), \"w-full\")}\n >\n {plan.buttonLabel}\n </SiteLink>\n </CardFooter>\n )}\n </Card>\n ))}\n </div>\n </section>\n ),\n};\n\nexport default Pricing;\n"
19
+ "content": "import { classNameField, SiteLink, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { CheckIcon } from \"lucide-react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from \"@/components/ui/card\";\n\ninterface Plan {\n name: string;\n price: string;\n period: string;\n description: string;\n features: string;\n buttonLabel: string;\n buttonLink: string;\n badge: string;\n}\n\nexport interface PricingProps {\n title: string;\n intro: string;\n plans: Plan[];\n className: string;\n}\n\nconst emptyPlan: Plan = {\n name: \"Plan\",\n price: \"$10\",\n period: \"a month\",\n description: \"\",\n features: \"Something included\\nSomething else included\",\n buttonLabel: \"Choose\",\n buttonLink: \"/contact\",\n badge: \"\",\n};\n\n/** Plans side by side, with prices, what each includes and a button. */\nconst Pricing: ComponentConfig<PricingProps> = withParts(\n {\n label: \"Pricing table\",\n fields: {\n title: { type: \"text\", label: \"Heading\" },\n intro: { type: \"textarea\", label: \"Introduction\" },\n plans: {\n type: \"array\",\n label: \"Plans\",\n arrayFields: {\n name: { type: \"text\", label: \"Name\" },\n price: { type: \"text\", label: \"Price, such as $10\" },\n period: { type: \"text\", label: \"Per, such as a month\" },\n description: { type: \"textarea\", label: \"Description\" },\n features: { type: \"textarea\", label: \"What's included, one per line\" },\n buttonLabel: { type: \"text\", label: \"Button\" },\n buttonLink: { type: \"text\", label: \"Button links to\" },\n badge: { type: \"text\", label: \"Label at the top, such as Most popular (optional)\" },\n },\n defaultItemProps: emptyPlan,\n getItemSummary: (plan) => plan.name || \"Plan\",\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"Prices\",\n intro: \"\",\n plans: [\n { ...emptyPlan, name: \"Basic\" },\n { ...emptyPlan, name: \"Standard\", price: \"$25\", badge: \"Most popular\" },\n { ...emptyPlan, name: \"Premium\", price: \"$50\" },\n ],\n className: \"\",\n },\n render: ({ title, intro, plans, className }) => {\n const part = useParts();\n return (\n <section className={part(\"gf-shadcn-pricing\", \"mx-auto w-full max-w-6xl px-4 py-12\", className)}>\n {(title || intro) && (\n <div className={part(\"gf-shadcn-pricing-header\", \"mb-8 flex flex-col items-center gap-3 text-center\")}>\n {title && (\n <h2 className={part(\"gf-shadcn-pricing-heading\", \"text-3xl font-bold tracking-tight\")}>{title}</h2>\n )}\n {intro && (\n <p className={part(\"gf-shadcn-pricing-intro\", \"max-w-2xl text-lg text-muted-foreground\")}>{intro}</p>\n )}\n </div>\n )}\n <div className={part(\"gf-shadcn-pricing-grid\", \"grid gap-6 md:grid-cols-2 lg:grid-cols-3\")}>\n {plans.map((plan, index) => (\n <Card\n // biome-ignore lint/suspicious/noArrayIndexKey: plans have no ids of their own\n key={index}\n className={part(\"gf-shadcn-pricing-plan\", undefined, plan.badge && \"border-primary border-2\")}\n >\n <CardHeader>\n <div className=\"flex items-center justify-between gap-2\">\n <CardTitle className={part(\"gf-shadcn-pricing-name\", \"text-xl\")}>{plan.name}</CardTitle>\n {plan.badge && <Badge className={part(\"gf-shadcn-pricing-badge\")}>{plan.badge}</Badge>}\n </div>\n {plan.description && (\n <CardDescription className={part(\"gf-shadcn-pricing-description\")}>\n {plan.description}\n </CardDescription>\n )}\n </CardHeader>\n <CardContent className=\"flex flex-1 flex-col gap-6\">\n <p>\n <span className={part(\"gf-shadcn-pricing-price\", \"text-4xl font-bold\")}>{plan.price}</span>\n {plan.period && (\n <span className={part(\"gf-shadcn-pricing-period\", \"text-muted-foreground\")}> {plan.period}</span>\n )}\n </p>\n <ul className={part(\"gf-shadcn-pricing-features\", \"flex flex-col gap-2\")}>\n {plan.features\n .split(\"\\n\")\n .map((feature) => feature.trim())\n .filter(Boolean)\n .map((feature) => (\n <li key={feature} className={part(\"gf-shadcn-pricing-feature\", \"flex items-start gap-2\")}>\n <CheckIcon aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0 text-primary\" />\n {feature}\n </li>\n ))}\n </ul>\n </CardContent>\n {plan.buttonLabel && (\n <CardFooter>\n <SiteLink\n href={plan.buttonLink || \"/\"}\n className={part(\"gf-shadcn-pricing-button\", [\n buttonVariants({ variant: plan.badge ? \"default\" : \"outline\" }),\n \"w-full\",\n ])}\n >\n {plan.buttonLabel}\n </SiteLink>\n </CardFooter>\n )}\n </Card>\n ))}\n </div>\n </section>\n );\n },\n },\n {\n \"gf-shadcn-pricing\": \"Pricing\",\n \"gf-shadcn-pricing-header\": \"Heading and introduction\",\n \"gf-shadcn-pricing-heading\": \"Heading\",\n \"gf-shadcn-pricing-intro\": \"Introduction\",\n \"gf-shadcn-pricing-grid\": \"Grid of plans\",\n \"gf-shadcn-pricing-plan\": \"Plan\",\n \"gf-shadcn-pricing-name\": \"Plan's name\",\n \"gf-shadcn-pricing-badge\": \"Badge\",\n \"gf-shadcn-pricing-description\": \"Plan's description\",\n \"gf-shadcn-pricing-price\": \"Price\",\n \"gf-shadcn-pricing-period\": \"Period\",\n \"gf-shadcn-pricing-features\": \"Features\",\n \"gf-shadcn-pricing-feature\": \"Feature\",\n \"gf-shadcn-pricing-button\": \"Button\",\n },\n);\n\nexport default Pricing;\n"
20
20
  }
21
21
  ],
22
22
  "meta": {
23
23
  "goodfellow": {
24
24
  "category": "Page sections",
25
- "version": "0.5.0"
25
+ "version": "0.7.0"
26
26
  }
27
27
  }
28
28
  }
@@ -11,19 +11,19 @@
11
11
  {
12
12
  "path": "blocks/installed/shadcn-tabs/block.tsx",
13
13
  "type": "registry:block",
14
- "content": "import { classNameField, cx } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig, RichText } from \"@puckeditor/core\";\nimport { TabsView } from \"./tabs-view\";\n\ninterface Tab {\n label: string;\n content: RichText;\n}\n\nexport interface TabsProps {\n tabs: Tab[];\n className: string;\n}\n\n/** Content split into tabs, one shown at a time. Every tab's content is in the page, so search engines see it. */\nconst Tabs: ComponentConfig<TabsProps> = {\n label: \"Tabs\",\n fields: {\n tabs: {\n type: \"array\",\n label: \"Tabs\",\n arrayFields: {\n label: { type: \"text\", label: \"Tab name\" },\n content: { type: \"richtext\", label: \"Content\" },\n },\n defaultItemProps: { label: \"Tab\", content: \"<p>What this tab shows.</p>\" },\n getItemSummary: (tab) => tab.label || \"Tab\",\n },\n className: classNameField,\n },\n defaultProps: {\n tabs: [\n { label: \"First\", content: \"<p>What the first tab shows.</p>\" },\n { label: \"Second\", content: \"<p>What the second tab shows.</p>\" },\n ],\n className: \"\",\n },\n render: ({ tabs, className }) => (\n <section className={cx(\"mx-auto w-full max-w-4xl px-4 py-8\", className)}>\n <TabsView tabs={tabs.map(({ label, content }) => ({ label, content }))} />\n </section>\n ),\n};\n\nexport default Tabs;\n"
14
+ "content": "import { classNameField, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig, RichText } from \"@puckeditor/core\";\nimport { TabsView } from \"./tabs-view\";\n\ninterface Tab {\n label: string;\n content: RichText;\n}\n\nexport interface TabsProps {\n tabs: Tab[];\n className: string;\n}\n\n/** Content split into tabs, one shown at a time. Every tab's content is in the page, so search engines see it. */\nconst Tabs: ComponentConfig<TabsProps> = withParts(\n {\n label: \"Tabs\",\n fields: {\n tabs: {\n type: \"array\",\n label: \"Tabs\",\n arrayFields: {\n label: { type: \"text\", label: \"Tab name\" },\n content: { type: \"richtext\", label: \"Content\" },\n },\n defaultItemProps: { label: \"Tab\", content: \"<p>What this tab shows.</p>\" },\n getItemSummary: (tab) => tab.label || \"Tab\",\n },\n className: classNameField,\n },\n defaultProps: {\n tabs: [\n { label: \"First\", content: \"<p>What the first tab shows.</p>\" },\n { label: \"Second\", content: \"<p>What the second tab shows.</p>\" },\n ],\n className: \"\",\n },\n render: ({ tabs, className }) => {\n const part = useParts();\n return (\n <section className={part(\"gf-shadcn-tabs\", \"mx-auto w-full max-w-4xl px-4 py-8\", className)}>\n <TabsView\n tabs={tabs.map(({ label, content }) => ({ label, content }))}\n classes={{\n list: part(\"gf-shadcn-tabs-list\", \"flex-wrap\"),\n tab: part(\"gf-shadcn-tabs-tab\"),\n content: part(\"gf-shadcn-tabs-content\", \"pt-4\", \"data-[state=inactive]:hidden\"),\n }}\n />\n </section>\n );\n },\n },\n {\n \"gf-shadcn-tabs\": \"Tabs\",\n \"gf-shadcn-tabs-list\": \"Tabs' names\",\n \"gf-shadcn-tabs-tab\": \"Tab's name\",\n \"gf-shadcn-tabs-content\": \"Tab's content\",\n },\n);\n\nexport default Tabs;\n"
15
15
  },
16
16
  {
17
17
  "path": "blocks/installed/shadcn-tabs/tabs-view.tsx",
18
18
  "type": "registry:component",
19
- "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\n\n/** The tabs, with every tab's content in the page and the others hidden. */\nexport function TabsView({ tabs }: { tabs: Array<{ label: string; content: ReactNode }> }) {\n if (tabs.length === 0) return null;\n return (\n <Tabs defaultValue=\"tab-0\">\n <TabsList className=\"flex-wrap\">\n {tabs.map((tab, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: tabs have no ids of their own\n <TabsTrigger key={index} value={`tab-${index}`}>\n {tab.label}\n </TabsTrigger>\n ))}\n </TabsList>\n {tabs.map((tab, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: tabs have no ids of their own\n <TabsContent key={index} value={`tab-${index}`} forceMount className=\"pt-4 data-[state=inactive]:hidden\">\n <div className=\"gf-prose\">{tab.content}</div>\n </TabsContent>\n ))}\n </Tabs>\n );\n}\n"
19
+ "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\n\n/** The tabs, with every tab's content in the page and the others hidden, and their parts' classes from the block. */\nexport function TabsView({\n tabs,\n classes,\n}: {\n tabs: Array<{ label: string; content: ReactNode }>;\n classes: { list: string; tab: string; content: string };\n}) {\n if (tabs.length === 0) return null;\n return (\n <Tabs defaultValue=\"tab-0\">\n <TabsList className={classes.list}>\n {tabs.map((tab, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: tabs have no ids of their own\n <TabsTrigger key={index} value={`tab-${index}`} className={classes.tab}>\n {tab.label}\n </TabsTrigger>\n ))}\n </TabsList>\n {tabs.map((tab, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: tabs have no ids of their own\n <TabsContent key={index} value={`tab-${index}`} forceMount className={classes.content}>\n <div className=\"gf-prose\">{tab.content}</div>\n </TabsContent>\n ))}\n </Tabs>\n );\n}\n"
20
20
  }
21
21
  ],
22
22
  "meta": {
23
23
  "goodfellow": {
24
24
  "category": "Interactive",
25
25
  "recommended": true,
26
- "version": "0.5.0"
26
+ "version": "0.7.0"
27
27
  }
28
28
  },
29
29
  "dependencies": []
@@ -11,14 +11,14 @@
11
11
  {
12
12
  "path": "blocks/installed/shadcn-testimonials/block.tsx",
13
13
  "type": "registry:block",
14
- "content": "import { classNameField, cx, mediaField, SiteImage } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { Card, CardContent, CardFooter } from \"@/components/ui/card\";\n\ninterface Testimonial {\n quote: string;\n name: string;\n role: string;\n photo: string;\n}\n\nexport interface TestimonialsProps {\n title: string;\n testimonials: Testimonial[];\n className: string;\n}\n\n/** Quotes from people, with their names and photos. */\nconst Testimonials: ComponentConfig<TestimonialsProps> = {\n label: \"Testimonials\",\n fields: {\n title: { type: \"text\", label: \"Heading\" },\n testimonials: {\n type: \"array\",\n label: \"Quotes\",\n arrayFields: {\n quote: { type: \"textarea\", label: \"What they said\" },\n name: { type: \"text\", label: \"Name\" },\n role: { type: \"text\", label: \"Who they are, such as a member since 2020\" },\n photo: mediaField(\"Photo\"),\n },\n defaultItemProps: { quote: \"Something kind they said.\", name: \"A name\", role: \"\", photo: \"\" },\n getItemSummary: (item) => item.name || \"Quote\",\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"What people say\",\n testimonials: [\n { quote: \"Something kind they said.\", name: \"A name\", role: \"\", photo: \"\" },\n { quote: \"Something else kind they said.\", name: \"Another name\", role: \"\", photo: \"\" },\n ],\n className: \"\",\n },\n render: ({ title, testimonials, className }) => (\n <section className={cx(\"mx-auto w-full max-w-6xl px-4 py-12\", className)}>\n {title && <h2 className=\"mb-8 text-3xl font-bold tracking-tight\">{title}</h2>}\n <div className=\"grid gap-6 md:grid-cols-2 lg:grid-cols-3\">\n {testimonials.map((item, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: quotes have no ids of their own\n <Card key={index}>\n <CardContent className=\"flex-1\">\n <blockquote className=\"text-lg leading-relaxed\">“{item.quote}”</blockquote>\n </CardContent>\n <CardFooter className=\"gap-3\">\n {item.photo && (\n <SiteImage src={item.photo} alt=\"\" className=\"size-10 shrink-0 rounded-full object-cover\" />\n )}\n <div className=\"flex flex-col\">\n <span className=\"font-semibold\">{item.name}</span>\n {item.role && <span className=\"text-sm text-muted-foreground\">{item.role}</span>}\n </div>\n </CardFooter>\n </Card>\n ))}\n </div>\n </section>\n ),\n};\n\nexport default Testimonials;\n"
14
+ "content": "import { classNameField, mediaField, SiteImage, useParts, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { Card, CardContent, CardFooter } from \"@/components/ui/card\";\n\ninterface Testimonial {\n quote: string;\n name: string;\n role: string;\n photo: string;\n}\n\nexport interface TestimonialsProps {\n title: string;\n testimonials: Testimonial[];\n className: string;\n}\n\n/** Quotes from people, with their names and photos. */\nconst Testimonials: ComponentConfig<TestimonialsProps> = withParts(\n {\n label: \"Testimonials\",\n fields: {\n title: { type: \"text\", label: \"Heading\" },\n testimonials: {\n type: \"array\",\n label: \"Quotes\",\n arrayFields: {\n quote: { type: \"textarea\", label: \"What they said\" },\n name: { type: \"text\", label: \"Name\" },\n role: { type: \"text\", label: \"Who they are, such as a member since 2020\" },\n photo: mediaField(\"Photo\"),\n },\n defaultItemProps: { quote: \"Something kind they said.\", name: \"A name\", role: \"\", photo: \"\" },\n getItemSummary: (item) => item.name || \"Quote\",\n },\n className: classNameField,\n },\n defaultProps: {\n title: \"What people say\",\n testimonials: [\n { quote: \"Something kind they said.\", name: \"A name\", role: \"\", photo: \"\" },\n { quote: \"Something else kind they said.\", name: \"Another name\", role: \"\", photo: \"\" },\n ],\n className: \"\",\n },\n render: ({ title, testimonials, className }) => {\n const part = useParts();\n return (\n <section className={part(\"gf-shadcn-testimonials\", \"mx-auto w-full max-w-6xl px-4 py-12\", className)}>\n {title && (\n <h2 className={part(\"gf-shadcn-testimonials-heading\", \"mb-8 text-3xl font-bold tracking-tight\")}>\n {title}\n </h2>\n )}\n <div className={part(\"gf-shadcn-testimonials-grid\", \"grid gap-6 md:grid-cols-2 lg:grid-cols-3\")}>\n {testimonials.map((item, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: quotes have no ids of their own\n <Card key={index} className={part(\"gf-shadcn-testimonials-card\")}>\n <CardContent className=\"flex-1\">\n <blockquote className={part(\"gf-shadcn-testimonials-quote\", \"text-lg leading-relaxed\")}>\n “{item.quote}”\n </blockquote>\n </CardContent>\n <CardFooter className=\"gap-3\">\n {item.photo && (\n <SiteImage\n src={item.photo}\n alt=\"\"\n className={part(\"gf-shadcn-testimonials-photo\", \"size-10 shrink-0 rounded-full object-cover\")}\n />\n )}\n <div className=\"flex flex-col\">\n <span className={part(\"gf-shadcn-testimonials-name\", \"font-semibold\")}>{item.name}</span>\n {item.role && (\n <span className={part(\"gf-shadcn-testimonials-role\", \"text-sm text-muted-foreground\")}>\n {item.role}\n </span>\n )}\n </div>\n </CardFooter>\n </Card>\n ))}\n </div>\n </section>\n );\n },\n },\n {\n \"gf-shadcn-testimonials\": \"Testimonials\",\n \"gf-shadcn-testimonials-heading\": \"Heading\",\n \"gf-shadcn-testimonials-grid\": \"Grid of quotes\",\n \"gf-shadcn-testimonials-card\": \"Quote's card\",\n \"gf-shadcn-testimonials-quote\": \"Quote\",\n \"gf-shadcn-testimonials-photo\": \"Photo\",\n \"gf-shadcn-testimonials-name\": \"Name\",\n \"gf-shadcn-testimonials-role\": \"Role\",\n },\n);\n\nexport default Testimonials;\n"
15
15
  }
16
16
  ],
17
17
  "meta": {
18
18
  "goodfellow": {
19
19
  "category": "Page sections",
20
20
  "recommended": true,
21
- "version": "0.5.0"
21
+ "version": "0.7.0"
22
22
  }
23
23
  },
24
24
  "dependencies": []
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "slideshow-hero",
4
+ "type": "registry:block",
5
+ "title": "Slideshow with quick links",
6
+ "description": "Full-width pictures that fade from one to the next, with a badge and a column of quick links over them.",
7
+ "files": [
8
+ {
9
+ "path": "blocks/installed/slideshow-hero/block.tsx",
10
+ "type": "registry:block",
11
+ "content": "import {\n classNameField,\n mediaField,\n SiteImage,\n SiteLink,\n useParts,\n withKeywords,\n withParts,\n} from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { SlideshowPictures } from \"./slideshow-pictures\";\n\ninterface Slide {\n image: string;\n alt: string;\n}\n\ninterface QuickLink {\n label: string;\n href: string;\n}\n\nexport interface SlideshowHeroProps {\n slides: Slide[];\n seconds: number;\n badge: string;\n badgeAlt: string;\n links: QuickLink[];\n /** Words for the pause button and the dots, which screen readers say. */\n words: { pause: string; play: string; picture: string };\n className: string;\n}\n\n/**\n * The top of a home page: pictures that fade from one to the next across the full width, with a\n * badge (such as a logo) and a column of quick links beside them. It changes pictures on its own\n * unless the visitor asks for less motion, stops while the pointer or the keyboard is in it, and\n * has a button to stop it.\n */\nconst SlideshowHero: ComponentConfig<SlideshowHeroProps> = withParts(\n {\n label: \"Slideshow with quick links\",\n fields: {\n slides: {\n type: \"array\",\n label: \"Pictures\",\n arrayFields: {\n image: mediaField(\"Picture\"),\n alt: { type: \"text\", label: \"Description for screen readers\" },\n },\n defaultItemProps: { image: \"\", alt: \"\" },\n getItemSummary: (slide, index) => slide.alt || `Picture ${(index ?? 0) + 1}`,\n },\n seconds: { type: \"number\", label: \"Seconds each picture shows\", min: 2, max: 30 },\n badge: mediaField(\"Badge above the links (optional)\"),\n badgeAlt: { type: \"text\", label: \"Badge's description for screen readers\" },\n links: {\n type: \"array\",\n label: \"Quick links\",\n arrayFields: {\n label: { type: \"text\", label: \"Label\" },\n href: { type: \"text\", label: \"Links to\" },\n },\n defaultItemProps: { label: \"New link\", href: \"/\" },\n getItemSummary: (link) => link.label || \"New link\",\n },\n words: {\n type: \"object\",\n label: \"Words on the buttons\",\n objectFields: {\n pause: { type: \"text\", label: \"Stop button\" },\n play: { type: \"text\", label: \"Start button\" },\n picture: { type: \"text\", label: \"Each dot, for screen readers, before its number\" },\n },\n },\n className: classNameField,\n },\n defaultProps: {\n slides: [],\n seconds: 6,\n badge: \"\",\n badgeAlt: \"\",\n links: [],\n words: { pause: \"Pause\", play: \"Play\", picture: \"Show picture\" },\n className: \"\",\n },\n render: ({ slides, seconds, badge, badgeAlt, links, words, className }) => {\n const part = useParts();\n const shown = slides.filter((slide) => slide.image);\n return (\n <section className={part(\"gf-slideshow-hero\", \"relative isolate overflow-hidden bg-primary\", className)}>\n {shown.length > 0 && (\n <SlideshowPictures\n slides={shown}\n seconds={seconds}\n words={{\n pause: words.pause || \"Pause\",\n play: words.play || \"Play\",\n picture: words.picture || \"Show picture\",\n }}\n classes={{\n picture: part(\n \"gf-slideshow-hero-picture\",\n \"absolute inset-0 h-full w-full object-cover transition-opacity duration-1000\",\n ),\n controls: part(\"gf-slideshow-hero-dots\", \"absolute bottom-4 left-4 z-10 flex items-center gap-2\"),\n dot: part(\"gf-slideshow-hero-dot\", \"size-3.5 rounded-full border border-white/80\"),\n pause: part(\n \"gf-slideshow-hero-pause\",\n \"mr-1 rounded-full bg-black/60 px-3 py-1 text-sm text-white hover:bg-black/80\",\n ),\n }}\n />\n )}\n <div\n className={part(\n \"gf-slideshow-hero-inner\",\n \"relative mx-auto flex min-h-[22rem] max-w-7xl items-center justify-end px-4 py-8 md:min-h-[28rem]\",\n )}\n >\n {(badge || links.length > 0) && (\n <div\n className={part(\"gf-slideshow-hero-links\", \"relative z-10 flex w-full max-w-xs flex-col gap-2 md:mr-4\")}\n >\n {badge && (\n <div className={part(\"gf-slideshow-hero-badge\", \"bg-primary/90 px-6 py-4\")}>\n <SiteImage\n src={badge}\n alt={badgeAlt}\n aria-hidden={badgeAlt ? undefined : true}\n className=\"mx-auto max-h-28 w-auto\"\n />\n </div>\n )}\n {links.map((link, index) => (\n <SiteLink\n // biome-ignore lint/suspicious/noArrayIndexKey: links have no ids of their own\n key={index}\n href={link.href}\n className={part(\n \"gf-slideshow-hero-link\",\n \"bg-primary/90 px-4 py-2.5 text-center text-lg text-primary-foreground shadow transition-colors hover:bg-secondary hover:text-secondary-foreground\",\n )}\n >\n {link.label}\n </SiteLink>\n ))}\n </div>\n )}\n </div>\n </section>\n );\n },\n },\n {\n \"gf-slideshow-hero\": \"Slideshow\",\n \"gf-slideshow-hero-picture\": \"Picture\",\n \"gf-slideshow-hero-dots\": \"Dots and stop button\",\n \"gf-slideshow-hero-dot\": \"Dot\",\n \"gf-slideshow-hero-pause\": \"Stop button\",\n \"gf-slideshow-hero-inner\": \"Inner area\",\n \"gf-slideshow-hero-links\": \"Badge and links\",\n \"gf-slideshow-hero-badge\": \"Badge\",\n \"gf-slideshow-hero-link\": \"Quick link\",\n },\n);\n\nexport default withKeywords(SlideshowHero, [\"slideshow\", \"slider\", \"hero\", \"banner\", \"quick links\", \"pictures\"]);\n"
12
+ },
13
+ {
14
+ "path": "blocks/installed/slideshow-hero/slideshow-pictures.tsx",
15
+ "type": "registry:component",
16
+ "content": "\"use client\";\n\nimport { SiteImage } from \"@goodfellow-cms/react\";\nimport { useEffect, useState } from \"react\";\n\n/**\n * The pictures, fading from one to the next every few seconds, with a dot for\n * each and a button that stops them. They stop while the pointer is over them\n * or the keyboard is in them, and don't change by themselves at all for\n * visitors who ask for less motion.\n */\nexport function SlideshowPictures({\n slides,\n seconds,\n words,\n classes,\n}: {\n slides: Array<{ image: string; alt: string }>;\n seconds: number;\n words: { pause: string; play: string; picture: string };\n classes: { picture: string; controls: string; dot: string; pause: string };\n}) {\n const [shown, setShown] = useState(0);\n const [hovered, setHovered] = useState(false);\n const [stopped, setStopped] = useState(false);\n const [still, setStill] = useState(false);\n\n useEffect(() => {\n const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n setStill(query.matches);\n const changed = () => setStill(query.matches);\n query.addEventListener(\"change\", changed);\n return () => query.removeEventListener(\"change\", changed);\n }, []);\n\n const moving = slides.length > 1 && !still && !stopped;\n useEffect(() => {\n if (!moving || hovered) return;\n const timer = window.setInterval(\n () => setShown((index) => (index + 1) % slides.length),\n Math.max(2, seconds) * 1000,\n );\n return () => window.clearInterval(timer);\n }, [moving, hovered, slides.length, seconds]);\n\n return (\n // biome-ignore lint/a11y/noStaticElementInteractions: it only notices the pointer and the keyboard in it, to stop the pictures; its buttons are what visitors use\n <div\n className=\"absolute inset-0\"\n onMouseEnter={() => setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n onFocus={() => setHovered(true)}\n onBlur={() => setHovered(false)}\n >\n {slides.map((slide, index) => (\n <SiteImage\n // biome-ignore lint/suspicious/noArrayIndexKey: pictures have no ids of their own\n key={index}\n src={slide.image}\n alt={index === shown ? slide.alt : \"\"}\n // Pictures not shown, and ones without a description, are only there to look at.\n aria-hidden={index === shown && slide.alt ? undefined : true}\n className={`${classes.picture} ${index === shown ? \"opacity-100\" : \"opacity-0\"}`}\n />\n ))}\n {slides.length > 1 && (\n <div className={classes.controls}>\n {!still && (\n <button type=\"button\" onClick={() => setStopped(!stopped)} className={classes.pause}>\n {stopped ? words.play : words.pause}\n </button>\n )}\n {slides.map((slide, index) => (\n <button\n // biome-ignore lint/suspicious/noArrayIndexKey: pictures have no ids of their own\n key={index}\n type=\"button\"\n aria-label={`${words.picture} ${index + 1}${slide.alt ? `: ${slide.alt}` : \"\"}`}\n aria-current={index === shown}\n onClick={() => setShown(index)}\n className={`${classes.dot} ${index === shown ? \"bg-white\" : \"bg-black/50 hover:bg-white/70\"}`}\n />\n ))}\n </div>\n )}\n </div>\n );\n}\n"
17
+ }
18
+ ],
19
+ "meta": {
20
+ "goodfellow": {
21
+ "category": "Interactive",
22
+ "version": "0.7.0"
23
+ }
24
+ },
25
+ "dependencies": []
26
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "social-links",
4
+ "type": "registry:block",
5
+ "title": "Social links",
6
+ "description": "Links to social networks, each shown as the network's mark in the text's color.",
7
+ "files": [
8
+ {
9
+ "path": "blocks/installed/social-links/block.tsx",
10
+ "type": "registry:block",
11
+ "content": "import { classNameField, SiteLink, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\n\ntype Network = \"facebook\" | \"instagram\" | \"youtube\" | \"x\";\n\ninterface SocialLink {\n network: Network;\n href: string;\n}\n\nexport interface SocialLinksProps {\n links: SocialLink[];\n size: \"sm\" | \"md\" | \"lg\";\n className: string;\n}\n\nconst names: Record<Network, string> = { facebook: \"Facebook\", instagram: \"Instagram\", youtube: \"YouTube\", x: \"X\" };\n\n/** Each network's mark, drawn in the text's color on a 24 by 24 grid. */\nconst paths: Record<Network, string> = {\n facebook:\n \"M24 12.07C24 5.41 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.04V9.41c0-3.02 1.8-4.7 4.54-4.7 1.31 0 2.68.24 2.68.24v2.97h-1.5c-1.5 0-1.96.93-1.96 1.89v2.26h3.32l-.53 3.5h-2.8V24C19.62 23.1 24 18.1 24 12.07\",\n instagram:\n \"M12 2.16c3.2 0 3.58.01 4.85.07 3.25.15 4.77 1.69 4.92 4.92.06 1.27.07 1.65.07 4.85s-.01 3.58-.07 4.85c-.15 3.23-1.66 4.77-4.92 4.92-1.27.06-1.65.07-4.85.07s-3.58-.01-4.85-.07c-3.26-.15-4.77-1.7-4.92-4.92C2.17 15.58 2.16 15.2 2.16 12s.01-3.58.07-4.85C2.38 3.92 3.9 2.38 7.15 2.23 8.42 2.17 8.8 2.16 12 2.16M12 0C8.74 0 8.33.01 7.05.07 2.7.27.27 2.69.07 7.05.01 8.33 0 8.74 0 12s.01 3.67.07 4.95c.2 4.36 2.62 6.78 6.98 6.98C8.33 23.99 8.74 24 12 24s3.67-.01 4.95-.07c4.35-.2 6.78-2.62 6.98-6.98.06-1.28.07-1.69.07-4.95s-.01-3.67-.07-4.95c-.2-4.35-2.62-6.78-6.98-6.98C15.67.01 15.26 0 12 0m0 5.84a6.16 6.16 0 1 0 0 12.32 6.16 6.16 0 0 0 0-12.32M12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8m6.4-11.85a1.44 1.44 0 1 0 0 2.88 1.44 1.44 0 0 0 0-2.88\",\n youtube:\n \"M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.88.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81M9.55 15.57V8.43L15.82 12z\",\n x: \"M18.9 1.15h3.68l-8.04 9.19L24 22.85h-7.4l-5.8-7.58-6.63 7.58H.48l8.6-9.83L0 1.15h7.59l5.24 6.93zm-1.29 19.5h2.04L6.48 3.24H4.3z\",\n};\n\nconst sizeClasses: Record<SocialLinksProps[\"size\"], string> = { sm: \"size-5\", md: \"size-6\", lg: \"size-8\" };\n\n/** Links to the site's pages on social networks, each shown as the network's mark, in the text's color. */\nconst SocialLinks: ComponentConfig<SocialLinksProps> = withParts(\n {\n label: \"Social links\",\n fields: {\n links: {\n type: \"array\",\n label: \"Links\",\n arrayFields: {\n network: {\n type: \"select\",\n label: \"Network\",\n options: (Object.keys(names) as Network[]).map((value) => ({ value, label: names[value] })),\n },\n href: { type: \"text\", label: \"Address of the page\", placeholder: \"https://www.facebook.com/…\" },\n },\n defaultItemProps: { network: \"facebook\", href: \"\" },\n getItemSummary: (link) => names[link.network] ?? \"New link\",\n },\n size: {\n type: \"radio\",\n label: \"Size\",\n options: [\n { value: \"sm\", label: \"Small\" },\n { value: \"md\", label: \"Medium\" },\n { value: \"lg\", label: \"Large\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: { links: [], size: \"md\", className: \"\" },\n render: ({ links, size, className }) => {\n const part = useParts();\n return (\n <ul className={part(\"gf-social-links\", \"flex flex-wrap items-center gap-3\", className)}>\n {links\n .filter((link) => link.href && paths[link.network])\n .map((link, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: links have no ids of their own\n <li key={index}>\n <SiteLink\n href={link.href}\n className={part(\"gf-social-links-link\", \"block opacity-90 transition-opacity hover:opacity-100\")}\n >\n <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" className={sizeClasses[size]}>\n <path d={paths[link.network]} />\n </svg>\n <span className=\"sr-only\">{names[link.network]}</span>\n </SiteLink>\n </li>\n ))}\n </ul>\n );\n },\n },\n { \"gf-social-links\": \"Social links\", \"gf-social-links-link\": \"Link\" },\n);\n\nexport default withKeywords(SocialLinks, [\"social\", \"facebook\", \"instagram\", \"youtube\", \"x\", \"twitter\", \"icons\"]);\n"
12
+ }
13
+ ],
14
+ "meta": {
15
+ "goodfellow": {
16
+ "category": "Page sections",
17
+ "version": "0.7.0"
18
+ }
19
+ },
20
+ "dependencies": []
21
+ }