@goodfellow-cms/registry 0.6.0 → 0.8.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 +16 -2
- package/package.json +3 -3
- package/r/comments.json +26 -0
- package/r/countdown.json +26 -0
- package/r/custom-html.json +4 -4
- package/r/feed-list.json +21 -0
- package/r/logos.json +21 -0
- package/r/map.json +21 -0
- package/r/numbers.json +21 -0
- package/r/panel.json +21 -0
- package/r/people.json +21 -0
- package/r/registry.json +250 -10
- package/r/section-links.json +21 -0
- package/r/shadcn-call-to-action.json +1 -1
- package/r/shadcn-cards.json +1 -1
- package/r/shadcn-carousel.json +2 -2
- package/r/shadcn-faq.json +2 -2
- package/r/shadcn-hero.json +1 -1
- package/r/shadcn-notice.json +1 -1
- package/r/shadcn-pricing.json +1 -1
- package/r/shadcn-tabs.json +2 -2
- package/r/shadcn-testimonials.json +1 -1
- package/r/slideshow-hero.json +26 -0
- package/r/social-links.json +21 -0
- package/r/timeline.json +21 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @goodfellow-cms/registry
|
|
2
2
|
|
|
3
|
-
Goodfellow's block registry: blocks built with [shadcn/ui](https://ui.shadcn.com), which a site's admins add and remove on the admin panel's **Blocks** screen, with no developer. It's also the reference for anyone publishing a registry of Goodfellow blocks built with another component library.
|
|
3
|
+
Goodfellow's block registry: blocks built with [shadcn/ui](https://ui.shadcn.com), and a few built with Tailwind alone for some sites only, which a site's admins add and remove on the admin panel's **Blocks** screen, with no developer. It's also the reference for anyone publishing a registry of Goodfellow blocks built with another component library.
|
|
4
4
|
|
|
5
5
|
| Block | Category | Recommended |
|
|
6
6
|
|---|---|---|
|
|
@@ -12,7 +12,19 @@ Goodfellow's block registry: blocks built with [shadcn/ui](https://ui.shadcn.com
|
|
|
12
12
|
| Pricing table | Page sections | |
|
|
13
13
|
| FAQ | Interactive | Yes |
|
|
14
14
|
| Tabs | Interactive | Yes |
|
|
15
|
+
| News feed | Page sections | |
|
|
16
|
+
| Map | Page sections | |
|
|
17
|
+
| Social links | Page sections | |
|
|
18
|
+
| Panel | Page sections | |
|
|
15
19
|
| Image carousel | Interactive | |
|
|
20
|
+
| Slideshow with quick links | Interactive | |
|
|
21
|
+
| Content with section links | Navigation | |
|
|
22
|
+
| People | Page sections | |
|
|
23
|
+
| Logos | Page sections | |
|
|
24
|
+
| Numbers | Page sections | |
|
|
25
|
+
| Timeline | Page sections | |
|
|
26
|
+
| Countdown | Interactive | |
|
|
27
|
+
| Comments | Interactive | |
|
|
16
28
|
| Custom HTML | Advanced | |
|
|
17
29
|
|
|
18
30
|
New sites made with `create-goodfellow` start with the recommended blocks unless the admin chooses "Built-in blocks only".
|
|
@@ -71,7 +83,7 @@ A block is a `registry:block` item with `meta.goodfellow`:
|
|
|
71
83
|
|
|
72
84
|
### Packages
|
|
73
85
|
|
|
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.
|
|
86
|
+
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
87
|
|
|
76
88
|
### Writing the blocks
|
|
77
89
|
|
|
@@ -79,6 +91,7 @@ Registry blocks follow the same rules as any Goodfellow block (see [AGENTS.md](h
|
|
|
79
91
|
|
|
80
92
|
- Every block has a `className` prop (`className: classNameField`), applied to its outermost element and combined with `cx()` from `@goodfellow-cms/react`, last.
|
|
81
93
|
- 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.
|
|
94
|
+
- **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.
|
|
82
95
|
- **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.
|
|
83
96
|
- 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()`.
|
|
84
97
|
- Labels and options are for non-technical people: "Space above and below", not "padding-y".
|
|
@@ -86,6 +99,7 @@ Registry blocks follow the same rules as any Goodfellow block (see [AGENTS.md](h
|
|
|
86
99
|
- **One Client Component per interactive part.** Sites built with `goodfellow build` run each Client Component a block renders as its own small React app (an island), and islands don't share React context. Components whose parts talk to each other through context, as Radix's `Accordion`, `AccordionItem` and `AccordionContent` do, must all be put together inside one `"use client"` file, which the block renders with its content as props. See `site/blocks/installed/shadcn-faq/` here.
|
|
87
100
|
- **Keep content in the page.** Hidden content, such as closed answers or other tabs, should still be in the HTML (Radix's `forceMount`, hidden with CSS), so search engines and visitors without JavaScript see it.
|
|
88
101
|
- Prefer HTML and CSS where they're enough: a `<details>` element rather than a Client Component, since a page with a Client Component loads React.
|
|
102
|
+
- **Say when a Client Component works with Preact**, with `export const worksWithPreact = true` in its `"use client"` file, once you've tried it there. Sites whose Client Components all say so run them with Preact's `preact/compat` instead of React, about a tenth of its size; one that doesn't keeps a site on React. Every interactive block here says so, and Goodfellow's tests try each with Preact.
|
|
89
103
|
|
|
90
104
|
### Letting a site use it
|
|
91
105
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/registry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.
|
|
32
|
-
"@goodfellow-cms/react": "0.
|
|
31
|
+
"@goodfellow-cms/core": "0.8.0",
|
|
32
|
+
"@goodfellow-cms/react": "0.8.0",
|
|
33
33
|
"@puckeditor/core": "0.23.0",
|
|
34
34
|
"@types/react": "^19.2.0",
|
|
35
35
|
"@types/react-dom": "^19.2.0",
|
package/r/comments.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "comments",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Comments",
|
|
6
|
+
"description": "Comments on the page through Giscus, kept as GitHub Discussions; visitors sign in with GitHub to comment.",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "blocks/installed/comments/block.tsx",
|
|
10
|
+
"type": "registry:block",
|
|
11
|
+
"content": "import {\n CONSENT_ACCEPT_ATTRIBUTE,\n CONSENT_CODE_ATTRIBUTE,\n CONSENT_PLACEHOLDER_ATTRIBUTE,\n consentSource,\n} from \"@goodfellow-cms/core\";\nimport { classNameField, useParts, useSite, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport type { ReactNode } from \"react\";\nimport { GiscusFrame } from \"./giscus-frame\";\n\nexport interface CommentsProps {\n heading: string;\n /**\n * Giscus's code from giscus.app, which names the repository and the\n * discussions' category, for comments kept somewhere other than where Site\n * Settings' Comments says; left empty, the block shows those.\n */\n code: string;\n reactions: boolean;\n /** Where the box for writing a comment is: above the comments or under them. */\n input: \"top\" | \"bottom\";\n className: string;\n}\n\n/** Giscus's script, from its own address, which loads its comments in a frame. */\nexport const GISCUS_SCRIPT = \"https://giscus.app/client.js\";\n\n/** What Giscus needs to know where a site's discussions are. */\nexport interface GiscusSettings {\n repo: string;\n repoId: string;\n category: string;\n categoryId: string;\n}\n\n/** The repository and category named in Giscus's code, if it names them as Giscus writes them. */\nexport function giscusSettings(code: string): GiscusSettings | undefined {\n const attribute = (name: string) =>\n new RegExp(`\\\\sdata-${name}\\\\s*=\\\\s*[\"']([^\"'<>]*)[\"']`, \"i\").exec(code)?.[1]?.replaceAll(\"&\", \"&\").trim();\n const repo = attribute(\"repo\");\n const repoId = attribute(\"repo-id\");\n const category = attribute(\"category\");\n const categoryId = attribute(\"category-id\");\n if (!repo || !/^[A-Za-z0-9-]+\\/[A-Za-z0-9._-]+$/.test(repo)) return undefined;\n if (!repoId || !/^[A-Za-z0-9_-]+$/.test(repoId)) return undefined;\n if (!categoryId || !/^[A-Za-z0-9_-]+$/.test(categoryId)) return undefined;\n return { repo, repoId, category: category ?? \"\", categoryId };\n}\n\nconst escapeText = (text: string) =>\n text.replace(/[&<>\"]/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\" })[c] as string);\n\nfunction CommentsView({\n heading,\n code,\n reactions,\n input,\n className,\n puck,\n}: CommentsProps & { puck?: { isEditing?: boolean } }) {\n const part = useParts();\n const { settings, adminLink } = useSite();\n const giscus = giscusSettings(code ?? \"\") ?? (code?.trim() ? undefined : settings.comments);\n if (!giscus && !puck?.isEditing) return null;\n // Pages are built on the server: in the browser, it's the editor or a preview, which never load Giscus.\n const onSite = typeof document === \"undefined\" && !puck?.isEditing;\n const frameClass = part(\"gf-comments-frame\", \"w-full\");\n let body: ReactNode;\n if (!giscus) {\n const link = code?.trim() ? undefined : adminLink?.(\"comments-settings\");\n const text = code?.trim()\n ? \"This isn't Giscus's code. On giscus.app, choose the repository and category, then copy the code at the end of the page.\"\n : \"Set up comments in Site settings' Comments to show them here.\";\n body = (\n <p className=\"rounded-lg bg-muted p-6 text-muted-foreground\">\n {link ? (\n <a {...link} className=\"underline underline-offset-2 hover:text-foreground\">\n {text}\n </a>\n ) : (\n text\n )}\n </p>\n );\n } else if (!onSite) {\n body = (\n <p className=\"rounded-lg bg-muted p-6 text-muted-foreground\">\n Comments from {giscus.repo}'s discussions show here on the site.\n </p>\n );\n } else {\n const attributes: Record<string, string> = {\n \"data-repo\": giscus.repo,\n \"data-repo-id\": giscus.repoId,\n \"data-category\": giscus.category,\n \"data-category-id\": giscus.categoryId,\n \"data-mapping\": \"pathname\",\n \"data-strict\": \"1\",\n \"data-reactions-enabled\": reactions === false ? \"0\" : \"1\",\n \"data-emit-metadata\": \"0\",\n \"data-input-position\": input === \"top\" ? \"top\" : \"bottom\",\n \"data-theme\": settings.theme?.darkMode ? \"preferred_color_scheme\" : \"light\",\n \"data-lang\": (settings.language ?? \"en\").split(\"-\")[0] ?? \"en\",\n \"data-loading\": \"lazy\",\n };\n const consent = settings.consent;\n if (consent) {\n // Held back in a template for the consent script, which runs it where it is once the visitor agrees.\n const script = `<script src=\"${GISCUS_SCRIPT}\" crossorigin=\"anonymous\" async${Object.entries(attributes)\n .map(([name, value]) => ` ${name}=\"${escapeText(value)}\"`)\n .join(\"\")}></script>`;\n const words = consent.held.replaceAll(\"{site}\", consentSource(GISCUS_SCRIPT));\n body = (\n <div\n // biome-ignore lint/security/noDangerouslySetInnerHtml: Giscus's script with escaped settings, held back until the visitor agrees\n dangerouslySetInnerHTML={{\n __html: `<div class=\"giscus ${escapeText(frameClass)}\"></div><template ${CONSENT_CODE_ATTRIBUTE}=\"body\">${script}</template><div class=\"gf-consent-placeholder gf-consent-inline\" ${CONSENT_PLACEHOLDER_ATTRIBUTE}=\"\"><p>${escapeText(words)}</p><button type=\"button\" ${CONSENT_ACCEPT_ATTRIBUTE}=\"content\">${escapeText(consent.show)}</button></div>`,\n }}\n />\n );\n } else {\n body = <GiscusFrame script={GISCUS_SCRIPT} attributes={attributes} className={frameClass} />;\n }\n }\n return (\n <section className={part(\"gf-comments\", \"w-full\", className)}>\n {heading && (\n <h2 className={part(\"gf-comments-heading\", \"mb-6 font-heading text-2xl font-semibold\")}>{heading}</h2>\n )}\n {body}\n </section>\n );\n}\n\n/**\n * Comments on the page, kept as GitHub Discussions in a repository of their\n * own, through Giscus: visitors sign in with GitHub to comment. Where they're\n * kept is Site Settings' Comments (`content/site.json`'s `comments`), unless\n * the block is given Giscus's code for somewhere else.\n * Giscus loads only on the built site, never in the editor or previews, and\n * on a site that asks visitors before loading other sites' content, it waits\n * until they agree.\n */\nconst Comments: ComponentConfig<CommentsProps> = withParts(\n {\n label: \"Comments\",\n fields: {\n heading: { type: \"text\", label: \"Heading\", contentEditable: true },\n code: {\n type: \"textarea\",\n label: \"Giscus's code, for comments kept elsewhere than Site settings says (optional)\",\n },\n reactions: {\n type: \"radio\",\n label: \"Reactions to the page\",\n options: [\n { value: true, label: \"Shown\" },\n { value: false, label: \"Hidden\" },\n ],\n },\n input: {\n type: \"radio\",\n label: \"Box for writing a comment\",\n options: [\n { value: \"bottom\", label: \"Under the comments\" },\n { value: \"top\", label: \"Above the comments\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: { heading: \"Comments\", code: \"\", reactions: true, input: \"bottom\", className: \"\" },\n render: (props) => <CommentsView {...props} />,\n },\n {\n \"gf-comments\": \"Comments\",\n \"gf-comments-heading\": \"Heading\",\n \"gf-comments-frame\": \"Comments' frame\",\n },\n);\n\nexport default withKeywords(Comments, [\"comments\", \"discussion\", \"giscus\", \"replies\", \"feedback\", \"github\"]);\n"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "blocks/installed/comments/giscus-frame.tsx",
|
|
15
|
+
"type": "registry:component",
|
|
16
|
+
"content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\n\n/**\n * Giscus's comments, loaded once the page has: its script reads its settings\n * from its own attributes and puts the comments in the `.giscus` element.\n * Started again after moving to another page on a Next.js site, which keeps\n * the same document.\n */\nexport function GiscusFrame({\n script,\n attributes,\n className,\n}: {\n script: string;\n attributes: Record<string, string>;\n className: string;\n}) {\n const box = useRef<HTMLDivElement>(null);\n const settings = JSON.stringify(attributes);\n useEffect(() => {\n const container = box.current;\n if (!container) return;\n const element = document.createElement(\"script\");\n element.src = script;\n element.async = true;\n element.crossOrigin = \"anonymous\";\n for (const [name, value] of Object.entries(JSON.parse(settings) as Record<string, string>)) {\n element.setAttribute(name, value);\n }\n container.after(element);\n return () => {\n element.remove();\n container.replaceChildren();\n };\n }, [script, settings]);\n return <div ref={box} className={`giscus ${className}`.trim()} />;\n}\n"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"meta": {
|
|
20
|
+
"goodfellow": {
|
|
21
|
+
"category": "Interactive",
|
|
22
|
+
"version": "0.8.0"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"dependencies": []
|
|
26
|
+
}
|
package/r/countdown.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "countdown",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Countdown",
|
|
6
|
+
"description": "The days, hours, minutes and seconds until a moment, in the site's time zone.",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "blocks/installed/countdown/block.tsx",
|
|
10
|
+
"type": "registry:block",
|
|
11
|
+
"content": "import { changesOn, isTimeZone, zonedInstant } from \"@goodfellow-cms/core\";\nimport { classNameField, useParts, useSite, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\nimport { CountdownClock } from \"./countdown-clock\";\n\nexport interface CountdownProps {\n heading: string;\n /** The day, as `YYYY-MM-DD`, and the time, as `HH:MM`, in the site's time zone. */\n date: string;\n time: string;\n /** What it says once the moment has come. */\n finished: string;\n words: { days: string; hours: string; minutes: string; seconds: string };\n className: string;\n}\n\n/** The moment, as an instant, from the day and time in the site's time zone, if they're ones. */\nexport function countdownInstant(date: string, time: string, timeZone: string | undefined): Date | undefined {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(date.trim())) return undefined;\n const clock = time.trim() || \"00:00\";\n if (!/^([01]\\d|2[0-3]):[0-5]\\d$/.test(clock)) return undefined;\n const instant = zonedInstant(`${date.trim()}T${clock}`, timeZone);\n return Number.isNaN(instant.getTime()) ? undefined : instant;\n}\n\nfunction whenText(instant: Date, language: string | undefined, timeZone: string | undefined): string {\n const options: Intl.DateTimeFormatOptions = {\n dateStyle: \"full\",\n timeStyle: \"short\",\n ...(timeZone && isTimeZone(timeZone) && { timeZone }),\n };\n try {\n return new Intl.DateTimeFormat(language || \"en\", options).format(instant);\n } catch {\n return new Intl.DateTimeFormat(\"en\", options).format(instant);\n }\n}\n\nfunction CountdownView({\n heading,\n date,\n time,\n finished,\n words,\n className,\n puck,\n}: CountdownProps & { puck?: { isEditing?: boolean } }) {\n const part = useParts();\n const { settings } = useSite();\n const instant = countdownInstant(date ?? \"\", time ?? \"\", settings.timeZone);\n if (!instant) {\n if (!puck?.isEditing) return null;\n return (\n <div className={part(\"gf-countdown\", \"rounded-lg bg-muted p-6 text-center text-muted-foreground\", className)}>\n {date?.trim()\n ? \"The day must be written as 2027-12-24, and the time as 18:00.\"\n : \"Choose the day to count down to.\"}\n </div>\n );\n }\n // Pages are built again on the day, so once it has passed they say what's chosen without JavaScript too.\n changesOn(date.trim());\n const past = instant.getTime() <= Date.now();\n const finishedClass = part(\"gf-countdown-finished\", \"font-heading text-2xl font-semibold text-primary\");\n return (\n <section className={part(\"gf-countdown\", \"flex w-full flex-col items-center gap-4 text-center\", className)}>\n {heading && <h2 className={part(\"gf-countdown-heading\", \"font-heading text-3xl font-semibold\")}>{heading}</h2>}\n {past ? (\n <p className={finishedClass}>{finished}</p>\n ) : (\n <CountdownClock\n until={instant.getTime()}\n iso={instant.toISOString()}\n when={whenText(instant, settings.language, settings.timeZone)}\n finished={finished}\n words={words}\n classes={{\n when: part(\"gf-countdown-when\", \"text-lg text-muted-foreground\"),\n units: part(\"gf-countdown-units\", \"flex flex-wrap justify-center gap-3\"),\n unit: part(\n \"gf-countdown-unit\",\n \"flex min-w-20 flex-col items-center rounded-lg border border-border bg-card px-4 py-3 text-card-foreground\",\n ),\n number: part(\"gf-countdown-number\", \"font-heading text-4xl font-bold tabular-nums\"),\n label: part(\"gf-countdown-label\", \"text-sm text-muted-foreground\"),\n finished: finishedClass,\n }}\n />\n )}\n </section>\n );\n}\n\n/**\n * The days, hours, minutes and seconds until a moment, such as a festival or\n * an appeal's end, in the site's time zone. Without JavaScript it shows when\n * the moment is, and once it has come, what's chosen to say then.\n */\nconst Countdown: ComponentConfig<CountdownProps> = withParts(\n {\n label: \"Countdown\",\n fields: {\n heading: { type: \"text\", label: \"Heading\", contentEditable: true },\n date: { type: \"text\", label: \"Day, such as 2027-12-24\", placeholder: \"2027-12-24\" },\n time: { type: \"text\", label: \"Time, such as 18:00, in the site's time zone\", placeholder: \"18:00\" },\n finished: { type: \"text\", label: \"What it says once the moment has come\", contentEditable: true },\n words: {\n type: \"object\",\n label: \"Words under the numbers\",\n objectFields: {\n days: { type: \"text\", label: \"Days\" },\n hours: { type: \"text\", label: \"Hours\" },\n minutes: { type: \"text\", label: \"Minutes\" },\n seconds: { type: \"text\", label: \"Seconds\" },\n },\n },\n className: classNameField,\n },\n defaultProps: {\n heading: \"Christmas is coming\",\n date: \"\",\n time: \"\",\n finished: \"It's here!\",\n words: { days: \"Days\", hours: \"Hours\", minutes: \"Minutes\", seconds: \"Seconds\" },\n className: \"\",\n },\n render: (props) => <CountdownView {...props} />,\n },\n {\n \"gf-countdown\": \"Countdown\",\n \"gf-countdown-heading\": \"Heading\",\n \"gf-countdown-when\": \"When, before it starts counting\",\n \"gf-countdown-units\": \"Numbers\",\n \"gf-countdown-unit\": \"Number with its word\",\n \"gf-countdown-number\": \"Number\",\n \"gf-countdown-label\": \"Word under the number\",\n \"gf-countdown-finished\": \"What it says once the moment has come\",\n },\n);\n\nexport default withKeywords(Countdown, [\"countdown\", \"timer\", \"launch\", \"event\", \"days until\", \"clock\"]);\n"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "blocks/installed/countdown/countdown-clock.tsx",
|
|
15
|
+
"type": "registry:component",
|
|
16
|
+
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\n\ninterface Words {\n days: string;\n hours: string;\n minutes: string;\n seconds: string;\n}\n\nconst UNITS = [\"days\", \"hours\", \"minutes\", \"seconds\"] as const;\n\nfunction left(until: number, now: number): Record<(typeof UNITS)[number], number> | undefined {\n const seconds = Math.floor((until - now) / 1000);\n if (seconds <= 0) return undefined;\n return {\n days: Math.floor(seconds / 86_400),\n hours: Math.floor(seconds / 3600) % 24,\n minutes: Math.floor(seconds / 60) % 60,\n seconds: seconds % 60,\n };\n}\n\n/**\n * The time left, counting down each second once the page has loaded. Before\n * then, and for visitors without JavaScript, it shows when the moment is,\n * which screen readers always read in place of the ticking numbers.\n */\nexport function CountdownClock({\n until,\n when,\n iso,\n finished,\n words,\n classes,\n}: {\n until: number;\n when: string;\n iso: string;\n finished: string;\n words: Words;\n classes: { when: string; units: string; unit: string; number: string; label: string; finished: string };\n}) {\n const [now, setNow] = useState<number | undefined>(undefined);\n useEffect(() => {\n setNow(Date.now());\n const timer = setInterval(() => setNow(Date.now()), 1000);\n return () => clearInterval(timer);\n }, []);\n const time = (\n <time dateTime={iso} className={now === undefined ? classes.when : \"sr-only\"}>\n {when}\n </time>\n );\n if (now === undefined) return time;\n const remaining = left(until, now);\n if (!remaining) return <p className={classes.finished}>{finished}</p>;\n return (\n <>\n {time}\n <div aria-hidden=\"true\" className={classes.units}>\n {UNITS.map((unit) => (\n <div key={unit} className={classes.unit}>\n <span className={classes.number}>{String(remaining[unit]).padStart(unit === \"days\" ? 1 : 2, \"0\")}</span>\n <span className={classes.label}>{words[unit]}</span>\n </div>\n ))}\n </div>\n </>\n );\n}\n"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"meta": {
|
|
20
|
+
"goodfellow": {
|
|
21
|
+
"category": "Interactive",
|
|
22
|
+
"version": "0.8.0"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"dependencies": []
|
|
26
|
+
}
|
package/r/custom-html.json
CHANGED
|
@@ -8,23 +8,23 @@
|
|
|
8
8
|
{
|
|
9
9
|
"path": "blocks/installed/custom-html/block.tsx",
|
|
10
10
|
"type": "registry:block",
|
|
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"
|
|
11
|
+
"content": "import {\n CONSENT_ACCEPT_ATTRIBUTE,\n CONSENT_CODE_ATTRIBUTE,\n CONSENT_PLACEHOLDER_ATTRIBUTE,\n type ConsentSettings,\n consentSource,\n endsHeldTemplate,\n} from \"@goodfellow-cms/core\";\nimport { classNameField, sanitizeHtml, useParts, useSite, 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/** HTML that loads or runs something, which a site that asks visitors first holds back. */\nconst LOADS = /<(script|iframe|object|embed|link)[\\s>/]/i;\n\nconst escapeText = (text: string) =>\n text.replace(/[&<>\"]/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\" })[c] as string);\n\n/**\n * HTML used as written, held back in a template for the consent script, with\n * a note and a button in its place, which the script hides once the visitor\n * agrees. It's all one element's HTML, so Next.js's React never looks inside\n * it, and leaves what the script adds there alone.\n */\nfunction heldHtml(html: string, consent: ConsentSettings): string {\n const src = /\\ssrc\\s*=\\s*[\"']?((?:https?:)?\\/\\/[^\"'\\s>]+)/i.exec(html)?.[1];\n const words = src\n ? consent.held.replaceAll(\"{site}\", consentSource(src.startsWith(\"//\") ? `https:${src}` : src))\n : consent.message;\n return `<template ${CONSENT_CODE_ATTRIBUTE}=\"body\">${html}</template><div class=\"gf-consent-placeholder gf-consent-inline\" ${CONSENT_PLACEHOLDER_ATTRIBUTE}=\"\"><p>${escapeText(words)}</p><button type=\"button\" ${CONSENT_ACCEPT_ATTRIBUTE}=\"content\">${escapeText(consent.show)}</button></div>`;\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. On a site that asks visitors before\n * loading other sites' content, HTML that loads or runs anything waits until\n * they agree, as other sites' players do.\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 // HTML used as written runs on the site: on full-stack sites, only owners may choose it.\n metadata: { owners: true },\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 { settings } = useSite();\n const className = part(\"gf-custom-html\", undefined, chosen);\n if (!sanitize && puck.isEditing) return <HtmlPreview html={html} className={className} />;\n // Where pages are built: the admin panel's previews render in the browser and show it at once.\n const consent = typeof document === \"undefined\" ? settings.consent : undefined;\n if (!sanitize && consent && LOADS.test(html)) {\n // HTML that would end the template early would run before the visitor agrees, so it's made safe instead.\n if (endsHeldTemplate(html)) {\n // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized\n return <div className={className} dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }} />;\n }\n return (\n <div\n className={className || undefined}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: the editor chose to use this HTML as written; it waits for the visitor's agreement\n dangerouslySetInnerHTML={{ __html: heldHtml(html, consent) }}\n />\n );\n }\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",
|
|
15
15
|
"type": "registry:component",
|
|
16
|
-
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/**\n * Shows HTML as it was written, in the editor only: inside a frame sandboxed\n * with nothing allowed, so it can't reach the admin panel or the editor's\n * sign-in. Its scripts don't run there, since the editor runs only its own\n * scripts; they run on the published site. It has the page's styles, and\n * can be made taller by dragging its corner.\n */\nexport function HtmlPreview({ html, className }: { html: string; className: string }) {\n const frame = useRef<HTMLIFrameElement>(null);\n const [styles, setStyles] = useState(\"\");\n\n useEffect(() => {\n const document = frame.current?.ownerDocument;\n if (!document) return;\n setStyles(\n [...document.querySelectorAll('link[rel=\"stylesheet\"], style')].map((element) => element.outerHTML).join(\"\"),\n );\n }, []);\n\n return (\n <div className={className || undefined}>\n <div style={{ height: 240, resize: \"vertical\", overflow: \"hidden\" }}>\n <iframe\n ref={frame}\n title=\"Custom HTML\"\n sandbox=\"\"\n srcDoc={`<!doctype html><html><head><meta charset=\"utf-8\">${styles}</head><body style=\"margin:0\">${html}</body></html>`}\n style={{ display: \"block\", width: \"100%\", height: \"100%\", border: 0 }}\n />\n </div>\n <p className=\"mt-2 text-sm text-muted-foreground\">\n Scripts don't run in the editor. They run on the published site.\n </p>\n </div>\n );\n}\n"
|
|
16
|
+
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\n\n/**\n * Shows HTML as it was written, in the editor only: inside a frame sandboxed\n * with nothing allowed, so it can't reach the admin panel or the editor's\n * sign-in. Its scripts don't run there, since the editor runs only its own\n * scripts; they run on the published site. It has the page's styles, and\n * can be made taller by dragging its corner.\n */\nexport function HtmlPreview({ html, className }: { html: string; className: string }) {\n const frame = useRef<HTMLIFrameElement>(null);\n const [styles, setStyles] = useState(\"\");\n\n useEffect(() => {\n const document = frame.current?.ownerDocument;\n if (!document) return;\n setStyles(\n [...document.querySelectorAll('link[rel=\"stylesheet\"], style')].map((element) => element.outerHTML).join(\"\"),\n );\n }, []);\n\n return (\n <div className={className || undefined}>\n <div style={{ height: 240, resize: \"vertical\", overflow: \"hidden\" }}>\n <iframe\n ref={frame}\n title=\"Custom HTML\"\n sandbox=\"\"\n srcDoc={`<!doctype html><html><head><meta charset=\"utf-8\">${styles}</head><body style=\"margin:0\">${html}</body></html>`}\n style={{ display: \"block\", width: \"100%\", height: \"100%\", border: 0 }}\n />\n </div>\n <p className=\"mt-2 text-sm text-muted-foreground\">\n Scripts don't run in the editor. They run on the published site.\n </p>\n </div>\n );\n}\n"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "blocks/installed/custom-html/html-scripts.tsx",
|
|
20
20
|
"type": "registry:component",
|
|
21
|
-
"content": "\"use client\";\n\nimport { useEffect, useRef, useSyncExternalStore } from \"react\";\n\n/** Scripts that have already run, so a second pass (React's Strict Mode runs effects twice) doesn't run them again. */\nconst ran = new WeakSet<Element>();\n\nconst noSubscription = () => () => {};\n\n/** Runs a script that was put in the page as HTML, which browsers never run on their own. */\nfunction run(original: HTMLScriptElement): Promise<void> {\n const script = document.createElement(\"script\");\n for (const { name, value } of original.attributes) script.setAttribute(name, value);\n script.text = original.text;\n ran.add(script);\n // An external script runs once it has loaded; the next one waits for it, as when the page is first opened.\n const waits = Boolean(original.src) && !original.async && original.type !== \"module\";\n const loaded = waits\n ? new Promise<void>((resolve) => {\n script.addEventListener(\"load\", () => resolve());\n script.addEventListener(\"error\", () => resolve());\n })\n : Promise.resolve();\n if (waits) script.async = false;\n original.replaceWith(script);\n return loaded;\n}\n\n/**\n * HTML with scripts, used as it was written. When the page is first opened\n * the browser runs its scripts; when it's shown without a new page load (moving\n * between pages on a Next.js site), this runs them, in order.\n */\nexport function HtmlWithScripts({ html, className }: { html: string; className: string }) {\n const container = useRef<HTMLDivElement>(null);\n // True when React takes over HTML the browser loaded with the page, whose scripts have already run.\n const hydrating = useSyncExternalStore(\n noSubscription,\n () => false,\n () => true,\n );\n const fromPage = useRef(hydrating);\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: runs the scripts again whenever the HTML changes\n useEffect(() => {\n const scripts = [...(container.current?.querySelectorAll(\"script\") ?? [])].filter((script) => !ran.has(script));\n if (fromPage.current) {\n fromPage.current = false;\n for (const script of scripts) ran.add(script);\n return;\n }\n let cancelled = false;\n (async () => {\n for (const script of scripts) {\n if (cancelled) return;\n await run(script);\n }\n })();\n return () => {\n cancelled = true;\n };\n }, [html]);\n\n return (\n <div\n ref={container}\n className={className || undefined}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: the editor chose to use this HTML as written\n dangerouslySetInnerHTML={{ __html: html }}\n />\n );\n}\n"
|
|
21
|
+
"content": "\"use client\";\n\nimport { useEffect, useRef, useSyncExternalStore } from \"react\";\n\n/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\n\n/** Scripts that have already run, so a second pass (React's Strict Mode runs effects twice) doesn't run them again. */\nconst ran = new WeakSet<Element>();\n\nconst noSubscription = () => () => {};\n\n/** Runs a script that was put in the page as HTML, which browsers never run on their own. */\nfunction run(original: HTMLScriptElement): Promise<void> {\n const script = document.createElement(\"script\");\n for (const { name, value } of original.attributes) script.setAttribute(name, value);\n script.text = original.text;\n ran.add(script);\n // An external script runs once it has loaded; the next one waits for it, as when the page is first opened.\n const waits = Boolean(original.src) && !original.async && original.type !== \"module\";\n const loaded = waits\n ? new Promise<void>((resolve) => {\n script.addEventListener(\"load\", () => resolve());\n script.addEventListener(\"error\", () => resolve());\n })\n : Promise.resolve();\n if (waits) script.async = false;\n original.replaceWith(script);\n return loaded;\n}\n\n/**\n * HTML with scripts, used as it was written. When the page is first opened\n * the browser runs its scripts; when it's shown without a new page load (moving\n * between pages on a Next.js site), this runs them, in order.\n */\nexport function HtmlWithScripts({ html, className }: { html: string; className: string }) {\n const container = useRef<HTMLDivElement>(null);\n // True when React takes over HTML the browser loaded with the page, whose scripts have already run.\n const hydrating = useSyncExternalStore(\n noSubscription,\n () => false,\n () => true,\n );\n const fromPage = useRef(hydrating);\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: runs the scripts again whenever the HTML changes\n useEffect(() => {\n const scripts = [...(container.current?.querySelectorAll(\"script\") ?? [])].filter((script) => !ran.has(script));\n if (fromPage.current) {\n fromPage.current = false;\n for (const script of scripts) ran.add(script);\n return;\n }\n let cancelled = false;\n (async () => {\n for (const script of scripts) {\n if (cancelled) return;\n await run(script);\n }\n })();\n return () => {\n cancelled = true;\n };\n }, [html]);\n\n return (\n <div\n ref={container}\n className={className || undefined}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: the editor chose to use this HTML as written\n dangerouslySetInnerHTML={{ __html: html }}\n />\n );\n}\n"
|
|
22
22
|
}
|
|
23
23
|
],
|
|
24
24
|
"meta": {
|
|
25
25
|
"goodfellow": {
|
|
26
26
|
"category": "Advanced",
|
|
27
|
-
"version": "0.
|
|
27
|
+
"version": "0.8.0"
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
30
|
"dependencies": []
|
package/r/feed-list.json
ADDED
|
@@ -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.8.0"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": []
|
|
21
|
+
}
|
package/r/logos.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "logos",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Logos",
|
|
6
|
+
"description": "A row of logos, such as sponsors or partners, each linking to its site.",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "blocks/installed/logos/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\";\n\ninterface Logo {\n image: string;\n name: string;\n link: string;\n}\n\nexport interface LogosProps {\n heading: string;\n logos: Logo[];\n size: \"small\" | \"medium\" | \"large\";\n /** Whether the logos are gray until the pointer is over them, so their colors don't clash with the site's. */\n gray: boolean;\n className: string;\n}\n\nconst sizeClasses: Record<LogosProps[\"size\"], string> = {\n small: \"h-8\",\n medium: \"h-12\",\n large: \"h-16\",\n};\n\nfunction LogosView({ heading, logos, size, gray, className, puck }: LogosProps & { puck?: { isEditing?: boolean } }) {\n const part = useParts();\n const shown = (logos ?? []).filter((logo) => logo.image);\n if (shown.length === 0) {\n // Only editors see why there's nothing; visitors see nothing.\n if (!puck?.isEditing) return null;\n return (\n <div className={part(\"gf-logos\", \"rounded-lg bg-muted p-6 text-center text-muted-foreground\", className)}>\n Add logos from the media library.\n </div>\n );\n }\n const imageClass = part(\"gf-logos-image\", [\n \"w-auto max-w-40 object-contain\",\n sizeClasses[size] ?? sizeClasses.medium,\n gray && \"opacity-70 grayscale transition hover:opacity-100 hover:grayscale-0\",\n ]);\n return (\n <section className={part(\"gf-logos\", \"w-full\", className)}>\n {heading && (\n <h2 className={part(\"gf-logos-heading\", \"mb-6 text-center font-heading text-2xl font-semibold\")}>{heading}</h2>\n )}\n <ul className={part(\"gf-logos-list\", \"flex flex-wrap items-center justify-center gap-x-10 gap-y-6\")}>\n {shown.map((logo, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: the same logo can be shown twice\n <li key={index} className={part(\"gf-logos-logo\", \"flex items-center\")}>\n {logo.link ? (\n <SiteLink href={logo.link} className=\"inline-flex\">\n <SiteImage src={logo.image} alt={logo.name} className={imageClass} />\n </SiteLink>\n ) : (\n <SiteImage src={logo.image} alt={logo.name} className={imageClass} />\n )}\n </li>\n ))}\n </ul>\n </section>\n );\n}\n\n/** A row of logos, such as sponsors, partners or the organizations a site belongs to, each linking to its site. */\nconst Logos: ComponentConfig<LogosProps> = withParts(\n {\n label: \"Logos\",\n fields: {\n heading: { type: \"text\", label: \"Heading\", contentEditable: true },\n logos: {\n type: \"array\",\n label: \"Logos\",\n arrayFields: {\n image: mediaField(\"Logo\"),\n name: { type: \"text\", label: \"Whose logo it is, for screen readers\" },\n link: { type: \"text\", label: \"Links to (optional)\" },\n },\n defaultItemProps: { image: \"\", name: \"\", link: \"\" },\n getItemSummary: (logo, index) => logo.name || `Logo ${(index ?? 0) + 1}`,\n },\n size: {\n type: \"radio\",\n label: \"Size\",\n options: [\n { value: \"small\", label: \"Small\" },\n { value: \"medium\", label: \"Medium\" },\n { value: \"large\", label: \"Large\" },\n ],\n },\n gray: {\n type: \"radio\",\n label: \"Colors\",\n options: [\n { value: false, label: \"The logos' own\" },\n { value: true, label: \"Gray until pointed at\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: { heading: \"Our partners\", logos: [], size: \"medium\", gray: false, className: \"\" },\n render: (props) => <LogosView {...props} />,\n },\n {\n \"gf-logos\": \"Logos\",\n \"gf-logos-heading\": \"Heading\",\n \"gf-logos-list\": \"List\",\n \"gf-logos-logo\": \"Logo\",\n \"gf-logos-image\": \"Logo's picture\",\n },\n);\n\nexport default withKeywords(Logos, [\"logos\", \"sponsors\", \"partners\", \"supporters\", \"clients\", \"members\"]);\n"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"meta": {
|
|
15
|
+
"goodfellow": {
|
|
16
|
+
"category": "Page sections",
|
|
17
|
+
"version": "0.8.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 { ConsentFrame, 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(\"&\", \"&\") ?? 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 // On a site that asks visitors before loading other sites' content, Google's map waits until they agree.\n <ConsentFrame\n box\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 boxClassName={className}\n className={part(\"gf-map\", \"w-full rounded-lg border border-border\")}\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.8.0"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": []
|
|
21
|
+
}
|
package/r/numbers.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "numbers",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Numbers",
|
|
6
|
+
"description": "A few numbers worth knowing, such as families served or years open, each with what it counts.",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "blocks/installed/numbers/block.tsx",
|
|
10
|
+
"type": "registry:block",
|
|
11
|
+
"content": "import { classNameField, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\n\ninterface NumberItem {\n value: string;\n label: string;\n text: string;\n}\n\nexport interface NumbersProps {\n heading: string;\n items: NumberItem[];\n look: \"plain\" | \"boxes\";\n className: string;\n}\n\nconst lookClasses: Record<NumbersProps[\"look\"], string> = {\n plain: \"\",\n boxes: \"rounded-lg border border-border bg-card p-6 text-card-foreground\",\n};\n\n/** A few numbers worth knowing, such as families served or years open, each with what it counts. */\nconst NumbersBlock: ComponentConfig<NumbersProps> = withParts(\n {\n label: \"Numbers\",\n fields: {\n heading: { type: \"text\", label: \"Heading\", contentEditable: true },\n items: {\n type: \"array\",\n label: \"Numbers\",\n arrayFields: {\n value: { type: \"text\", label: \"Number, such as 1,200 or 98%\" },\n label: { type: \"text\", label: \"What it counts\" },\n text: { type: \"text\", label: \"More words under it (optional)\" },\n },\n defaultItemProps: { value: \"100\", label: \"What it counts\", text: \"\" },\n getItemSummary: (item) => [item.value, item.label].filter(Boolean).join(\" \") || \"Number\",\n },\n look: {\n type: \"radio\",\n label: \"Look\",\n options: [\n { value: \"plain\", label: \"Plain\" },\n { value: \"boxes\", label: \"In boxes\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: {\n heading: \"\",\n items: [\n { value: \"150\", label: \"Years in the town\", text: \"\" },\n { value: \"1,200\", label: \"Families\", text: \"\" },\n { value: \"40\", label: \"Groups and ministries\", text: \"\" },\n ],\n look: \"plain\",\n className: \"\",\n },\n render: ({ heading, items, look, className }) => {\n const part = useParts();\n return (\n <section className={part(\"gf-numbers\", \"w-full\", className)}>\n {heading && (\n <h2 className={part(\"gf-numbers-heading\", \"mb-8 text-center font-heading text-3xl font-semibold\")}>\n {heading}\n </h2>\n )}\n {/* What each number counts is its term, so screen readers read \"Families: 1,200\". */}\n <dl className={part(\"gf-numbers-list\", \"grid gap-6 sm:grid-cols-[repeat(auto-fit,minmax(12rem,1fr))]\")}>\n {(items ?? []).map((item, index) => (\n <div\n // biome-ignore lint/suspicious/noArrayIndexKey: numbers can repeat\n key={index}\n className={part(\"gf-numbers-item\", [\n \"flex flex-col-reverse items-center gap-1 text-center\",\n lookClasses[look] ?? \"\",\n ])}\n >\n <dt className={part(\"gf-numbers-label\", \"font-medium\")}>\n {item.label}\n {item.text && (\n <span className={part(\"gf-numbers-text\", \"mt-1 block text-sm font-normal text-muted-foreground\")}>\n {item.text}\n </span>\n )}\n </dt>\n <dd className={part(\"gf-numbers-value\", \"font-heading text-4xl font-bold text-primary\")}>\n {item.value}\n </dd>\n </div>\n ))}\n </dl>\n </section>\n );\n },\n },\n {\n \"gf-numbers\": \"Numbers\",\n \"gf-numbers-heading\": \"Heading\",\n \"gf-numbers-list\": \"List\",\n \"gf-numbers-item\": \"Number with its words\",\n \"gf-numbers-value\": \"Number\",\n \"gf-numbers-label\": \"What it counts\",\n \"gf-numbers-text\": \"More words\",\n },\n);\n\nexport default withKeywords(NumbersBlock, [\"numbers\", \"statistics\", \"stats\", \"facts\", \"figures\", \"counters\"]);\n"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"meta": {
|
|
15
|
+
"goodfellow": {
|
|
16
|
+
"category": "Page sections",
|
|
17
|
+
"version": "0.8.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.8.0"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": []
|
|
21
|
+
}
|
package/r/people.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "people",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "People",
|
|
6
|
+
"description": "The people of an organization, such as its staff or council, each with a photo, their name, role and a few words.",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "blocks/installed/people/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\";\n\ninterface Person {\n photo: string;\n name: string;\n role: string;\n bio: string;\n email: string;\n link: string;\n}\n\nexport interface PeopleProps {\n heading: string;\n people: Person[];\n columns: \"2\" | \"3\" | \"4\";\n photos: \"round\" | \"square\";\n className: string;\n}\n\nconst columnClasses: Record<PeopleProps[\"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\nconst photoClasses: Record<PeopleProps[\"photos\"], string> = {\n round: \"rounded-full\",\n square: \"rounded-lg\",\n};\n\n/** The people of an organization, such as its staff or council: each with a photo, their name, role and a few words. */\nconst People: ComponentConfig<PeopleProps> = withParts(\n {\n label: \"People\",\n fields: {\n heading: { type: \"text\", label: \"Heading\", contentEditable: true },\n people: {\n type: \"array\",\n label: \"People\",\n arrayFields: {\n photo: mediaField(\"Photo\"),\n name: { type: \"text\", label: \"Name\" },\n role: { type: \"text\", label: \"Role, such as Parish secretary\" },\n bio: { type: \"textarea\", label: \"A few words about them\" },\n email: { type: \"text\", label: \"Email address (optional)\" },\n link: { type: \"text\", label: \"Links to, such as their page (optional)\" },\n },\n defaultItemProps: { photo: \"\", name: \"Name\", role: \"Role\", bio: \"\", email: \"\", link: \"\" },\n getItemSummary: (person) => person.name || \"Person\",\n },\n columns: {\n type: \"radio\",\n label: \"Across the page\",\n options: [\n { value: \"2\", label: \"2\" },\n { value: \"3\", label: \"3\" },\n { value: \"4\", label: \"4\" },\n ],\n },\n photos: {\n type: \"radio\",\n label: \"Photos\",\n options: [\n { value: \"round\", label: \"Round\" },\n { value: \"square\", label: \"Square\" },\n ],\n },\n className: classNameField,\n },\n defaultProps: {\n heading: \"Our team\",\n people: [\n { photo: \"\", name: \"Name\", role: \"Role\", bio: \"\", email: \"\", link: \"\" },\n { photo: \"\", name: \"Name\", role: \"Role\", bio: \"\", email: \"\", link: \"\" },\n { photo: \"\", name: \"Name\", role: \"Role\", bio: \"\", email: \"\", link: \"\" },\n ],\n columns: \"3\",\n photos: \"round\",\n className: \"\",\n },\n render: ({ heading, people, columns, photos, className }) => {\n const part = useParts();\n // A person's name is a heading one level under the block's, or level 2 without one.\n const Name = heading ? \"h3\" : \"h2\";\n return (\n <section className={part(\"gf-people\", \"w-full\", className)}>\n {heading && (\n <h2 className={part(\"gf-people-heading\", \"mb-8 text-center font-heading text-3xl font-semibold\")}>\n {heading}\n </h2>\n )}\n <ul className={part(\"gf-people-list\", [\"grid gap-8\", columnClasses[columns] ?? columnClasses[\"3\"]])}>\n {(people ?? []).map((person, index) => (\n <li\n // biome-ignore lint/suspicious/noArrayIndexKey: people can share a name\n key={index}\n className={part(\"gf-people-person\", \"flex flex-col items-center gap-2 text-center\")}\n >\n {person.photo ? (\n // The name is under it, so the photo needs no words of its own.\n <SiteImage\n src={person.photo}\n alt=\"\"\n aria-hidden=\"true\"\n className={part(\"gf-people-photo\", [\n \"mb-2 size-32 object-cover\",\n photoClasses[photos] ?? photoClasses.round,\n ])}\n />\n ) : (\n <div\n aria-hidden=\"true\"\n className={part(\"gf-people-photo\", [\n \"mb-2 size-32 bg-muted\",\n photoClasses[photos] ?? photoClasses.round,\n ])}\n />\n )}\n <Name className={part(\"gf-people-name\", \"font-heading text-lg font-semibold\")}>\n {person.link ? (\n <SiteLink href={person.link} className=\"hover:underline\">\n {person.name}\n </SiteLink>\n ) : (\n person.name\n )}\n </Name>\n {person.role && (\n <p className={part(\"gf-people-role\", \"text-sm font-medium text-primary\")}>{person.role}</p>\n )}\n {person.bio && <p className={part(\"gf-people-bio\", \"text-muted-foreground\")}>{person.bio}</p>}\n {person.email && (\n <SiteLink\n href={`mailto:${person.email.trim()}`}\n className={part(\"gf-people-email\", \"text-sm text-primary underline underline-offset-4\")}\n >\n {person.email.trim()}\n </SiteLink>\n )}\n </li>\n ))}\n </ul>\n </section>\n );\n },\n },\n {\n \"gf-people\": \"People\",\n \"gf-people-heading\": \"Heading\",\n \"gf-people-list\": \"List\",\n \"gf-people-person\": \"Person\",\n \"gf-people-photo\": \"Photo\",\n \"gf-people-name\": \"Name\",\n \"gf-people-role\": \"Role\",\n \"gf-people-bio\": \"A few words\",\n \"gf-people-email\": \"Email address\",\n },\n);\n\nexport default withKeywords(People, [\n \"team\",\n \"staff\",\n \"people\",\n \"board\",\n \"council\",\n \"clergy\",\n \"volunteers\",\n \"about us\",\n]);\n"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"meta": {
|
|
15
|
+
"goodfellow": {
|
|
16
|
+
"category": "Page sections",
|
|
17
|
+
"version": "0.8.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.
|
|
24
|
+
"version": "0.8.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.
|
|
48
|
+
"version": "0.8.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.
|
|
72
|
+
"version": "0.8.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.
|
|
95
|
+
"version": "0.8.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.
|
|
122
|
+
"version": "0.8.0"
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
},
|
|
@@ -144,10 +144,250 @@
|
|
|
144
144
|
"goodfellow": {
|
|
145
145
|
"category": "Page sections",
|
|
146
146
|
"recommended": true,
|
|
147
|
-
"version": "0.
|
|
147
|
+
"version": "0.8.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.8.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.8.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.8.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.8.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.8.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.8.0"
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
"dependencies": []
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"name": "people",
|
|
271
|
+
"type": "registry:block",
|
|
272
|
+
"title": "People",
|
|
273
|
+
"description": "The people of an organization, such as its staff or council, each with a photo, their name, role and a few words.",
|
|
274
|
+
"files": [
|
|
275
|
+
{
|
|
276
|
+
"path": "blocks/installed/people/block.tsx",
|
|
277
|
+
"type": "registry:block"
|
|
278
|
+
}
|
|
279
|
+
],
|
|
280
|
+
"meta": {
|
|
281
|
+
"goodfellow": {
|
|
282
|
+
"category": "Page sections",
|
|
283
|
+
"version": "0.8.0"
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
"dependencies": []
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
"name": "logos",
|
|
290
|
+
"type": "registry:block",
|
|
291
|
+
"title": "Logos",
|
|
292
|
+
"description": "A row of logos, such as sponsors or partners, each linking to its site.",
|
|
293
|
+
"files": [
|
|
294
|
+
{
|
|
295
|
+
"path": "blocks/installed/logos/block.tsx",
|
|
296
|
+
"type": "registry:block"
|
|
297
|
+
}
|
|
298
|
+
],
|
|
299
|
+
"meta": {
|
|
300
|
+
"goodfellow": {
|
|
301
|
+
"category": "Page sections",
|
|
302
|
+
"version": "0.8.0"
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
"dependencies": []
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
"name": "numbers",
|
|
309
|
+
"type": "registry:block",
|
|
310
|
+
"title": "Numbers",
|
|
311
|
+
"description": "A few numbers worth knowing, such as families served or years open, each with what it counts.",
|
|
312
|
+
"files": [
|
|
313
|
+
{
|
|
314
|
+
"path": "blocks/installed/numbers/block.tsx",
|
|
315
|
+
"type": "registry:block"
|
|
316
|
+
}
|
|
317
|
+
],
|
|
318
|
+
"meta": {
|
|
319
|
+
"goodfellow": {
|
|
320
|
+
"category": "Page sections",
|
|
321
|
+
"version": "0.8.0"
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
"dependencies": []
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
"name": "timeline",
|
|
328
|
+
"type": "registry:block",
|
|
329
|
+
"title": "Timeline",
|
|
330
|
+
"description": "Moments in order down a line, such as an organization's history or the steps of a plan.",
|
|
331
|
+
"files": [
|
|
332
|
+
{
|
|
333
|
+
"path": "blocks/installed/timeline/block.tsx",
|
|
334
|
+
"type": "registry:block"
|
|
335
|
+
}
|
|
336
|
+
],
|
|
337
|
+
"meta": {
|
|
338
|
+
"goodfellow": {
|
|
339
|
+
"category": "Page sections",
|
|
340
|
+
"version": "0.8.0"
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
"dependencies": []
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
"name": "countdown",
|
|
347
|
+
"type": "registry:block",
|
|
348
|
+
"title": "Countdown",
|
|
349
|
+
"description": "The days, hours, minutes and seconds until a moment, in the site's time zone.",
|
|
350
|
+
"files": [
|
|
351
|
+
{
|
|
352
|
+
"path": "blocks/installed/countdown/block.tsx",
|
|
353
|
+
"type": "registry:block"
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
"path": "blocks/installed/countdown/countdown-clock.tsx",
|
|
357
|
+
"type": "registry:component"
|
|
358
|
+
}
|
|
359
|
+
],
|
|
360
|
+
"meta": {
|
|
361
|
+
"goodfellow": {
|
|
362
|
+
"category": "Interactive",
|
|
363
|
+
"version": "0.8.0"
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
"dependencies": []
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"name": "comments",
|
|
370
|
+
"type": "registry:block",
|
|
371
|
+
"title": "Comments",
|
|
372
|
+
"description": "Comments on the page through Giscus, kept as GitHub Discussions; visitors sign in with GitHub to comment.",
|
|
373
|
+
"files": [
|
|
374
|
+
{
|
|
375
|
+
"path": "blocks/installed/comments/block.tsx",
|
|
376
|
+
"type": "registry:block"
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
"path": "blocks/installed/comments/giscus-frame.tsx",
|
|
380
|
+
"type": "registry:component"
|
|
381
|
+
}
|
|
382
|
+
],
|
|
383
|
+
"meta": {
|
|
384
|
+
"goodfellow": {
|
|
385
|
+
"category": "Interactive",
|
|
386
|
+
"version": "0.8.0"
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
"dependencies": []
|
|
390
|
+
},
|
|
151
391
|
{
|
|
152
392
|
"name": "custom-html",
|
|
153
393
|
"type": "registry:block",
|
|
@@ -170,7 +410,7 @@
|
|
|
170
410
|
"meta": {
|
|
171
411
|
"goodfellow": {
|
|
172
412
|
"category": "Advanced",
|
|
173
|
-
"version": "0.
|
|
413
|
+
"version": "0.8.0"
|
|
174
414
|
}
|
|
175
415
|
},
|
|
176
416
|
"dependencies": []
|
|
@@ -197,7 +437,7 @@
|
|
|
197
437
|
"goodfellow": {
|
|
198
438
|
"category": "Interactive",
|
|
199
439
|
"recommended": true,
|
|
200
|
-
"version": "0.
|
|
440
|
+
"version": "0.8.0"
|
|
201
441
|
}
|
|
202
442
|
},
|
|
203
443
|
"dependencies": []
|
|
@@ -224,7 +464,7 @@
|
|
|
224
464
|
"goodfellow": {
|
|
225
465
|
"category": "Interactive",
|
|
226
466
|
"recommended": true,
|
|
227
|
-
"version": "0.
|
|
467
|
+
"version": "0.8.0"
|
|
228
468
|
}
|
|
229
469
|
},
|
|
230
470
|
"dependencies": []
|
|
@@ -250,7 +490,7 @@
|
|
|
250
490
|
"meta": {
|
|
251
491
|
"goodfellow": {
|
|
252
492
|
"category": "Interactive",
|
|
253
|
-
"version": "0.
|
|
493
|
+
"version": "0.8.0"
|
|
254
494
|
}
|
|
255
495
|
},
|
|
256
496
|
"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.8.0"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": []
|
|
21
|
+
}
|
package/r/shadcn-cards.json
CHANGED
package/r/shadcn-carousel.json
CHANGED
|
@@ -16,13 +16,13 @@
|
|
|
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, 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"
|
|
19
|
+
"content": "\"use client\";\n\nimport { SiteImage } from \"@goodfellow-cms/react\";\nimport { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from \"@/components/ui/carousel\";\n\n/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\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.
|
|
25
|
+
"version": "0.8.0"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"dependencies": []
|
package/r/shadcn-faq.json
CHANGED
|
@@ -16,14 +16,14 @@
|
|
|
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/**\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"
|
|
19
|
+
"content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from \"@/components/ui/accordion\";\n\n/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\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.
|
|
26
|
+
"version": "0.8.0"
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"dependencies": []
|
package/r/shadcn-hero.json
CHANGED
package/r/shadcn-notice.json
CHANGED
package/r/shadcn-pricing.json
CHANGED
package/r/shadcn-tabs.json
CHANGED
|
@@ -16,14 +16,14 @@
|
|
|
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, 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"
|
|
19
|
+
"content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\n\n/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\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.
|
|
26
|
+
"version": "0.8.0"
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"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/** Runs with Preact as well as React, so sites whose Client Components all do load Preact instead (see Islands). */\nexport const worksWithPreact = true;\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.8.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.8.0"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": []
|
|
21
|
+
}
|
package/r/timeline.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "timeline",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Timeline",
|
|
6
|
+
"description": "Moments in order down a line, such as an organization's history or the steps of a plan.",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "blocks/installed/timeline/block.tsx",
|
|
10
|
+
"type": "registry:block",
|
|
11
|
+
"content": "import { classNameField, useParts, withKeywords, withParts } from \"@goodfellow-cms/react\";\nimport type { ComponentConfig } from \"@puckeditor/core\";\n\ninterface Moment {\n when: string;\n title: string;\n text: string;\n}\n\nexport interface TimelineProps {\n heading: string;\n moments: Moment[];\n className: string;\n}\n\n/** Moments in order down a line, such as an organization's history or the steps of a plan. */\nconst Timeline: ComponentConfig<TimelineProps> = withParts(\n {\n label: \"Timeline\",\n fields: {\n heading: { type: \"text\", label: \"Heading\", contentEditable: true },\n moments: {\n type: \"array\",\n label: \"Moments\",\n arrayFields: {\n when: { type: \"text\", label: \"When, such as 1902 or Spring 2027\" },\n title: { type: \"text\", label: \"Title\" },\n text: { type: \"textarea\", label: \"What happened\" },\n },\n defaultItemProps: { when: \"\", title: \"What happened\", text: \"\" },\n getItemSummary: (moment) => [moment.when, moment.title].filter(Boolean).join(\": \") || \"Moment\",\n },\n className: classNameField,\n },\n defaultProps: {\n heading: \"Our history\",\n moments: [\n { when: \"1902\", title: \"The first church is built\", text: \"\" },\n { when: \"1958\", title: \"The school opens\", text: \"\" },\n { when: \"2002\", title: \"A hundred years\", text: \"\" },\n ],\n className: \"\",\n },\n render: ({ heading, moments, className }) => {\n const part = useParts();\n const Title = heading ? \"h3\" : \"h2\";\n return (\n <section className={part(\"gf-timeline\", \"w-full\", className)}>\n {heading && (\n <h2 className={part(\"gf-timeline-heading\", \"mb-8 font-heading text-3xl font-semibold\")}>{heading}</h2>\n )}\n <ol className={part(\"gf-timeline-list\", \"relative space-y-8 border-l-2 border-border pl-8\")}>\n {(moments ?? []).map((moment, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: moments can share a title\n <li key={index} className={part(\"gf-timeline-moment\", \"relative\")}>\n <span\n aria-hidden=\"true\"\n className={part(\n \"gf-timeline-dot\",\n \"absolute top-1.5 -left-[2.5625rem] size-4 rounded-full border-2 border-background bg-primary\",\n )}\n />\n {moment.when && (\n <p className={part(\"gf-timeline-when\", \"text-sm font-semibold text-primary\")}>{moment.when}</p>\n )}\n <Title className={part(\"gf-timeline-title\", \"font-heading text-xl font-semibold\")}>\n {moment.title}\n </Title>\n {moment.text && (\n <p className={part(\"gf-timeline-text\", \"mt-1 whitespace-pre-line text-muted-foreground\")}>\n {moment.text}\n </p>\n )}\n </li>\n ))}\n </ol>\n </section>\n );\n },\n },\n {\n \"gf-timeline\": \"Timeline\",\n \"gf-timeline-heading\": \"Heading\",\n \"gf-timeline-list\": \"Line\",\n \"gf-timeline-moment\": \"Moment\",\n \"gf-timeline-dot\": \"Dot\",\n \"gf-timeline-when\": \"When\",\n \"gf-timeline-title\": \"Title\",\n \"gf-timeline-text\": \"What happened\",\n },\n);\n\nexport default withKeywords(Timeline, [\"timeline\", \"history\", \"milestones\", \"steps\", \"events\", \"journey\"]);\n"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"meta": {
|
|
15
|
+
"goodfellow": {
|
|
16
|
+
"category": "Page sections",
|
|
17
|
+
"version": "0.8.0"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": []
|
|
21
|
+
}
|