├── .env ├── .env.local.example ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── app ├── [category] │ ├── [article] │ │ ├── _fragments.ts │ │ ├── page.tsx │ │ └── styles.module.scss │ ├── _fragments.ts │ └── page.tsx ├── _components │ ├── analytics │ │ └── page-view.tsx │ ├── article-link.tsx │ ├── article │ │ ├── image-with-zoom.tsx │ │ └── video.tsx │ ├── articles-list │ │ ├── articles-list.module.scss │ │ └── index.tsx │ ├── breadcrumb.tsx │ ├── callout.tsx │ ├── category-card.tsx │ ├── feedback.tsx │ ├── footer │ │ └── index.tsx │ ├── gradient-circle.tsx │ ├── header │ │ ├── header.module.scss │ │ ├── index.tsx │ │ └── mobile-navbar.tsx │ ├── icon.tsx │ ├── inline-icon.tsx │ ├── intercom.tsx │ ├── search │ │ ├── index.tsx │ │ └── search.module.scss │ ├── theme-provider │ │ ├── client.tsx │ │ └── server.tsx │ ├── theme-switcher │ │ ├── index.tsx │ │ └── theme-switcher.module.scss │ └── toc │ │ ├── index.tsx │ │ └── toc.module.scss ├── _fragments.ts ├── globals.css ├── icons.tsx ├── layout.tsx ├── normalize.css ├── page.tsx └── sitemap.ts ├── hooks ├── use-has-rendered.ts └── use-toggle-state.ts ├── lib ├── basehub-helpers │ └── util.ts └── constants │ └── index.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── public └── basehub.svg └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | # Created by Vercel CLI 2 | VERCEL="1" 3 | VERCEL_ENV="development" 4 | VERCEL_URL="" 5 | VERCEL_GIT_PROVIDER="" 6 | VERCEL_GIT_PREVIOUS_SHA="" 7 | VERCEL_GIT_REPO_SLUG="" 8 | VERCEL_GIT_REPO_OWNER="" 9 | VERCEL_GIT_REPO_ID="" 10 | VERCEL_GIT_COMMIT_REF="" 11 | VERCEL_GIT_COMMIT_SHA="" 12 | VERCEL_GIT_COMMIT_MESSAGE="" 13 | VERCEL_GIT_COMMIT_AUTHOR_LOGIN="" 14 | VERCEL_GIT_COMMIT_AUTHOR_NAME="" 15 | VERCEL_GIT_PULL_REQUEST_ID="" 16 | NEXT_PUBLIC_INTERCOM_APP_ID="d8v8a4l7" 17 | BASEHUB_OUTPUT=".basehub" 18 | BASEHUB_TOKEN="bshb_pk_fmqdzy1k8x1kv0246r1m4jqhukapgu53w1gk61njumo2ex8d6sncupqa4cdehq2g" 19 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | BASEHUB_TOKEN="" 2 | BASEHUB_OUTPUT=".basehub" -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // const { resolve } = require('node:path') 2 | 3 | // const project = resolve(process.cwd(), 'tsconfig.json') 4 | 5 | /* 6 | * This is a custom ESLint configuration for use with 7 | * Next.js apps. 8 | * 9 | * This config extends the Vercel Engineering Style Guide. 10 | * For more information, see https://github.com/vercel/style-guide 11 | * 12 | */ 13 | 14 | module.exports = { 15 | extends: [ 16 | 'next', 17 | "next/core-web-vitals", 18 | 'eslint:recommended', 19 | 'plugin:import/recommended', 20 | 'plugin:import/typescript', 21 | 'plugin:react/recommended', 22 | 'plugin:@typescript-eslint/recommended', 23 | ], 24 | // parserOptions: { 25 | // project, 26 | // }, 27 | globals: { 28 | React: true, 29 | JSX: true, 30 | }, 31 | plugins: ['react', 'react-hooks', 'simple-import-sort', '@typescript-eslint'], 32 | parser: '@typescript-eslint/parser', 33 | settings: { 34 | 'import/parsers': { 35 | '@typescript-eslint/parser': ['.ts', '.tsx'], 36 | }, 37 | 'import/resolver': { 38 | typescript: { 39 | project: ['packages/*/tsconfig.json', 'apps/*/tsconfig.json'], 40 | }, 41 | }, 42 | react: { 43 | version: 'detect', 44 | }, 45 | }, 46 | ignorePatterns: ['node_modules/', 'dist/'], 47 | // add rules configurations here 48 | rules: { 49 | 'no-extra-boolean-cast': 'off', 50 | '@typescript-eslint/no-explicit-any': 'warn', 51 | 'react/no-unknown-property': 'off', 52 | 'react/prop-types': 'off', 53 | 'import/no-default-export': 'off', 54 | 'react/display-name': 'off', 55 | 'react/no-unescaped-entities': 0, 56 | curly: ['error', 'multi-line'], 57 | 'react/jsx-no-target-blank': [2, { allowReferrer: true }], 58 | 'react-hooks/exhaustive-deps': [ 59 | 'warn', 60 | { 61 | additionalHooks: 62 | '(useIsomorphicLayoutEffect|useDeepCompareMemo|useIsoLayoutEffect|useDebouncedCallback|useThrottledCallback|useGsapContext)', 63 | }, 64 | ], 65 | '@typescript-eslint/no-unused-vars': [ 66 | 2, 67 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 68 | ], 69 | 'no-console': [1, { allow: ['warn', 'error'] }], 70 | '@typescript-eslint/ban-ts-comment': 0, 71 | 'prefer-const': [ 72 | 'error', 73 | { 74 | destructuring: 'all', 75 | ignoreReadBeforeAssign: false, 76 | }, 77 | ], 78 | // '@typescript-eslint/no-floating-promises': 'error', 79 | // '@typescript-eslint/await-thenable': 'error', 80 | // '@typescript-eslint/no-misused-promises': 'error', 81 | }, 82 | env: { 83 | es6: true, 84 | browser: true, 85 | node: true, 86 | }, 87 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # basehub 39 | .basehub -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "always", 5 | "tabWidth": 2, 6 | "printWidth": 80, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Open Graph, Homepage (2) (1)](https://github.com/basehub-ai/help-center-template/assets/40034115/c93742ab-8d86-4c3b-873a-14a781b56807) 2 | 3 | [BaseHub Templates](https://basehub.com/templates) are production-ready website templates, powered by BaseHub. 4 | 5 | # Help Center Template 6 | 7 | [![Use template](https://basehub.com/template-button.svg)](https://basehub.com/basehub/help-center) 8 | 9 | Fully featured help center website. 10 | 11 | - 🔸 Perfect for kickstarting your own help center 12 | - 🔸 Fully editable from BaseHub 13 | - 🔸 Comes with Search, Dark/Light Mode, Analytics, and more 14 | - 🔸 Requires just a BaseHub account and a deployment platform—no other service 15 | 16 | ## Stack 17 | 18 | - Next.js 19 | - BaseHub 20 | - Radix Themes 21 | 22 | ## One Click Deployment 23 | 24 | [![Deploy with Vercel](https://vercel.com/button)]([https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fbasehub-ai%2Fnextjs-help-center&integration-ids=oac_xwgyJe0UwFLtsKIvIScYh0rY&env=&demo-url=https%3A%2F%2Fhelp.basehub.com&demo-image=https%3A%2F%2Fbasehub.earth%2F4d1fdd52%2Frs9ELWGrdN6BQSKDttrzw%2Freadme-1.png&external-id=mly6i259eym3jkyvq6txyciu%3Abc-k906HuZC6AF3-7c0L7) 25 | 26 | _You can deploy this anywhere. Vercel works nicely and with one click._ 27 | 28 | ## Local Development 29 | 30 | **Install dependencies** 31 | ```bash 32 | pnpm i 33 | ``` 34 | 35 | **Add your BASEHUB_TOKEN to `.env.local`** 36 | ```txt 37 | # .env.local 38 | 39 | BASEHUB_TOKEN="" 40 | ``` 41 | 42 | **Start the dev server** 43 | ```bash 44 | pnpm dev 45 | ``` 46 | -------------------------------------------------------------------------------- /app/[category]/[article]/_fragments.ts: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from '@/.basehub' 2 | 3 | export const Callout = fragmentOn('CalloutComponent', { 4 | _id: true, 5 | content: { 6 | json: { 7 | content: true, 8 | }, 9 | }, 10 | type: true, 11 | }) 12 | 13 | export type Callout = fragmentOn.infer 14 | 15 | export const InlineIcon = fragmentOn('InlineIconComponent', { 16 | _id: true, 17 | name: true, 18 | tooltip: true, 19 | }) 20 | 21 | export type InlineIcon = fragmentOn.infer 22 | -------------------------------------------------------------------------------- /app/[category]/[article]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Pump } from '@/.basehub/react-pump' 2 | import { 3 | Avatar, 4 | Blockquote, 5 | Box, 6 | Code, 7 | Container, 8 | Em, 9 | Grid, 10 | Heading, 11 | Link, 12 | Separator, 13 | Table, 14 | Text, 15 | } from '@radix-ui/themes' 16 | import NextLink from 'next/link' 17 | import { basehub } from '@/.basehub' 18 | import { RichText } from 'basehub/react-rich-text' 19 | import { notFound } from 'next/navigation' 20 | import { CategoryMeta } from '@/app/_components/category-card' 21 | import { ArticleMeta } from '@/app/_components/article-link' 22 | import { 23 | Callout as CalloutFragment, 24 | InlineIcon as InlineIconFragment, 25 | } from './_fragments' 26 | import { Callout } from '@/app/_components/callout' 27 | import { Fragment } from 'react' 28 | import { ArticlesList } from '@/app/_components/articles-list' 29 | import { TOCRenderer } from '@/app/_components/toc' 30 | import { Breadcrumb } from '@/app/_components/breadcrumb' 31 | import { format } from 'date-fns' 32 | import s from './styles.module.scss' 33 | import { Feedback } from '@/app/_components/feedback' 34 | import { InlineIcon } from '@/app/_components/inline-icon' 35 | import { Video } from '@/app/_components/article/video' 36 | import { ImageWithZoom } from '@/app/_components/article/image-with-zoom' 37 | import { draftMode } from 'next/headers' 38 | import type { Metadata } from 'next/types' 39 | import { MetadataFragment } from '@/app/_fragments' 40 | import { PageView } from '@/app/_components/analytics/page-view' 41 | import { getArticleHrefFromSlugPath } from '@/lib/basehub-helpers/util' 42 | import { CodeBlock, createCssVariablesTheme } from 'basehub/react-code-block' 43 | 44 | export const generateStaticParams = async () => { 45 | const data = await basehub({ next: { revalidate: 60 } }).query({ 46 | index: { 47 | categoriesSection: { 48 | title: true, 49 | categories: { 50 | items: { 51 | ...CategoryMeta, 52 | articles: { 53 | items: ArticleMeta, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | }) 60 | 61 | return data.index.categoriesSection.categories.items 62 | .map((category) => { 63 | return category.articles.items.map((article) => { 64 | return { category: category._slug, article: article._slug } 65 | }) 66 | }) 67 | .flat() 68 | } 69 | 70 | export const generateMetadata = async ({ 71 | params: _params, 72 | }: { 73 | params: Promise<{ category: string; article: string }> 74 | }): Promise => { 75 | const params = await _params 76 | const data = await basehub({ 77 | next: { revalidate: 60 }, 78 | draft: (await draftMode()).isEnabled, 79 | }).query({ 80 | settings: { 81 | metadata: MetadataFragment, 82 | }, 83 | index: { 84 | categoriesSection: { 85 | title: true, 86 | categories: { 87 | __args: { 88 | first: 1, 89 | filter: { _sys_slug: { eq: params.category } }, 90 | }, 91 | items: { 92 | ...CategoryMeta, 93 | articles: { 94 | __args: { 95 | first: 1, 96 | filter: { _sys_slug: { eq: params.article } }, 97 | }, 98 | items: { 99 | ...ArticleMeta, 100 | ogImage: { 101 | url: true, 102 | }, 103 | }, 104 | }, 105 | }, 106 | }, 107 | }, 108 | }, 109 | }) 110 | 111 | const category = data.index.categoriesSection.categories.items[0] 112 | if (!category) return {} 113 | const siteName = data.settings.metadata.title 114 | 115 | const article = category.articles.items[0] 116 | if (!article) return notFound() 117 | const { _title, excerpt } = article 118 | 119 | const title = { 120 | absolute: `${_title} - ${category._title} | ${siteName}`, 121 | } 122 | const description = !excerpt 123 | ? undefined 124 | : excerpt.length > 150 125 | ? excerpt.slice(0, 147) + '...' 126 | : excerpt 127 | 128 | const images = [ 129 | { 130 | url: article.ogImage.url, 131 | width: 1200, 132 | height: 630, 133 | }, 134 | ] 135 | 136 | return { 137 | title, 138 | description, 139 | icons: { 140 | icon: data.settings.metadata.icon.url, 141 | shortcut: data.settings.metadata.icon.url, 142 | apple: data.settings.metadata.icon.url, 143 | }, 144 | openGraph: { 145 | title, 146 | description, 147 | siteName, 148 | locale: 'en-US', 149 | type: 'website', 150 | url: `/${params.category}/${params.article}`, 151 | images, 152 | }, 153 | } 154 | } 155 | 156 | export default async function ArticlePage({ 157 | params: _params, 158 | }: { 159 | params: Promise<{ category: string; article: string }> 160 | }) { 161 | const params = await _params 162 | return ( 163 | 232 | {async ([data]) => { 233 | 'use server' 234 | 235 | const category = data.index.categoriesSection.categories.items[0] 236 | if (!category) notFound() 237 | const article = category.articles.items[0] 238 | if (!article) notFound() 239 | 240 | const authorInitials = 241 | article.author?._title 242 | .split(/\s/) 243 | .reduce( 244 | (acc, name, idx) => (idx < 3 && name ? acc + name[0] : acc), 245 | '' 246 | ) ?? 'A' 247 | 248 | return ( 249 | <> 250 | 251 | 262 |
263 | {article.body?.json.toc} 264 | 265 | 266 | 271 | {article._title} 272 | 273 | 280 | {article.excerpt} 281 | 282 | {article.author && ( 283 | 284 | 289 | 290 | {article.author?._title} 291 | 292 | 293 | Last Updated{' '} 294 | {format( 295 | new Date(article._sys.lastModifiedAt), 296 | 'MMMM dd, yyyy' 297 | )} 298 | 299 | 300 | )} 301 | 302 | ( 306 | 313 | ), 314 | h3: (props) => ( 315 | 322 | ), 323 | h4: (props) => ( 324 | 325 | {props.children} 326 | 327 | ), 328 | h5: (props) => ( 329 | 330 | {props.children} 331 | 332 | ), 333 | h6: (props) => ( 334 | 335 | {props.children} 336 | 337 | ), 338 | blockquote: ({ children }) => ( 339 |
{children}
340 | ), 341 | table: (props) => ( 342 | 343 | ), 344 | em: (props) => , 345 | tbody: (props) => , 346 | tr: ({ children }) => {children}, 347 | th: ({ children, rowspan, colspan }) => ( 348 | 352 | {children} 353 | 354 | ), 355 | td: ({ children, rowspan, colspan }) => ( 356 | 357 | {children} 358 | 359 | ), 360 | hr: () => , 361 | video: Video, 362 | p: (props) => ( 363 | 369 | ), 370 | a: (props) => { 371 | if (props.internal) { 372 | if (props.internal.__typename !== 'ArticlesItem') { 373 | console.error( 374 | 'unhandled internal link', 375 | props.internal 376 | ) 377 | return null 378 | } 379 | 380 | return ( 381 | 382 | 388 | 389 | ) 390 | } 391 | return ( 392 | 393 | 394 | 395 | ) 396 | }, 397 | img: (props) => , 398 | code: (props) => { 399 | return 400 | }, 401 | pre: ({ code, language }) => ( 402 | 411 | ), 412 | CalloutComponent: Callout, 413 | InlineIconComponent_mark: InlineIcon, 414 | InlineIconComponent: InlineIcon, 415 | }} 416 | > 417 | {article.body?.json.content} 418 |
419 | {!!article.related?.length && ( 420 | 421 | 422 | Related Articles 423 | 424 | 425 | 426 | )} 427 | 428 | 429 |
430 |
431 |
432 |
433 | 434 | ) 435 | }} 436 |
437 | ) 438 | } 439 | -------------------------------------------------------------------------------- /app/[category]/[article]/styles.module.scss: -------------------------------------------------------------------------------- 1 | @media (min-width: 1024px) { 2 | .container { 3 | width: 100%; 4 | 5 | > div { 6 | display: flex; 7 | flex-direction: row-reverse; 8 | justify-content: flex-end; 9 | } 10 | } 11 | } 12 | 13 | .container { 14 | max-width: 100vw; 15 | 16 | h1, 17 | h2, 18 | h3, 19 | h4, 20 | h5, 21 | h6 { 22 | scroll-margin-top: var(--space-9); 23 | } 24 | 25 | ol ol, 26 | ol ul, 27 | ul ol, 28 | ul ul { 29 | margin-block: var(--space-3); 30 | } 31 | 32 | li { 33 | p { 34 | margin-bottom: 0; 35 | } 36 | margin-bottom: var(--space-2); 37 | } 38 | 39 | table { 40 | p:last-child { 41 | margin-bottom: 0; 42 | } 43 | } 44 | 45 | picture { 46 | background-color: var(--gray-2); 47 | padding: var(--space-1); 48 | display: flex; 49 | max-width: max-content; 50 | margin-block: var(--space-6); 51 | border-radius: var(--radius-4); 52 | border: 1px solid var(--gray-5); 53 | z-index: 1; 54 | position: relative; 55 | box-shadow: 0px 1px 2px 0px var(--gray-6); 56 | 57 | video, 58 | img { 59 | border-radius: var(--radius-3); 60 | position: relative; 61 | max-width: 100%; 62 | height: auto; 63 | } 64 | 65 | img { 66 | cursor: zoom-in; 67 | } 68 | } 69 | 70 | figcaption { 71 | padding: var(--space-5); 72 | padding-bottom: var(--space-2); 73 | margin-top: calc(var(--space-8) * -1); 74 | margin-bottom: var(--space-6); 75 | display: block; 76 | position: relative; 77 | text-align: center; 78 | z-index: 0; 79 | border-bottom-left-radius: var(--radius-4); 80 | border-bottom-right-radius: var(--radius-4); 81 | border: 1px solid var(--gray-5); 82 | background: var(--gray-3); 83 | color: var(--gray-11); 84 | font-size: var(--font-size-2); 85 | font-weight: 500; 86 | line-height: var(--space-4); 87 | text-wrap: pretty; 88 | } 89 | 90 | pre { 91 | &:focus { 92 | outline-color: var(--focus-9); 93 | } 94 | 95 | cursor: text; 96 | overflow-x: auto; 97 | line-height: calc(20px * var(--scaling)); 98 | border-radius: var(--radius-4); 99 | font-size: var(--font-size-2); 100 | margin-block: var(--space-6); 101 | border: 1px solid var(--gray-6); 102 | font-size: var(--font-size-1); 103 | font-weight: 500; 104 | position: relative; 105 | 106 | --shiki-color-text: #000; 107 | --shiki-background: var(--gray-2); 108 | --shiki-token-constant: var(--accent-12); 109 | --shiki-token-function: var(--gray-11); 110 | --shiki-token-string-expression: var(--gray-11); 111 | --shiki-token-string: var(--accent-indicator); 112 | --shiki-token-comment: var(--gray-10); 113 | --shiki-token-keyword: var(--accent-11); 114 | --shiki-token-parameter: #d6deeb; 115 | --shiki-token-punctuation: #c792e9; 116 | --shiki-token-link: #79b8ff; 117 | 118 | counter-reset: step; 119 | counter-increment: step 0; 120 | 121 | :global(.line::before) { 122 | content: counter(step); 123 | counter-increment: step; 124 | width: var(--space-4); 125 | display: inline-block; 126 | user-select: none; 127 | text-align: right; 128 | color: var(--gray-9); 129 | margin-right: var(--space-4); 130 | } 131 | 132 | :global(.line) { 133 | box-sizing: border-box; 134 | display: inline-block; 135 | width: 100%; 136 | padding-left: var(--space-3); 137 | padding-right: var(--space-3); 138 | 139 | &:first-child { 140 | margin-top: var(--space-3); 141 | } 142 | 143 | &:last-child { 144 | margin-bottom: var(--space-3); 145 | } 146 | 147 | &:global(.highlighted) { 148 | background-color: var(--gray-4); 149 | } 150 | :global(.highlighted-word) { 151 | background-color: var(--accent-5); 152 | color: var(--gray-12) !important; 153 | padding: 1px 0; 154 | margin: -1px 0; 155 | } 156 | &:global(.diff.remove) { 157 | background-color: var(--red-3); 158 | 159 | &::before { 160 | content: '-'; 161 | color: var(--red-12); 162 | } 163 | } 164 | &:global(.diff.add) { 165 | background-color: var(--green-3); 166 | 167 | &::before { 168 | content: '+'; 169 | color: var(--green-12); 170 | } 171 | } 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /app/[category]/_fragments.ts: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from '@/.basehub' 2 | 3 | export const Category = fragmentOn('CategoriesItem', { 4 | _id: true, 5 | _title: true, 6 | _slug: true, 7 | description: true, 8 | icon: true, 9 | articles: { 10 | items: { 11 | _id: true, 12 | _slug: true, 13 | _title: true, 14 | }, 15 | }, 16 | }) 17 | 18 | export type Category = fragmentOn.infer 19 | -------------------------------------------------------------------------------- /app/[category]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Pump } from '@/.basehub/react-pump' 2 | import { Container, Grid, Heading, Text } from '@radix-ui/themes' 3 | import { basehub } from '@/.basehub' 4 | import { CategoryMeta } from '../_components/category-card' 5 | import { ArticleMeta } from '../_components/article-link' 6 | import { notFound } from 'next/navigation' 7 | import { ArticlesList } from '../_components/articles-list' 8 | import { Breadcrumb } from '../_components/breadcrumb' 9 | import { draftMode } from 'next/headers' 10 | import type { Metadata } from 'next/types' 11 | import { MetadataFragment } from '../_fragments' 12 | import { PageView } from '../_components/analytics/page-view' 13 | 14 | export const generateStaticParams = async () => { 15 | const data = await basehub({ next: { revalidate: 120 } }).query({ 16 | index: { 17 | categoriesSection: { 18 | title: true, 19 | categories: { 20 | items: CategoryMeta, 21 | }, 22 | }, 23 | }, 24 | }) 25 | return data.index.categoriesSection.categories.items.map((category) => ({ 26 | params: { category: category._slug }, 27 | })) 28 | } 29 | 30 | export const generateMetadata = async ({ 31 | params: _params, 32 | }: { 33 | params: Promise<{ category: string }> 34 | }): Promise => { 35 | const params = await _params 36 | const data = await basehub({ 37 | next: { revalidate: 60 }, 38 | draft: (await draftMode()).isEnabled, 39 | }).query({ 40 | settings: { 41 | metadata: MetadataFragment, 42 | }, 43 | index: { 44 | categoriesSection: { 45 | title: true, 46 | categories: { 47 | __args: { 48 | first: 1, 49 | filter: { _sys_slug: { eq: params.category } }, 50 | }, 51 | items: { 52 | ...CategoryMeta, 53 | ogImage: { 54 | url: true, 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }) 61 | 62 | const category = data.index.categoriesSection.categories.items[0] 63 | if (!category) return {} 64 | const siteName = data.settings.metadata.title 65 | 66 | const title = { 67 | absolute: `${category._title} | ${siteName}`, 68 | } 69 | const description = !category.description 70 | ? undefined 71 | : category.description.length > 150 72 | ? category.description.slice(0, 147) + '...' 73 | : category.description 74 | 75 | const images = [ 76 | { 77 | url: category.ogImage.url, 78 | width: 1200, 79 | height: 630, 80 | }, 81 | ] 82 | 83 | return { 84 | title, 85 | description, 86 | icons: { 87 | icon: data.settings.metadata.icon.url, 88 | shortcut: data.settings.metadata.icon.url, 89 | apple: data.settings.metadata.icon.url, 90 | }, 91 | openGraph: { 92 | title, 93 | description, 94 | siteName, 95 | locale: 'en-US', 96 | type: 'website', 97 | url: `/${params.category}`, 98 | images, 99 | }, 100 | } 101 | } 102 | 103 | export default async function CategoryPage({ 104 | params: _params, 105 | }: { 106 | params: Promise<{ category: string }> 107 | }) { 108 | const params = await _params 109 | return ( 110 | 140 | {async ([data]) => { 141 | 'use server' 142 | 143 | const category = data.index.categoriesSection.categories.items[0] 144 | if (!category) notFound() 145 | 146 | return ( 147 | <> 148 | 149 | 159 |
160 | 161 | 168 | {category._title} 169 | {category.description} 170 | 174 | 175 |
176 |
177 | 178 | ) 179 | }} 180 |
181 | ) 182 | } 183 | -------------------------------------------------------------------------------- /app/_components/analytics/page-view.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { Views } from '@/.basehub/schema' 3 | import * as React from 'react' 4 | import { sendEvent } from 'basehub/events' 5 | 6 | export const PageView = ({ ingestKey }: { ingestKey: Views['ingestKey'] }) => { 7 | // On mount, send the event 8 | React.useEffect(() => { 9 | sendEvent(ingestKey) 10 | }, [ingestKey]) 11 | 12 | return null 13 | } 14 | -------------------------------------------------------------------------------- /app/_components/article-link.tsx: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from '@/.basehub' 2 | import { ArrowRightIcon } from '@radix-ui/react-icons' 3 | import { Card, Flex, Text } from '@radix-ui/themes' 4 | import Link from 'next/link' 5 | 6 | export const ArticleMeta = fragmentOn('ArticlesItem', { 7 | _id: true, 8 | _slug: true, 9 | _title: true, 10 | excerpt: true, 11 | }) 12 | 13 | export type ArticleMeta = fragmentOn.infer 14 | 15 | export const ArticleLink = ({ 16 | data, 17 | categorySlug, 18 | }: { 19 | data: ArticleMeta 20 | categorySlug: string 21 | }) => { 22 | return ( 23 | 24 | 25 | 26 | 27 | {data._title} 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/_components/article/image-with-zoom.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import Image from 'next/image' 5 | import Zoom from 'react-medium-image-zoom' 6 | import 'react-medium-image-zoom/dist/styles.css' 7 | 8 | export const ImageWithZoom = ({ 9 | caption, 10 | alt, 11 | ...rest 12 | }: { 13 | caption?: string | undefined 14 | src: string 15 | alt?: string | null 16 | width?: number 17 | height?: number 18 | }) => { 19 | return ( 20 | 21 | 22 | {alt 27 | 28 | {caption &&
{caption}
} 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/_components/article/video.tsx: -------------------------------------------------------------------------------- 1 | export const Video = ({ 2 | src, 3 | caption, 4 | ...rest 5 | }: { 6 | src: string 7 | caption?: string | undefined 8 | }) => ( 9 | <> 10 | 11 | 13 | {caption &&
{caption}
} 14 | 15 | ) 16 | -------------------------------------------------------------------------------- /app/_components/articles-list/articles-list.module.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | height: fit-content; 3 | @media (min-width: 768px) { 4 | grid-row: -1 / 1; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /app/_components/articles-list/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, Flex } from '@radix-ui/themes' 2 | import { ArrowRightIcon } from '@radix-ui/react-icons' 3 | import NextLink from 'next/link' 4 | import { ArticlesItem } from '@/.basehub/schema' 5 | import s from './articles-list.module.scss' 6 | import { getArticleHrefFromSlugPath } from '@/lib/basehub-helpers/util' 7 | 8 | type Props = 9 | | { 10 | articles: Pick[] 11 | } 12 | | { 13 | articles: Pick[] 14 | categorySlug: string 15 | } 16 | 17 | export const ArticlesList = (props: Props) => { 18 | return ( 19 | 25 | 26 | {props.articles.map((item) => { 27 | return ( 28 | 51 | ) 52 | })} 53 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /app/_components/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Link, Text } from '@radix-ui/themes' 2 | import { SlashIcon } from '@radix-ui/react-icons' 3 | import NextLink from 'next/link' 4 | 5 | export const Breadcrumb = ({ 6 | category, 7 | article, 8 | }: { 9 | category: { _slug: string; _title: string } 10 | article?: { _slug: string; _title: string } 11 | }) => { 12 | return ( 13 | 14 | 15 | Categories 16 | 17 | 18 | 19 | 20 | 21 | 28 | {category._title} 29 | 30 | 31 | {article && ( 32 | <> 33 | 34 | 35 | 36 | 43 | 44 | {article._title} 45 | 46 | 47 | 48 | )} 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/_components/callout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CheckCircledIcon, 3 | CrossCircledIcon, 4 | ExclamationTriangleIcon, 5 | InfoCircledIcon, 6 | } from '@radix-ui/react-icons' 7 | import { Callout as RadixCallout, Link } from '@radix-ui/themes' 8 | import type { Callout as CalloutFragment } from '../[category]/[article]/_fragments' 9 | import { RichText } from 'basehub/react-rich-text' 10 | 11 | export const Callout = ({ type = 'info', content }: CalloutFragment) => { 12 | const { Icon, color } = 13 | theme[type as 'info' | 'warning' | 'error' | 'success'] 14 | return ( 15 | 16 | 17 | 18 | 19 | , 23 | a: (props) => , 24 | }} 25 | /> 26 | 27 | ) 28 | } 29 | 30 | const theme = { 31 | info: { Icon: InfoCircledIcon, color: 'blue' }, 32 | warning: { Icon: ExclamationTriangleIcon, color: 'yellow' }, 33 | error: { Icon: CrossCircledIcon, color: 'red' }, 34 | success: { Icon: CheckCircledIcon, color: 'green' }, 35 | } as const 36 | -------------------------------------------------------------------------------- /app/_components/category-card.tsx: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from '@/.basehub' 2 | import { Card, Flex, Text } from '@radix-ui/themes' 3 | import { Icon } from './icon' 4 | import Link from 'next/link' 5 | 6 | export const CategoryMeta = fragmentOn('CategoriesItem', { 7 | _id: true, 8 | _title: true, 9 | _slug: true, 10 | description: true, 11 | icon: true, 12 | articles: { 13 | items: { 14 | _id: true, 15 | }, 16 | }, 17 | }) 18 | 19 | export type CategoryMeta = fragmentOn.infer 20 | 21 | export const CategoryCard = ({ data }: { data: CategoryMeta }) => { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | {data._title} 29 | 30 | 40 | {data.description} 41 | 42 | 43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /app/_components/feedback.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { 3 | Button, 4 | Card, 5 | Flex, 6 | Grid, 7 | IconButton, 8 | Popover, 9 | Text, 10 | TextArea, 11 | } from '@radix-ui/themes' 12 | import * as React from 'react' 13 | import { parseFormData, sendEvent, EventSchema } from 'basehub/events' 14 | import { Feedback as FeedbackType } from '@/.basehub/schema' 15 | import { CheckCircledIcon } from '@radix-ui/react-icons' 16 | import { usePathname } from 'next/navigation' 17 | import { ThumbsUp, ThumbsDown } from 'lucide-react' 18 | import Cookie from 'js-cookie' 19 | 20 | export const Feedback = ({ 21 | ingestKey, 22 | schema, 23 | }: { 24 | ingestKey: FeedbackType['submissions']['ingestKey'] 25 | schema: FeedbackType['submissions']['schema'] 26 | }) => { 27 | const pathname = usePathname() 28 | 29 | const [intent, setIntent] = React.useState< 30 | EventSchema['intent'] | null 31 | >(null) 32 | const [submitting, setSubmitting] = React.useState(false) 33 | const [shouldThank, setShouldThank] = React.useState(false) 34 | const formRef = React.useRef(null) 35 | 36 | React.useEffect(() => { 37 | if (!shouldThank) return 38 | const timeout = window.setTimeout(() => { 39 | formRef.current?.reset() 40 | setSubmitting(false) 41 | }, 300) 42 | 43 | return () => window.clearTimeout(timeout) 44 | }, [shouldThank]) 45 | 46 | const handleSubmit = async (e?: React.FormEvent) => { 47 | e?.preventDefault() 48 | const form = formRef.current 49 | if (!intent || !form || submitting) return 50 | const formData = new FormData(form) 51 | formData.set('intent', intent) 52 | formData.set('path', pathname) 53 | 54 | try { 55 | const userFromCookie = Cookie.get('intercom-user') 56 | if (userFromCookie) { 57 | const user = JSON.parse(decodeURIComponent(userFromCookie)) 58 | formData.set('email', user.email) 59 | } 60 | } catch (err) { 61 | null 62 | } 63 | 64 | const parsed = parseFormData(ingestKey, schema, formData) 65 | if (!parsed.success) { 66 | console.error(parsed.errors) 67 | setShouldThank(true) 68 | return 69 | } 70 | 71 | setSubmitting(true) 72 | try { 73 | await sendEvent(ingestKey, parsed.data) 74 | } catch (e) { 75 | console.error(e) 76 | } 77 | setShouldThank(true) 78 | } 79 | 80 | return ( 81 | 82 | 83 | Did this answer your question? 84 | 85 | setIntent('Negative')} 91 | aria-label="Bad article" 92 | > 93 | 98 | 99 | setIntent('Positive')} 105 | aria-label="Good article." 106 | > 107 | 112 | 113 | 114 | { 117 | if (!v) { 118 | setIntent(null) 119 | setShouldThank(false) 120 | } 121 | }} 122 | > 123 | 130 |
131 | 132 | 133 | 134 |
139 | 149 |