├── .cursor └── rules │ └── main-context.mdc ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── apps └── web │ ├── .env.example │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── app │ ├── (blog) │ │ ├── blog │ │ │ ├── [slug] │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── ui │ │ │ │ └── Tags.tsx │ │ └── custom-renderer.tsx │ ├── (docs) │ │ ├── docs │ │ │ ├── ai-rules │ │ │ │ └── page.tsx │ │ │ ├── api │ │ │ │ └── [endpointId] │ │ │ │ │ ├── code-examples.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── getting-started │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── nextjs │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ └── typescript │ │ │ │ └── page.tsx │ │ └── ui │ │ │ ├── object-renderer.tsx │ │ │ └── sidebar.tsx │ ├── (legal) │ │ ├── layout.tsx │ │ ├── privacy │ │ │ └── page.tsx │ │ └── terms │ │ │ └── page.tsx │ ├── api │ │ ├── [...route] │ │ │ └── route.ts │ │ └── public │ │ │ ├── [...route] │ │ │ ├── public-api.checks.ts │ │ │ ├── public-api.constants.ts │ │ │ ├── public-api.errors.ts │ │ │ ├── public-api.types.ts │ │ │ └── route.ts │ │ │ ├── og │ │ │ └── route.tsx │ │ │ └── public-posts.test.ts │ ├── auth │ │ └── confirm │ │ │ └── route.ts │ ├── contact │ │ └── page.tsx │ ├── forms │ │ ├── lib.tsx │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── pub │ │ ├── [subdomain] │ │ │ ├── [slug] │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── icon.tsx │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── queries.ts │ │ └── themes │ │ │ ├── blog-home.tsx │ │ │ ├── default │ │ │ ├── blog-post-item.tsx │ │ │ ├── home.tsx │ │ │ ├── post-dark.tsx │ │ │ └── post.tsx │ │ │ ├── directory │ │ │ ├── home.tsx │ │ │ └── post.tsx │ │ │ ├── garden │ │ │ └── home.tsx │ │ │ ├── instrument │ │ │ ├── home.tsx │ │ │ └── styles.css │ │ │ └── newsroom │ │ │ └── home.tsx │ ├── supa.ts │ ├── types.ts │ ├── ui │ │ ├── SocialLinks.tsx │ │ ├── docs-page-layout.tsx │ │ ├── fade-in.tsx │ │ ├── table-of-contents.tsx │ │ └── zenblog-footer.tsx │ └── utils │ │ ├── api-client.ts │ │ ├── dates.ts │ │ ├── slugify.test.ts │ │ ├── slugify.ts │ │ └── wait.ts │ ├── assets │ ├── Inter-Bold.ttf │ ├── Inter-Regular.ttf │ └── InterVariable.ttf │ ├── components.json │ ├── constants │ └── themes.ts │ ├── env.d.ts │ ├── lib │ ├── axiom.ts │ ├── openai.ts │ └── use-url-anchor.ts │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.cjs │ ├── public │ ├── 18fcdaa0b91d4461ee42613bf6988ba0ee2ba8ccf810e838d204aeaedf0fb0f6.txt │ └── static │ │ ├── editor-screenshot.webp │ │ ├── favicon.ico │ │ ├── landing_screenshot.png │ │ ├── leaf.png │ │ ├── logo.svg │ │ ├── logotype.svg │ │ ├── og.jpg │ │ ├── screaming-cowboy-cat.png │ │ ├── tweets │ │ ├── jordienr.jpg │ │ ├── lawrencejessej.jpg │ │ ├── leximory.jpg │ │ ├── metasurfero.jpg │ │ ├── pqoqubbw.jpg │ │ ├── williamhzo.jpg │ │ └── zenbloghq.jpg │ │ ├── ui-1.png │ │ ├── ui-2.png │ │ ├── ui-3.png │ │ ├── zenblog-nextjs-template.webp │ │ ├── zenblog-ui.png │ │ └── zenblog-ui.webp │ ├── src │ ├── analytics │ │ └── index.ts │ ├── cms │ │ ├── ContentRenderer.tsx │ │ ├── code-block-sugar.tsx │ │ ├── index.ts │ │ └── sugar-plugin.ts │ ├── components │ │ ├── 3d │ │ │ └── leaves.tsx │ │ ├── Blogs │ │ │ └── BlogSelector.tsx │ │ ├── CodeBlock.tsx │ │ ├── Content │ │ │ ├── ContentEditor.tsx │ │ │ └── ContentRenderer.tsx │ │ ├── CopyButton.tsx │ │ ├── Debugger.tsx │ │ ├── Editor.tsx │ │ ├── Editor │ │ │ ├── Editor.constants.tsx │ │ │ ├── Editor.queries.ts │ │ │ ├── Editor.state.ts │ │ │ ├── EditorMenu.tsx │ │ │ ├── EditorSettings.tsx │ │ │ ├── TagManagement.tsx │ │ │ ├── ZendoEditor.tsx │ │ │ ├── editor-category-picker.tsx │ │ │ ├── editor-media-node.tsx │ │ │ ├── slash-commands │ │ │ │ └── slash-commands.tsx │ │ │ ├── trailing-node.ts │ │ │ └── upload-image.ts │ │ ├── EmojiPicker.tsx │ │ ├── Feedback.tsx │ │ ├── Feedback │ │ │ └── feedback-form.tsx │ │ ├── Footer.tsx │ │ ├── HiddenField.tsx │ │ ├── Homepage │ │ │ └── Cards │ │ │ │ ├── BaseCard.tsx │ │ │ │ ├── CodeExamples.tsx │ │ │ │ ├── OpenSource.tsx │ │ │ │ ├── ReactComponents.tsx │ │ │ │ └── TypeSafety.tsx │ │ ├── Images │ │ │ ├── ImagePicker.tsx │ │ │ ├── ImageUploader.tsx │ │ │ └── Images.queries.ts │ │ ├── Insights │ │ │ └── TextTypePicker.tsx │ │ ├── LoggedInUser.tsx │ │ ├── LoggedInUserChecks.tsx │ │ ├── MultiSelect.tsx │ │ ├── Notifications.tsx │ │ ├── Shortcut.tsx │ │ ├── Spinner.tsx │ │ ├── Tags │ │ │ ├── CreateTagDialog.tsx │ │ │ ├── CreateTagForm.tsx │ │ │ ├── TagPicker.tsx │ │ │ └── UpdateTagDialog.tsx │ │ ├── Tiptap.tsx │ │ ├── UserButton.tsx │ │ ├── ZendoLogo.tsx │ │ ├── code-block.tsx │ │ ├── confirm-dialog.tsx │ │ ├── copy-cell.tsx │ │ ├── dev │ │ │ └── zenblog-toolbar.tsx │ │ ├── integration-guide.tsx │ │ ├── is-dev-mode.tsx │ │ ├── magicui │ │ │ └── marquee.tsx │ │ ├── marketing │ │ │ └── Navigation.tsx │ │ ├── onboarding.tsx │ │ └── ui │ │ │ ├── accordion.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── carousel.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── editor-date-input.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── popover.tsx │ │ │ ├── resizable.tsx │ │ │ ├── select.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ ├── env.mjs │ ├── hooks │ │ ├── use-blog-id.ts │ │ ├── useKeyboard.ts │ │ └── useRouterTabs.ts │ ├── layouts │ │ └── AppLayout.tsx │ ├── lib │ │ ├── ai │ │ │ └── insights.ts │ │ ├── client │ │ │ └── use-local-storage.ts │ │ ├── config.ts │ │ ├── constants.ts │ │ ├── create-id.ts │ │ ├── models │ │ │ ├── blogs │ │ │ │ └── Blogs.ts │ │ │ └── posts │ │ │ │ └── Posts.ts │ │ ├── pricing.constants.ts │ │ ├── server │ │ │ ├── deprecated │ │ │ │ └── supabase.ts │ │ │ ├── stripe.constants.ts │ │ │ ├── stripe.ts │ │ │ └── supabase │ │ │ │ ├── admin.ts │ │ │ │ └── index.ts │ │ ├── supabase.ts │ │ ├── tremor │ │ │ └── utils.ts │ │ ├── utils.ts │ │ └── utils │ │ │ ├── auth.ts │ │ │ └── slugs.ts │ ├── middleware.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── account │ │ │ └── index.tsx │ │ ├── api │ │ │ └── webhooks │ │ │ │ └── stripe.ts │ │ ├── blogs │ │ │ ├── [blogId] │ │ │ │ ├── authors.tsx │ │ │ │ ├── categories.tsx │ │ │ │ ├── create.tsx │ │ │ │ ├── customise.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── media.tsx │ │ │ │ ├── post │ │ │ │ │ └── [postSlug].tsx │ │ │ │ ├── posts.tsx │ │ │ │ ├── settings.tsx │ │ │ │ ├── tags.tsx │ │ │ │ └── usage │ │ │ │ │ └── index.tsx │ │ │ ├── _ │ │ │ │ └── [...rest].tsx │ │ │ ├── create.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── insights │ │ │ └── index.tsx │ │ ├── pricing.tsx │ │ ├── reset-password-confirmation.tsx │ │ ├── reset-password.tsx │ │ ├── sign-in.tsx │ │ ├── sign-out.tsx │ │ ├── sign-up.tsx │ │ ├── test.tsx │ │ └── uploader.tsx │ ├── queries │ │ ├── authors.ts │ │ ├── blogs.ts │ │ ├── categories.ts │ │ ├── onboarding.ts │ │ ├── posts.ts │ │ ├── prices.ts │ │ ├── products.ts │ │ ├── subscription.ts │ │ └── tags.ts │ ├── scripts │ │ └── stripe-sync.ts │ ├── store │ │ └── app.ts │ ├── styles │ │ └── globals.css │ ├── trpc │ │ ├── client │ │ │ └── index.ts │ │ ├── server │ │ │ ├── context.ts │ │ │ ├── index.ts │ │ │ └── trpc.ts │ │ └── utils.ts │ ├── types │ │ └── supabase.ts │ └── utils │ │ ├── get-hosted-blog-url.ts │ │ └── supabase │ │ ├── browser.tsx │ │ └── middleware.ts │ ├── tailwind.config.ts │ ├── tinybird │ └── index.ts │ ├── tsconfig.json │ └── vercel.json ├── package-lock.json ├── package.json ├── packages ├── code-block-sugar-high │ ├── README.md │ ├── code-block-sugar.tsx │ ├── index.ts │ ├── package.json │ └── sugar-plugin.ts ├── hash │ ├── index.ts │ └── package.json ├── types │ ├── index.ts │ └── package.json └── zenblog │ ├── README.md │ ├── demo │ └── index.ts │ ├── dist │ ├── index.d.ts │ ├── index.d.ts.map │ ├── index.js │ ├── index.js.map │ ├── lib │ │ ├── index.d.ts │ │ ├── index.d.ts.map │ │ ├── index.js │ │ └── index.js.map │ ├── types.d.ts │ ├── types.d.ts.map │ ├── types.js │ └── types.js.map │ ├── package.json │ ├── src │ ├── index.ts │ ├── lib │ │ └── index.ts │ └── types.ts │ ├── tests │ └── index.test.ts │ └── tsconfig.json ├── todo ├── cleanup.md ├── mvp.md ├── postmvp.md └── templates.md └── turbo.json /.cursor/rules/main-context.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | --- 5 | 6 | # Project context 7 | 8 | - You're building Zenblog, a headless blogging CMS. 9 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | .backup 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # turbo 34 | .turbo 35 | 36 | # vercel 37 | .vercel 38 | 39 | supabase/.temp -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = true 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node-terminal", 19 | "request": "launch", 20 | "command": "npm run dev", 21 | "serverReadyAction": { 22 | "pattern": "- Local:.+(https?://.+)", 23 | "uriFormat": "%s", 24 | "action": "debugWithChrome" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.editor.customLabels.patterns": { 3 | "**/app/**/page.tsx": "${dirname}/page" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL= 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY= 3 | SUPABASE_SERVICE_ROLE= 4 | RESEND= 5 | STRIPE_PUBLISHABLE_KEY= 6 | STRIPE_SECRET_KEY= 7 | STRIPE_WEBHOOK_SECRET= -------------------------------------------------------------------------------- /apps/web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | const config = { 5 | extends: [ "next/core-web-vitals" ], 6 | rules: { 7 | 8 | }, 9 | }; 10 | 11 | module.exports = config; 12 | -------------------------------------------------------------------------------- /apps/web/.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | next-env.d.ts 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 | .pnpm-debug.log* 28 | 29 | # local env files 30 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 31 | .env 32 | .env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | .turbo -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/README.md -------------------------------------------------------------------------------- /apps/web/app/(blog)/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { getBlogClient } from "@/cms"; 3 | import { CustomRenderer } from "app/(blog)/custom-renderer"; 4 | import { ArrowLeftIcon } from "lucide-react"; 5 | import Link from "next/link"; 6 | import React from "react"; 7 | import ReactSyntaxHighlighter from "react-syntax-highlighter"; 8 | 9 | export const dynamic = "force-dynamic"; 10 | type Params = { 11 | params: { 12 | slug: string; 13 | }; 14 | }; 15 | 16 | export async function generateMetadata({ params }: Params) { 17 | const blog = getBlogClient(); 18 | const { data: post } = await blog.posts.get({ slug: params.slug }); 19 | 20 | return { 21 | title: post.title, 22 | description: post.excerpt, 23 | }; 24 | } 25 | const Post = async ({ params: { slug } }: Params) => { 26 | const blog = getBlogClient(); 27 | const { data: post } = await blog.posts.get({ slug }); 28 | 29 | return ( 30 |
37 | {new Date(post.published_at).toLocaleDateString("en-US", { 38 | year: "numeric", 39 | month: "long", 40 | day: "numeric", 41 | })} 42 |
43 |elements to highlight code 13 | if (element.tagName.toLowerCase() === "pre") { 14 | const code = element.querySelector("code"); 15 | const language = code?.className.replace("language-", "") || "tsx"; 16 | return ( 17 |18 | {code?.textContent || ""} 19 | 20 | ); 21 | } 22 | 23 | // For other elements, create React elements 24 | const children = Array.from(element.childNodes).map((node, index) => { 25 | if (node.nodeType === Node.ELEMENT_NODE) { 26 | return renderElement(node as Element); 27 | } 28 | return node.textContent; 29 | }); 30 | 31 | return React.createElement( 32 | element.tagName.toLowerCase(), 33 | { key: index }, 34 | ...children 35 | ); 36 | }; 37 | 38 | return ( 39 |40 | {Array.from(doc.body.children).map((element, index) => 41 | renderElement(element, index) 42 | )} 43 |44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /apps/web/app/(docs)/docs/ai-rules/page.tsx: -------------------------------------------------------------------------------- 1 | import { CodeBlock } from "@/components/CodeBlock"; 2 | import { endpoints } from "app/api/public/[...route]/public-api.constants"; 3 | import { DocsPageLayout } from "app/ui/docs-page-layout"; 4 | 5 | export default function AiRules() { 6 | return ( 7 |12 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/app/(docs)/docs/api/[endpointId]/code-examples.tsx: -------------------------------------------------------------------------------- 1 | import { CodeBlock } from "@/components/CodeBlock"; 2 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 3 | import { EndpointCodeExamples } from "app/api/public/[...route]/public-api.types"; 4 | 5 | export function CodeExamples({ examples }: { examples: EndpointCodeExamples }) { 6 | const keys = Object.keys(examples) as (keyof EndpointCodeExamples)[]; 7 | 8 | return ( 9 |13 |36 |14 | {`This is the documentation to integrate zenblog into your website. 15 | Zenblog is a headless CMS that allows you to create and manage your blog. It will host images and videos for you. 16 | You can install the zenblog package with npm i zenblog to use the typed http client for the API. Make sure the package version is 1.2.0 or higher. 17 | To integrate zenblog into your website, you just need to make an HTTP request to the API with the Blog ID which you can find in the Zenblog dashboard. The content is returned as an html string. You have to render it to the dom. You can parse it however you want and change the styles and dom elements for your website as you need. 18 | 19 | Here is the schema for the API and typescript/javascript client: 20 | 21 | ${JSON.stringify(endpoints, null, 2) 22 | .trim() 23 | .replace(/^```text\n/, "") 24 | .replace(/\n```$/, "") 25 | // replace escaped quotes with actual quotes 26 | .replace(/\\"/g, '"') 27 | .replace(/\\'/g, "'") 28 | .replace(/\\`/g, "`") 29 | .replace(/\\n/g, "\n") 30 | .replace(/\\t/g, "\t") 31 | .replace(/\\r/g, "\r") 32 | .replace(/\\f/g, "\f")} 33 | `} 34 | 35 |10 |33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/app/(docs)/docs/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default function Docs() { 4 | redirect("/docs/getting-started"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(docs)/ui/object-renderer.tsx: -------------------------------------------------------------------------------- 1 | export function ObjectRenderer({ object }: { object: any }) { 2 | const keys = Object.keys(object); 3 | 4 | return ( 5 |11 | 32 |12 | {keys.map((key) => ( 13 | 18 | {keys.map((key) => ( 19 |14 | {key} 15 | 16 | ))} 17 |20 | 30 | ))} 31 |21 | {examples[key].map((example) => ( 22 |29 |23 | {`// ${example.description} 24 | ${example.code.trim()} 25 | `} 26 | 27 | ))} 28 |6 | {keys.map((key) => ( 7 |13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/app/(docs)/ui/sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { cn } from "@/lib/utils"; 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | export function SidebarTitle({ 7 | children, 8 | className, 9 | }: { 10 | children: React.ReactNode; 11 | className?: string; 12 | }) { 13 | return ( 14 |8 |11 | ))} 12 |{key}
9 |{object[key]}
10 |20 | {children} 21 |
22 | ); 23 | } 24 | 25 | export function SidebarLink({ 26 | children, 27 | href, 28 | className, 29 | }: { 30 | children: React.ReactNode; 31 | href: string; 32 | className?: string; 33 | }) { 34 | const pathname = usePathname(); 35 | 36 | const isActive = pathname?.includes(href); 37 | return ( 38 | 46 |{children}47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /apps/web/app/(legal)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/Footer"; 2 | import Navigation from "@/components/marketing/Navigation"; 3 | 4 | export default function LegalLayout({ 5 | children, 6 | }: { 7 | children: React.ReactNode; 8 | }) { 9 | return ( 10 | <> 11 |12 | {children} 13 | 14 | > 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/app/api/public/[...route]/public-api.checks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if the code examples are valid with typescript 3 | */ 4 | 5 | import { createZenblogClient } from "zenblog"; 6 | 7 | const zenblog = createZenblogClient({ 8 | blogId: "doesnt-matter", 9 | }); 10 | 11 | const posts = await zenblog.posts.list({ 12 | limit: 100, 13 | offset: 0, 14 | tags: ["tag1", "tag2"], 15 | category: "category-slug", 16 | author: "author-slug", 17 | }); 18 | const postsBySlug = await zenblog.posts.get({ 19 | slug: "slug", 20 | }); 21 | 22 | const categories = await zenblog.categories.list(); 23 | const tags = await zenblog.tags.list(); 24 | const authors = await zenblog.authors.list(); 25 | const authorBySlug = await zenblog.authors.get({ 26 | slug: "slug", 27 | }); 28 | 29 | console.log(posts); 30 | console.log(postsBySlug); 31 | console.log(categories); 32 | console.log(tags); 33 | console.log(authors); 34 | console.log(authorBySlug); 35 | -------------------------------------------------------------------------------- /apps/web/app/api/public/[...route]/public-api.errors.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { StatusCode } from "hono/utils/http-status"; 3 | import { axiom, AXIOM_DATASETS } from "lib/axiom"; 4 | 5 | const ERROR_TABLE = "zenblog-errors"; 6 | 7 | type ErrorItem = { 8 | message: string; 9 | status: StatusCode; 10 | }; 11 | const ERROR_MAP: Record= { 12 | MISSING_BLOG_ID: { message: "No blogId provided", status: 400 }, 13 | MISSING_API_KEY: { message: "No API key provided", status: 400 }, 14 | NO_POSTS_FOUND: { message: "No posts found", status: 404 }, 15 | INVALID_API_KEY: { message: "Invalid API key", status: 401 }, 16 | NO_CATEGORIES_FOUND: { message: "No categories found", status: 404 }, 17 | NO_TAGS_FOUND: { message: "No tags found", status: 404 }, 18 | AUTHOR_NOT_FOUND: { message: "Author not found", status: 404 }, 19 | }; 20 | 21 | export const throwError = (ctx: Context, error: keyof typeof ERROR_MAP) => { 22 | console.log(`🔴 ${ERROR_MAP[error]?.message}`); 23 | axiom.ingest(AXIOM_DATASETS.api, { 24 | error: ERROR_MAP[error]?.message, 25 | request: ctx.req, 26 | status: ERROR_MAP[error]?.status, 27 | }); 28 | return ctx.json( 29 | { message: ERROR_MAP[error]?.message }, 30 | ERROR_MAP[error]?.status 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/web/app/api/public/[...route]/public-api.types.ts: -------------------------------------------------------------------------------- 1 | export type CodeExample = { 2 | description: string; 3 | code: string; 4 | }; 5 | 6 | export type EndpointCodeExamples = { 7 | typescript: CodeExample[]; 8 | }; 9 | 10 | export type Endpoint = { 11 | id: string; 12 | path: string; 13 | method: string; 14 | title: string; 15 | description: string; 16 | headers?: Header[]; 17 | query?: Query[]; 18 | response: Response; 19 | examples: EndpointCodeExamples; 20 | }; 21 | 22 | export type Header = { 23 | key: string; 24 | required: boolean; 25 | description: string; 26 | }; 27 | 28 | export type Query = { 29 | key: string; 30 | required: boolean; 31 | description: string; 32 | }; 33 | 34 | export type Response = { 35 | [200]: { 36 | description: string; 37 | type: string; 38 | example: string; 39 | }; 40 | }; 41 | 42 | export type PublicApiResponse = { 43 | data: T; 44 | total?: number; 45 | offset?: number; 46 | limit?: number; 47 | }; 48 | -------------------------------------------------------------------------------- /apps/web/app/api/public/og/route.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/og"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export const runtime = "edge"; 5 | 6 | export async function GET(req: NextRequest) { 7 | const { searchParams } = new URL(req.url); 8 | const title = 9 | searchParams.get("title") || "Add a blog to your website in 2 minutes!"; 10 | const emoji = searchParams.get("emoji") || "⛩️"; 11 | 12 | const url = searchParams.get("url") || "zenblog.com"; 13 | const urlColor = searchParams.get("urlColor") || "#f97316"; 14 | const bgColor = searchParams.get("bgColor") || "#fafafa"; 15 | 16 | const fontDataInterRegular = await fetch( 17 | new URL("../../../../assets/Inter-Regular.ttf", import.meta.url) 18 | ).then((res) => res.arrayBuffer()); 19 | 20 | return new ImageResponse( 21 | ( 22 | 36 |73 | ), 74 | { 75 | width: 1200, 76 | height: 630, 77 | fonts: [ 78 | { 79 | data: fontDataInterRegular, 80 | name: "Inter", 81 | style: "normal", 82 | weight: 400, 83 | }, 84 | ], 85 | } 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /apps/web/app/auth/confirm/route.ts: -------------------------------------------------------------------------------- 1 | import { type EmailOtpType } from "@supabase/supabase-js"; 2 | import { createClient } from "app/supa"; 3 | import { type NextRequest, NextResponse } from "next/server"; 4 | 5 | export async function GET(request: NextRequest) { 6 | const { searchParams } = new URL(request.url); 7 | const token_hash = searchParams.get("token_hash"); 8 | const type = searchParams.get("type") as EmailOtpType | null; 9 | const next = searchParams.get("next") ?? "/blogs"; 10 | 11 | const redirectTo = request.nextUrl.clone(); 12 | redirectTo.pathname = next; 13 | redirectTo.searchParams.delete("token_hash"); 14 | redirectTo.searchParams.delete("type"); 15 | 16 | if (token_hash && type) { 17 | const supabase = createClient(); 18 | 19 | const { error } = await supabase.auth.verifyOtp({ 20 | type, 21 | token_hash, 22 | }); 23 | if (!error) { 24 | redirectTo.searchParams.delete("next"); 25 | return NextResponse.redirect(next); 26 | } 27 | } 28 | 29 | // return the user to an error page with some instructions 30 | redirectTo.pathname = "/error"; 31 | return NextResponse.redirect(redirectTo); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/app/contact/page.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/Footer"; 2 | import Navigation from "@/components/marketing/Navigation"; 3 | 4 | export default function ContactPage() { 5 | return ( 6 |42 |61 |47 | {emoji} 48 |49 |58 | {title} 59 |60 |70 | {url} 71 |72 |7 |27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/app/forms/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { z } from "zod"; 4 | import { createForm } from "./lib"; 5 | import Link from "next/link"; 6 | 7 | export default function Page() { 8 | const SignInSchema = z.object({ 9 | email: z.string().min(1).email().label("Email").placeholder("joe@mama.com"), 10 | password: z.string().min(1).label("Password").password(), 11 | firstName: z.string().optional().label("Name"), 12 | country: z 13 | .enum(["es", "us"]) 14 | .select([ 15 | { 16 | label: "Spain", 17 | value: "es", 18 | }, 19 | { 20 | label: "United States", 21 | value: "us", 22 | }, 23 | ]) 24 | .label("Country") 25 | .defaultValue("es"), 26 | birthday: z 27 | .date() 28 | .label("Birthday") 29 | .optional() 30 | .label("Date of birth") 31 | .defaultValue("1990-12-12"), 32 | terms: z 33 | .boolean() 34 | .optional() 35 | .label( 36 | 37 | You must agree to the{" "} 38 | 39 | terms and conditions 40 | 41 | 42 | ), 43 | }); 44 | 45 | const SignInForm = createForm(SignInSchema); 46 | 47 | return ( 48 |8 | 9 |25 | 26 |Contact
10 |11 | If you have any questions, feedback, or issues, please feel free to 12 | contact us by email. 13 |
14 |15 | Email: support@zenblog.com 16 |
17 |18 | Twitter: @zenbloghq 19 |
20 |21 | Please make sure to include all relevant information so we can get 22 | back to you. 23 |
24 |49 |60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /apps/web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { IBM_Plex_Mono, Inter } from "next/font/google"; 2 | import "./globals.css"; 3 | import PlausibleProvider from "next-plausible"; 4 | 5 | export const metadata = { 6 | title: "Zenblog blog", 7 | description: "A simple headless blogging CMS", 8 | icons: { 9 | icon: "/static/favicon.ico", 10 | }, 11 | openGraph: { 12 | title: "Zenblog blog", 13 | description: "A simple headless blogging CMS", 14 | images: "/static/og.jpg", 15 | }, 16 | }; 17 | 18 | const ibmPlexMono = IBM_Plex_Mono({ 19 | subsets: ["latin"], 20 | weight: ["400", "500", "600"], 21 | display: "swap", 22 | variable: "--font-mono", 23 | }); 24 | 25 | const inter = Inter({ 26 | subsets: ["latin"], 27 | display: "swap", 28 | variable: "--font-sans", 29 | }); 30 | 31 | export default function RootLayout({ 32 | children, 33 | }: { 34 | children: React.ReactNode; 35 | }) { 36 | return ( 37 | 38 | 39 |{ 52 | e.preventDefault(); 53 | const data = Object.fromEntries( 54 | new FormData(e.target as HTMLFormElement) 55 | ); 56 | window.alert(JSON.stringify(data, null, 2)); 57 | }} 58 | /> 59 | 40 | {" "} 44 | 45 | 46 | {children} 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /apps/web/app/pub/[subdomain]/[slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 | 4 |6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/app/pub/[subdomain]/icon.tsx: -------------------------------------------------------------------------------- 1 | export default function Icon() { 2 | return ( 3 | 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/app/pub/[subdomain]/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { PropsWithChildren } from "react"; 3 | import { getBlog } from "../queries"; 4 | 5 | export default async function Layout({ 6 | children, 7 | params, 8 | }: PropsWithChildren<{ 9 | params: { subdomain: string }; 10 | }>) { 11 | const { subdomain } = params; 12 | const { data: blog } = await getBlog(subdomain); 13 | 14 | return ( 15 |Loading...5 |16 |18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/app/pub/[subdomain]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { getBlog, getPosts } from "../queries"; 3 | import { Metadata } from "next"; 4 | import { BlogHomePage } from "../themes/blog-home"; 5 | import { Theme } from "app/types"; 6 | 7 | export async function generateMetadata({ 8 | params: { subdomain }, 9 | }: { 10 | params: { subdomain: string }; 11 | }): Promise{children} 17 |{ 12 | console.log(subdomain); 13 | const { data: blog } = await getBlog(subdomain); 14 | 15 | return { 16 | title: `${blog?.title}` || "A zenblog blog", 17 | icons: { 18 | icon: 19 | `data:image/svg+xml,` || 20 | `data:image/svg+xml,`, 21 | }, 22 | description: blog?.description || "Start writing your blog today", 23 | openGraph: { 24 | title: `${blog?.title} - Zenblog` || "A zenblog blog", 25 | description: blog?.description || "Start writing your blog today", 26 | type: "website", 27 | }, 28 | }; 29 | } 30 | 31 | async function HostedBlog({ 32 | params: { subdomain }, 33 | }: { 34 | params: { subdomain: string }; 35 | }) { 36 | const { data: blog, error: blogError } = await getBlog(subdomain); 37 | const { data: posts, error: postsError } = await getPosts( 38 | subdomain, 39 | blog?.order 40 | ); 41 | 42 | if (blogError || postsError) { 43 | return ( 44 | 45 |49 | ); 50 | } 51 | 52 | returnBlog not found
46 |{blogError?.message}
47 |{postsError?.message}
48 |; 53 | } 54 | 55 | export default HostedBlog; 56 | -------------------------------------------------------------------------------- /apps/web/app/pub/queries.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "app/supa"; 2 | 3 | export async function getBlog(subdomain: string) { 4 | const supa = createClient(); 5 | 6 | const res = await supa 7 | .from("blogs") 8 | .select( 9 | "id, title, emoji, description, order, theme, twitter, instagram, website" 10 | ) 11 | .eq("slug", subdomain) 12 | .single(); 13 | 14 | return res; 15 | } 16 | 17 | export async function getPosts(subdomain: string, sort: string = "desc") { 18 | const supa = createClient(); 19 | const res = await supa 20 | .from("posts_v5") 21 | .select("title, slug, published_at, cover_image, excerpt") 22 | .eq("blog_slug", subdomain) 23 | .eq("published", true) 24 | .order("published_at", { ascending: sort === "asc" }); 25 | 26 | return res as { 27 | data: { 28 | title: string; 29 | slug: string; 30 | published_at: string; 31 | cover_image: string; 32 | excerpt: string; 33 | }[]; 34 | error: any; 35 | }; 36 | } 37 | 38 | export async function getPost(subdomain: string, slug: string) { 39 | const supa = createClient(); 40 | const { data: post } = await supa 41 | .from("posts_v5") 42 | .select("title, cover_image, published_at, created_at, html_content") 43 | .eq("slug", slug) 44 | .eq("published", true) 45 | .eq("blog_slug", subdomain) 46 | .single(); 47 | 48 | return post; 49 | } 50 | -------------------------------------------------------------------------------- /apps/web/app/pub/themes/blog-home.tsx: -------------------------------------------------------------------------------- 1 | import { Blog, Theme } from "app/types"; 2 | import { DefaultHome } from "./default/home"; 3 | import { DirectoryHome } from "./directory/home"; 4 | import { NewsroomHome } from "./newsroom/home"; 5 | import { GardenHome } from "./garden/home"; 6 | import { InstrumentHome } from "./instrument/home"; 7 | 8 | export function BlogHomePage({ 9 | theme, 10 | blog, 11 | posts, 12 | disableLinks = false, 13 | }: { 14 | theme: Theme; 15 | blog: Blog; 16 | posts: { 17 | title: string; 18 | slug: string; 19 | published_at: string; 20 | cover_image: string; 21 | excerpt: string; 22 | }[]; 23 | disableLinks?: boolean; 24 | }) { 25 | const props = { 26 | blog, 27 | posts, 28 | disableLinks, 29 | }; 30 | 31 | if (theme === "directory") { 32 | return ; 33 | } else if (theme === "default") { 34 | return ; 35 | } else if (theme === "newsroom") { 36 | return ; 37 | } else if (theme === "garden") { 38 | return ; 39 | } else if (theme === "instrument") { 40 | return ; 41 | } else { 42 | return ; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/web/app/pub/themes/default/blog-post-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { motion } from "framer-motion"; 4 | import { formatPostDate } from "app/utils/dates"; 5 | import { FadeIn } from "app/ui/fade-in"; 6 | import Link from "next/link"; 7 | import { useState } from "react"; 8 | 9 | export function BlogPostItem({ 10 | post, 11 | disableLinks, 12 | index, 13 | }: { 14 | post: { 15 | title: string; 16 | slug: string; 17 | published_at: string; 18 | excerpt: string; 19 | }; 20 | disableLinks?: boolean; 21 | index: number; 22 | }) { 23 | const date = formatPostDate(post.published_at); 24 | const [rightText, setRightText] = useState(date); 25 | 26 | return ( 27 | setRightText("Read more →")} 29 | onMouseLeave={() => setRightText(date)} 30 | > 31 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /apps/web/app/pub/themes/default/home.tsx: -------------------------------------------------------------------------------- 1 | import { Blog } from "app/types"; 2 | import { SocialLinks } from "app/ui/SocialLinks"; 3 | import { ZenblogFooter } from "app/ui/zenblog-footer"; 4 | import React from "react"; 5 | import { BlogPostItem } from "./blog-post-item"; 6 | 7 | export function DefaultHome({ 8 | posts, 9 | blog, 10 | disableLinks, 11 | }: { 12 | posts: { 13 | title: string; 14 | slug: string; 15 | published_at: string; 16 | cover_image: string; 17 | excerpt: string; 18 | }[]; 19 | blog: Blog; 20 | disableLinks?: boolean; 21 | }) { 22 | return ( 23 |32 | 37 | 69 |38 | 39 | {post.title} 40 | 41 |64 | {post.excerpt && ( 65 |61 | {rightText} 62 | 63 |{post.excerpt}
66 | )} 67 | 68 |24 |63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /apps/web/app/pub/themes/default/post-dark.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/web/app/pub/themes/default/post.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/app/pub/themes/default/post.tsx -------------------------------------------------------------------------------- /apps/web/app/pub/themes/directory/post.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/app/pub/themes/directory/post.tsx -------------------------------------------------------------------------------- /apps/web/app/pub/themes/instrument/styles.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/app/pub/themes/instrument/styles.css -------------------------------------------------------------------------------- /apps/web/app/supa.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "@/types/supabase"; 2 | import { createServerClient, type CookieOptions } from "@supabase/ssr"; 3 | import { cookies } from "next/headers"; 4 | 5 | export function createClient() { 6 | const cookieStore = cookies(); 7 | 8 | return createServerClient25 |33 | 34 |26 | {blog?.emoji} 27 | {blog?.title} 28 |
29 | {blog?.description && ( 30 |{blog?.description}
31 | )} 32 |39 | 40 | {posts?.length === 0 && ( 41 | <> 42 | 43 |48 | > 49 | )} 50 | 51 |No posts yet
44 |45 | Check back later, see you soon! 46 |
47 |52 | {posts?.map((post, index) => ( 53 |61 |59 | ))} 60 | 62 | ( 9 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 10 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 11 | { 12 | cookies: { 13 | get(name: string) { 14 | return cookieStore.get(name)?.value; 15 | }, 16 | set(name: string, value: string, options: CookieOptions) { 17 | try { 18 | cookieStore.set({ name, value, ...options }); 19 | } catch (error) { 20 | // The `set` method was called from a Server Component. 21 | // This can be ignored if you have middleware refreshing 22 | // user sessions. 23 | } 24 | }, 25 | remove(name: string, options: CookieOptions) { 26 | try { 27 | cookieStore.set({ name, value: "", ...options }); 28 | } catch (error) { 29 | // The `delete` method was called from a Server Component. 30 | // This can be ignored if you have middleware refreshing 31 | // user sessions. 32 | } 33 | }, 34 | }, 35 | } 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/web/app/types.ts: -------------------------------------------------------------------------------- 1 | export type Theme = "directory" | "default" | "newsroom"; 2 | 3 | export type Blog = { 4 | emoji: string; 5 | title: string; 6 | description: string; 7 | twitter?: string; 8 | instagram?: string; 9 | website?: string; 10 | }; 11 | 12 | export type BlogHomeProps = { 13 | posts: { 14 | title: string; 15 | slug: string; 16 | published_at: string; 17 | cover_image: string; 18 | excerpt: string; 19 | }[]; 20 | blog: Blog; 21 | disableLinks?: boolean; 22 | }; 23 | -------------------------------------------------------------------------------- /apps/web/app/ui/SocialLinks.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { motion } from "framer-motion"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | export function SocialLinks({ 6 | links, 7 | className, 8 | linkClassName, 9 | }: { 10 | links: { 11 | twitter?: string; 12 | instagram?: string; 13 | website?: string; 14 | }; 15 | className?: string; 16 | linkClassName?: string; 17 | }) { 18 | const allAreEmpty = !links.twitter && !links.instagram && !links.website; 19 | 20 | if (allAreEmpty) { 21 | return null; 22 | } 23 | 24 | const baseLinkClassName = 25 | "px-2 py-1 text-gray-500 hover:text-gray-800 hover:bg-gray-100 transition-all rounded-lg hover:cursor-default"; 26 | 27 | return ( 28 | 34 | {links.website && ( 35 | 40 | Website 41 | 42 | )} 43 | {links.twitter && ( 44 | 49 | Twitter 50 | 51 | )} 52 | {links.instagram && ( 53 | 58 | Instagram 59 | 60 | )} 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /apps/web/app/ui/docs-page-layout.tsx: -------------------------------------------------------------------------------- 1 | import { TableOfContents } from "./table-of-contents"; 2 | 3 | export function DocsPageLayout({ 4 | children, 5 | tocItems, 6 | title, 7 | description, 8 | }: { 9 | children: React.ReactNode; 10 | tocItems: { title: string; href: string }[]; 11 | title: string; 12 | description: string; 13 | }) { 14 | return ( 15 |16 |31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/app/ui/fade-in.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { motion } from "framer-motion"; 3 | import { PropsWithChildren } from "react"; 4 | 5 | export function FadeIn({ 6 | children, 7 | delay, 8 | }: PropsWithChildren<{ 9 | delay?: number; 10 | }>) { 11 | return ( 12 |17 | 23 |18 | 21 | {children} 22 |{title}
19 |{description}
20 |24 | {tocItems.length > 0 ? ( 25 |30 |26 |28 | ) : null} 29 |27 | 17 | {children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/app/ui/table-of-contents.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { useUrlAnchor } from "lib/use-url-anchor"; 5 | import { ChevronRight } from "lucide-react"; 6 | 7 | type Props = { 8 | items: { 9 | title: string; 10 | href: string; 11 | }[]; 12 | }; 13 | 14 | export function TableOfContents({ items }: Props) { 15 | const hash = useUrlAnchor(); 16 | function isActive(href: string) { 17 | return hash === "#" + href; 18 | } 19 | 20 | return ( 21 |22 |46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /apps/web/app/ui/zenblog-footer.tsx: -------------------------------------------------------------------------------- 1 | export function ZenblogFooter() { 2 | return ( 3 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/app/utils/api-client.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 2 | import { ManagementAPI } from "app/api/[...route]/route"; 3 | import { hc } from "hono/client"; 4 | 5 | export const API = () => { 6 | const client = hcOn this page
23 |24 | {items.map((item) => ( 25 |
45 |- 26 | 32 |
43 | ))} 44 |40 | {item.title} 41 | 42 | (""); 7 | 8 | return client.api; 9 | }; 10 | 11 | export const useAPIQuery = ({ 12 | queryKey, 13 | queryFn, 14 | options, 15 | }: { 16 | queryKey: string; 17 | queryFn: (apiClient: ReturnType ) => Promise ; 18 | options?: UseQueryOptions ; 19 | }) => { 20 | return useQuery({ 21 | queryKey: [queryKey], 22 | queryFn: async () => { 23 | const apiClient = API(); 24 | return queryFn(apiClient); 25 | }, 26 | ...options, 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/web/app/utils/dates.ts: -------------------------------------------------------------------------------- 1 | export function formatPostDate(date: string) { 2 | return new Date(date).toLocaleDateString("en-US", { 3 | year: "numeric", 4 | month: "long", 5 | day: "numeric", 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/app/utils/slugify.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { slugify } from "./slugify"; 3 | 4 | test("slugify", () => { 5 | expect(slugify("John Doe")).toBe("john-doe"); 6 | expect(slugify("John Doe 123")).toBe("john-doe-123"); 7 | expect(slugify("John Doe 123!")).toBe("john-doe-123"); 8 | expect(slugify("John Doe 123!@#$%^&*()")).toBe("john-doe-123"); 9 | expect(slugify("jordi/hola/quepasa")).toBe("jordi-hola-quepasa"); 10 | }); 11 | -------------------------------------------------------------------------------- /apps/web/app/utils/slugify.ts: -------------------------------------------------------------------------------- 1 | export const slugify = (text: string) => { 2 | return text 3 | .toLowerCase() 4 | .replace(/ /g, "-") // Replace spaces with - 5 | .replace(/\//g, "-") // Replace forward slashes with hyphens (moved up) 6 | .replace(/\\/g, "-") // Replace backslashes with hyphens (moved up) 7 | .replace(/[^\w\s-]/g, "") // Remove non-word characters 8 | .replace(/-+/g, "-") // Replace multiple - with a single - 9 | .replace(/^-+/, "") // Remove leading - 10 | .replace(/-+$/, ""); // Remove trailing - 11 | }; 12 | -------------------------------------------------------------------------------- /apps/web/app/utils/wait.ts: -------------------------------------------------------------------------------- 1 | export function wait(ms: number) { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/assets/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/assets/Inter-Bold.ttf -------------------------------------------------------------------------------- /apps/web/assets/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/assets/Inter-Regular.ttf -------------------------------------------------------------------------------- /apps/web/assets/InterVariable.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/assets/InterVariable.ttf -------------------------------------------------------------------------------- /apps/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /apps/web/constants/themes.ts: -------------------------------------------------------------------------------- 1 | export const THEMES = [ 2 | { 3 | id: "default", 4 | name: "Zenblog", 5 | description: "The default theme", 6 | }, 7 | { 8 | id: "directory", 9 | name: "Directory", 10 | description: "A simple directory theme", 11 | }, 12 | // { 13 | // id: "newsroom", 14 | // name: "Newsroom", 15 | // description: "A newsroom theme", 16 | // }, 17 | // { 18 | // id: "garden", 19 | // name: "Garden", 20 | // description: "For the nature lovers", 21 | // }, 22 | // { 23 | // id: "instrument", 24 | // name: "Instrument", 25 | // description: "A dark, elegant theme", 26 | // }, 27 | ]; 28 | -------------------------------------------------------------------------------- /apps/web/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@uidotdev/usehooks"; 2 | -------------------------------------------------------------------------------- /apps/web/lib/axiom.ts: -------------------------------------------------------------------------------- 1 | import { Axiom } from "@axiomhq/js"; 2 | 3 | export const AXIOM_DATASETS = { 4 | api: "api", 5 | stripe: "stripe", 6 | }; 7 | 8 | const axiom = new Axiom({ 9 | token: process.env.AXIOM_TOKEN!, 10 | orgId: process.env.AXIOM_ORG_ID!, 11 | }); 12 | 13 | export type ApiUsageEvent = { 14 | blogId: string; 15 | event: "api-usage"; 16 | timestamp: string; 17 | path: string; 18 | }; 19 | 20 | export function trackApiUsage(event: ApiUsageEvent) { 21 | axiom.ingest(AXIOM_DATASETS.api, event); 22 | } 23 | 24 | export async function getApiUsageForBlog( 25 | blogId: string, 26 | startTime: string, 27 | endTime: string 28 | ) { 29 | const query = ` 30 | api 31 | | where (event =~ "api-usage" and blogId =~ "${blogId}") 32 | | summarize count() by bin_auto(_time) 33 | `; 34 | const result = await axiom 35 | .query(query, { 36 | startTime, 37 | endTime, 38 | }) 39 | .catch((e) => { 40 | console.error("⛩️ Error getting API usage", e); 41 | return { 42 | matches: [], 43 | }; 44 | }); 45 | 46 | console.log("result", result); 47 | 48 | if (!result.matches || result.matches.length === 0) { 49 | return []; 50 | } 51 | 52 | const data = result.matches[0]?.data; 53 | 54 | console.log("data", data); 55 | 56 | return data; 57 | } 58 | 59 | export { axiom }; 60 | -------------------------------------------------------------------------------- /apps/web/lib/openai.ts: -------------------------------------------------------------------------------- 1 | import { createOpenAI } from "@ai-sdk/openai"; 2 | 3 | const openai = createOpenAI({ 4 | apiKey: process.env.OPENAI_API_KEY, 5 | }); 6 | 7 | export const models = { 8 | "4o": openai("gpt-4o"), 9 | "4turbo": openai("gpt-4-turbo"), 10 | "35": openai("gpt-3.5-turbo"), 11 | } as const; 12 | -------------------------------------------------------------------------------- /apps/web/lib/use-url-anchor.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | export function useUrlAnchor() { 6 | const getHash = () => 7 | typeof window !== "undefined" 8 | ? decodeURIComponent(window.location.hash) 9 | : ""; 10 | const [hash, setHash] = useState(getHash()); 11 | 12 | useEffect(() => { 13 | const onHashChange = () => { 14 | setHash(getHash()); 15 | }; 16 | window.addEventListener("hashchange", onHashChange); 17 | return () => window.removeEventListener("hashchange", onHashChange); 18 | }, []); 19 | 20 | return hash; 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | await import("./src/env.mjs"); 6 | 7 | /** @type {import("next").NextConfig} */ 8 | const config = { 9 | reactStrictMode: true, 10 | transpilePackages: [ "react-syntax-highlighter" ], 11 | images: { 12 | remotePatterns: [ 13 | { hostname: "images.zenblog.com", protocol: "https", port: "", pathname: "/**" }, 14 | { hostname: "ppfseefimhneysnokffx.supabase.co", protocol: "https", port: "", pathname: "/**" }, 15 | ], 16 | }, 17 | }; 18 | export default config; 19 | -------------------------------------------------------------------------------- /apps/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /apps/web/public/18fcdaa0b91d4461ee42613bf6988ba0ee2ba8ccf810e838d204aeaedf0fb0f6.txt: -------------------------------------------------------------------------------- 1 | 18fcdaa0b91d4461ee42613bf6988ba0ee2ba8ccf810e838d204aeaedf0fb0f6 -------------------------------------------------------------------------------- /apps/web/public/static/editor-screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/editor-screenshot.webp -------------------------------------------------------------------------------- /apps/web/public/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/static/landing_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/landing_screenshot.png -------------------------------------------------------------------------------- /apps/web/public/static/leaf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/leaf.png -------------------------------------------------------------------------------- /apps/web/public/static/logo.svg: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /apps/web/public/static/og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/og.jpg -------------------------------------------------------------------------------- /apps/web/public/static/screaming-cowboy-cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/screaming-cowboy-cat.png -------------------------------------------------------------------------------- /apps/web/public/static/tweets/jordienr.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/tweets/jordienr.jpg -------------------------------------------------------------------------------- /apps/web/public/static/tweets/lawrencejessej.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/tweets/lawrencejessej.jpg -------------------------------------------------------------------------------- /apps/web/public/static/tweets/leximory.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/tweets/leximory.jpg -------------------------------------------------------------------------------- /apps/web/public/static/tweets/metasurfero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/tweets/metasurfero.jpg -------------------------------------------------------------------------------- /apps/web/public/static/tweets/pqoqubbw.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/tweets/pqoqubbw.jpg -------------------------------------------------------------------------------- /apps/web/public/static/tweets/williamhzo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/tweets/williamhzo.jpg -------------------------------------------------------------------------------- /apps/web/public/static/tweets/zenbloghq.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/tweets/zenbloghq.jpg -------------------------------------------------------------------------------- /apps/web/public/static/ui-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/ui-1.png -------------------------------------------------------------------------------- /apps/web/public/static/ui-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/ui-2.png -------------------------------------------------------------------------------- /apps/web/public/static/ui-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/ui-3.png -------------------------------------------------------------------------------- /apps/web/public/static/zenblog-nextjs-template.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/zenblog-nextjs-template.webp -------------------------------------------------------------------------------- /apps/web/public/static/zenblog-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/zenblog-ui.png -------------------------------------------------------------------------------- /apps/web/public/static/zenblog-ui.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/public/static/zenblog-ui.webp -------------------------------------------------------------------------------- /apps/web/src/analytics/index.ts: -------------------------------------------------------------------------------- 1 | export function sendViewEvent({ 2 | blog_id, 3 | post_slug, 4 | }: { 5 | blog_id: string; 6 | post_slug: string; 7 | }) { 8 | if (!blog_id || !post_slug) { 9 | console.error("Missing blog_id or post_slug"); 10 | return; 11 | } 12 | fetch("https://api.eu-central-1.aws.tinybird.co/v0/events?name=post_views", { 13 | method: "POST", 14 | body: JSON.stringify({ 15 | timestamp: new Date().toISOString(), 16 | blog_id, 17 | post_slug, 18 | event_type: "post_view", 19 | }), 20 | headers: { 21 | Authorization: `Bearer ${process.env.TINYBIRD_TOKEN}`, 22 | }, 23 | }).then((res) => { 24 | if (!res.ok) { 25 | throw new Error("Failed to send event"); 26 | } 27 | console.log("Event sent"); 28 | }); 29 | } 30 | 31 | export async function getPostViews({ blog_id }: { blog_id: string }) { 32 | let url = new URL( 33 | `https://api.eu-central-1.aws.tinybird.co/v0/pipes/posts.json?blog_id=${blog_id}` 34 | ); 35 | 36 | const result = await fetch(url, { 37 | headers: { 38 | Authorization: `Bearer ${process.env.TINYBIRD_TOKEN}`, 39 | }, 40 | }) 41 | .then((r) => r.json()) 42 | .then((r) => r) 43 | .catch((e) => e.toString()); 44 | 45 | if (!result.data) { 46 | console.log(result); 47 | console.error(`there was a problem running the query: ${result}`); 48 | } else { 49 | return result.data; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/web/src/cms/ContentRenderer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { highlight } from "sugar-high"; 4 | 5 | type Props = { 6 | content: string; 7 | }; 8 | export const ContentRenderer = ({ content }: Props) => { 9 | const domParser = new DOMParser(); 10 | 11 | const parsed = domParser.parseFromString(content, "text/html"); 12 | 13 | const images = parsed.querySelectorAll("img"); 14 | 15 | images.forEach((img) => { 16 | img.setAttribute("loading", "lazy"); 17 | img.setAttribute("decoding", "async"); 18 | }); 19 | 20 | const links = parsed.querySelectorAll("a"); 21 | 22 | links.forEach((link) => { 23 | link.setAttribute("target", "_blank"); 24 | link.setAttribute("rel", "noopener noreferrer"); 25 | }); 26 | 27 | const codeBlocks = parsed.querySelectorAll("pre"); 28 | 29 | codeBlocks.forEach((codeBlock) => { 30 | const text = codeBlock.textContent; 31 | if (!text) return; 32 | const highlighted = highlight(text); 33 | codeBlock.innerHTML = highlighted; 34 | }); 35 | 36 | return ( 37 | 38 | 43 |44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /apps/web/src/cms/code-block-sugar.tsx: -------------------------------------------------------------------------------- 1 | import CodeBlock, { CodeBlockOptions } from "@tiptap/extension-code-block"; 2 | import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; 3 | import { SugarPlugin } from "./sugar-plugin"; 4 | 5 | type CodeBlockAttrs = { 6 | node: { 7 | attrs: { 8 | language: string; 9 | }; 10 | }; 11 | updateAttributes: (attrs: { language: string }) => void; 12 | }; 13 | export const CodeBlockComp = ({ 14 | node: { 15 | attrs: { language: defaultLanguage = "typescript" }, 16 | }, 17 | }: CodeBlockAttrs) => ( 18 |19 | 23 | ); 24 | 25 | export const CodeBlockSugarHigh = CodeBlock.extend({ 26 | addProseMirrorPlugins() { 27 | return [ 28 | ...(this.parent?.() || []), 29 | SugarPlugin({ 30 | name: this.name, 31 | }), 32 | ]; 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /apps/web/src/cms/index.ts: -------------------------------------------------------------------------------- 1 | import { createZenblogClient } from "zenblog"; 2 | 3 | export const getBlogClient = () => { 4 | const blog = createZenblogClient({ 5 | blogId: process.env.ZENBLOG_BLOG_ID || "", 6 | _url: process.env.NEXT_PUBLIC_API_URL + "/public", 7 | _debug: true, 8 | }); 9 | 10 | return blog; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/web/src/components/Blogs/BlogSelector.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useRouter } from "next/router"; 3 | import { useState } from "react"; 4 | import { HiChevronDown, HiCog, HiPlus } from "react-icons/hi"; 5 | import { useBlogsQuery } from "@/queries/blogs"; 6 | 7 | export function BlogSelector() { 8 | const router = useRouter(); 9 | 10 | const blogs = useBlogsQuery({ enabled: true }); 11 | const [showSelector, setShowSelector] = useState(false); 12 | 13 | const getCurrentBlog = () => { 14 | const blogId = router.query.blogId; 15 | if (!blogId) return null; 16 | if (blogs.data) { 17 | return blogs.data.find((blog) => blog.id === blogId); 18 | } else { 19 | return null; 20 | } 21 | }; 22 | 23 | return ( 24 |20 |22 |21 | 25 | 34 | {showSelector && ( 35 |62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /apps/web/src/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import SyntaxHighlighter from "react-syntax-highlighter"; 5 | import { stackoverflowDark as codeTheme } from "react-syntax-highlighter/dist/esm/styles/hljs"; 6 | import { Check, Clipboard } from "lucide-react"; 7 | import useClipboard from "react-use-clipboard"; 8 | 9 | type Props = { 10 | children: string; 11 | language?: string; 12 | title?: string; 13 | hideHeader?: boolean; 14 | }; 15 | 16 | export const CodeBlock = ({ 17 | children, 18 | language = "typescript", 19 | title, 20 | hideHeader = false, 21 | }: Props) => { 22 | const [isCopied, copy] = useClipboard(children, { 23 | successDuration: 1000, 24 | }); 25 | 26 | return ( 27 |36 | {blogs.data?.map((blog) => ( 37 | 54 | ))} 55 | 56 |60 | )} 61 |57 | Create blog 58 | 59 | 28 | {!hideHeader && ( 29 |49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /apps/web/src/components/Content/ContentEditor.tsx: -------------------------------------------------------------------------------- 1 | import { ContentRenderer } from "./ContentRenderer"; 2 | import { PencilIcon } from "lucide-react"; 3 | import { useState } from "react"; 4 | 5 | type Props = { 6 | post: any; 7 | }; 8 | export function ContentEditor({ post }: Props) { 9 | const [editable, setEditable] = useState(false); 10 | 11 | function toggleEditable() { 12 | if (!post) return; 13 | setEditable(!editable); 14 | } 15 | 16 | return ( 17 |30 |35 | )} 36 |{title || language}
31 | 34 |46 | {children} 47 | 48 |18 |37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/src/components/Content/ContentRenderer.tsx: -------------------------------------------------------------------------------- 1 | import Heading from "@tiptap/extension-heading"; 2 | import { EditorContent, useEditor, Content } from "@tiptap/react"; 3 | import StarterKit from "@tiptap/starter-kit"; 4 | 5 | export function ContentRenderer({ content }: { content: Content }) { 6 | const editor = useEditor({ 7 | extensions: [StarterKit, Heading.configure({ levels: [2, 3, 4, 5, 6] })], 8 | content: content, 9 | editable: false, 10 | }); 11 | 12 | return ( 13 |19 |36 |20 |33 | 34 |21 | 22 | {post?.slug} 23 | 24 |26 |{post?.title}
25 |27 | 31 |32 |35 | 14 |16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/src/components/CopyButton.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/src/components/CopyButton.tsx -------------------------------------------------------------------------------- /apps/web/src/components/Debugger.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from "react"; 2 | 3 | type Props = {}; 4 | 5 | const Debugger = (props: PropsWithChildren15 | ) => { 6 | const isDev = process.env.NODE_ENV === "development"; 7 | 8 | if (!isDev) return null; 9 | return {props.children}; 10 | }; 11 | 12 | export default Debugger; 13 | -------------------------------------------------------------------------------- /apps/web/src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { EditorContent, useEditor } from "@tiptap/react"; 2 | import StarterKit from "@tiptap/starter-kit"; 3 | import React from "react"; 4 | 5 | type Props = { 6 | content?: string; 7 | }; 8 | 9 | const Editor = (props: Props) => { 10 | const editor = useEditor({ 11 | extensions: [ 12 | StarterKit.configure({ 13 | heading: { 14 | levels: [2, 3, 4, 5, 6], 15 | }, 16 | }), 17 | ], 18 | content: props.content || "", 19 | }); 20 | 21 | return ( 22 |23 |25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /apps/web/src/components/Editor/Editor.queries.ts: -------------------------------------------------------------------------------- 1 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 3 | 4 | type UseTags = { 5 | blogId: string; 6 | }; 7 | export const useBlogTags = ({ blogId }: UseTags) => { 8 | const sb = createSupabaseBrowserClient(); 9 | 10 | const hasBlogId = !!blogId && blogId !== "demo"; 11 | 12 | const query = useQuery({ 13 | queryKey: ["tags"], 14 | queryFn: async () => { 15 | const { data, error } = await sb 16 | .from("tags") 17 | .select("*") 18 | .eq("blog_id", blogId); 19 | if (error) { 20 | throw error; 21 | } 22 | return data; 23 | }, 24 | enabled: hasBlogId, 25 | }); 26 | 27 | return query; 28 | }; 29 | 30 | export const useCreateBlogTag = () => { 31 | const sb = createSupabaseBrowserClient(); 32 | const queryClient = useQueryClient(); 33 | 34 | const mutation = useMutation({ 35 | mutationFn: async (category: { 36 | name: string; 37 | slug: string; 38 | blog_id: string; 39 | }) => { 40 | const { data, error } = await sb.from("tags").insert(category); 41 | if (error) { 42 | throw error; 43 | } 44 | queryClient.invalidateQueries({ queryKey: ["tags"] }); 45 | return data; 46 | }, 47 | }); 48 | 49 | return mutation; 50 | }; 51 | -------------------------------------------------------------------------------- /apps/web/src/components/Editor/Editor.state.ts: -------------------------------------------------------------------------------- 1 | import { Editor, Range } from "@tiptap/core"; 2 | import { create } from "zustand"; 3 | 4 | export type EditorStore = { 5 | editor: Editor | null; 6 | setEditor: (editor: Editor) => void; 7 | range: Range | null; 8 | setRange: (range: Range) => void; 9 | linkDialogOpen: boolean; 10 | setLinkDialogOpen: (linkDialogOpen: boolean) => void; 11 | }; 12 | 13 | export const useEditorState = create24 | ((set) => ({ 14 | editor: null, 15 | setEditor: (editor: Editor) => set({ editor }), 16 | range: null, 17 | setRange: (range: Range) => set({ range }), 18 | linkDialogOpen: false, 19 | setLinkDialogOpen: (linkDialogOpen: boolean) => set({ linkDialogOpen }), 20 | })); 21 | -------------------------------------------------------------------------------- /apps/web/src/components/Editor/trailing-node.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | import { Plugin } from "@tiptap/pm/state"; 3 | 4 | export const TrailingNode = Extension.create({ 5 | name: "trailingNode", 6 | addProseMirrorPlugins() { 7 | return [ 8 | new Plugin({ 9 | appendTransaction: (transactions, oldState, newState) => { 10 | const shouldAdd = 11 | transactions.some((tr) => tr.docChanged) && 12 | newState.doc.content.lastChild?.type.name !== "paragraph"; 13 | 14 | if (!shouldAdd) return null; 15 | 16 | const { doc, tr, schema } = newState; 17 | if (!schema.nodes.paragraph) return null; 18 | return tr.insert(doc.content.size, schema.nodes.paragraph.create()); 19 | }, 20 | }), 21 | ]; 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /apps/web/src/components/EmojiPicker.tsx: -------------------------------------------------------------------------------- 1 | import data from "@emoji-mart/data"; 2 | import Picker from "@emoji-mart/react"; 3 | import { useState } from "react"; 4 | import { useClickAway } from "@uidotdev/usehooks"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuTrigger, 9 | } from "./ui/dropdown-menu"; 10 | 11 | type Props = { 12 | onEmojiChange: (emoji: string) => void; 13 | emoji: string; 14 | }; 15 | export function EmojiPicker({ emoji, onEmojiChange }: Props) { 16 | const [_emoji, setEmoji] = useState(emoji); 17 | 18 | function _onEmojiChange(e: any) { 19 | setEmoji(e.native); 20 | onEmojiChange(e.native); 21 | } 22 | return ( 23 | 24 |38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/components/Feedback.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuTrigger, 6 | } from "./ui/dropdown-menu"; 7 | import { Label } from "./ui/label"; 8 | import { Textarea } from "./ui/textarea"; 9 | import { Button } from "./ui/button"; 10 | import { toast } from "sonner"; 11 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 12 | import { FeedbackForm } from "./Feedback/feedback-form"; 13 | 14 | type Props = {}; 15 | 16 | const Feedback = (props: Props) => { 17 | const sb = createSupabaseBrowserClient(); 18 | const [isSuccess, setIsSuccess] = useState(false); 19 | 20 | useEffect(() => { 21 | if (isSuccess) { 22 | setTimeout(() => { 23 | setIsSuccess(false); 24 | }, 4000); 25 | } 26 | }, [isSuccess]); 27 | 28 | return ( 29 |25 | 37 |26 | 32 | 33 |34 | 36 |35 | 30 |59 | ); 60 | }; 61 | 62 | export default Feedback; 63 | -------------------------------------------------------------------------------- /apps/web/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ZendoLogo } from "./ZendoLogo"; 3 | import Link from "next/link"; 4 | import { IoLogoGithub, IoLogoTwitter } from "react-icons/io5"; 5 | 6 | type Props = {}; 7 | 8 | const Footer = (props: Props) => { 9 | const navLinks = [ 10 | { 11 | label: "Contact", 12 | href: "/contact", 13 | }, 14 | { 15 | label: "Pricing", 16 | href: "/pricing", 17 | }, 18 | { 19 | label: "Terms", 20 | href: "/terms", 21 | }, 22 | { 23 | label: "Privacy", 24 | href: "/privacy", 25 | }, 26 | ]; 27 | 28 | const navLinksLeft = [ 29 | { 30 | label: "Home", 31 | href: "/", 32 | }, 33 | { 34 | label: "Blog", 35 | href: "/blog", 36 | }, 37 | { 38 | label: "Docs", 39 | href: "/docs", 40 | }, 41 | ]; 42 | 43 | return ( 44 | 86 | ); 87 | }; 88 | 89 | export default Footer; 90 | -------------------------------------------------------------------------------- /apps/web/src/components/HiddenField.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { CgEye } from "react-icons/cg"; 3 | 4 | type Props = { 5 | value: string; 6 | }; 7 | export function HiddenField({ value }: Props) { 8 | const [show, setShow] = useState(false); 9 | const handleClick = () => setShow(!show); 10 | 11 | return ( 12 |{ 33 | try { 34 | const user = await sb.auth.getUser(); 35 | 36 | if (user.error) { 37 | throw user.error; 38 | } 39 | 40 | const { data, error } = await sb 41 | .from("feedback") 42 | .insert({ feedback, type, user_email: user.data.user.email }); 43 | 44 | if (error) { 45 | throw error; 46 | } 47 | 48 | toast.success("Thanks for the feedback!"); 49 | setIsSuccess(true); 50 | } catch (error) { 51 | toast.error( 52 | "Failed to submit feedback. Check the console for more details." 53 | ); 54 | console.error("Failed to submit feedback", error); 55 | } 56 | }} 57 | > 58 |13 | 19 | 22 |23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/src/components/Homepage/Cards/BaseCard.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import React, { PropsWithChildren } from "react"; 3 | 4 | type Props = { 5 | title: string; 6 | caption: string; 7 | className?: string; 8 | }; 9 | 10 | const BaseCard = (props: PropsWithChildren) => { 11 | return ( 12 | 18 |26 | ); 27 | }; 28 | 29 | export default BaseCard; 30 | -------------------------------------------------------------------------------- /apps/web/src/components/Homepage/Cards/CodeExamples.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BaseCard from "./BaseCard"; 3 | import { CodeBlock } from "@/components/CodeBlock"; 4 | 5 | type Props = {}; 6 | 7 | const CodeExamples = (props: Props) => { 8 | return ( 9 |19 |22 |{props.title}
20 |{props.caption}
21 |23 | {props.children} 24 |25 |13 | 28 | ); 29 | }; 30 | 31 | export default CodeExamples; 32 | -------------------------------------------------------------------------------- /apps/web/src/components/Insights/TextTypePicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { TextType, TextTypes } from "@/lib/ai/insights"; 3 | import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog"; 4 | import { Label } from "../ui/label"; 5 | 6 | type Props = { 7 | onTextTypeChange: (textType: TextType) => void; 8 | }; 9 | 10 | const TextTypePicker = (props: Props) => { 11 | const [selectedTextTypeVal, setSelectedTextType] = 12 | React.useState14 |27 |{`npm i @zendo/cms`} 15 |16 | {` 17 | import { createClient } from "@zendo/cms"; 18 | 19 | const cms = createClient({ 20 | blogId: env.ZENDO_BLOG_ID, 21 | }); 22 | 23 | const posts = await cms.posts.list(); 24 | `} 25 | 26 |("blog_post"); 13 | 14 | const selectedTextType = useMemo(() => { 15 | return TextTypes[selectedTextTypeVal]; 16 | }, [selectedTextTypeVal]); 17 | 18 | return ( 19 | 54 | ); 55 | }; 56 | 57 | export default TextTypePicker; 58 | -------------------------------------------------------------------------------- /apps/web/src/components/LoggedInUser.tsx: -------------------------------------------------------------------------------- 1 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 2 | import { useUser } from "@/utils/supabase/browser"; 3 | import { PropsWithChildren } from "react"; 4 | 5 | export function LoggedInUser({ children }: PropsWithChildren) { 6 | const sb = createSupabaseBrowserClient(); 7 | const user = useUser(); 8 | 9 | if (!user) { 10 | return null; 11 | } 12 | 13 | return <>{children}>; 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/src/components/LoggedInUserChecks.tsx: -------------------------------------------------------------------------------- 1 | import { useSubscriptionQuery } from "@/queries/subscription"; 2 | import { Loader2 } from "lucide-react"; 3 | import React, { PropsWithChildren } from "react"; 4 | 5 | type Props = {}; 6 | 7 | export const GlobalAppLoading = () => { 8 | return ( 9 | 10 |12 | ); 13 | }; 14 | 15 | const LoggedInUserChecks = (props: PropsWithChildren11 | ) => { 16 | const { data: subscription, isLoading } = useSubscriptionQuery(); 17 | 18 | const validSubscriptionStatus = ["active", "trialing", "past_due"]; 19 | const isValidSubscription = 20 | subscription?.status && 21 | validSubscriptionStatus.includes(subscription.status); 22 | 23 | return <>{props.children}>; 24 | }; 25 | 26 | export default LoggedInUserChecks; 27 | -------------------------------------------------------------------------------- /apps/web/src/components/MultiSelect.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/src/components/MultiSelect.tsx -------------------------------------------------------------------------------- /apps/web/src/components/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuTrigger, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | } from "./ui/dropdown-menu"; 8 | import { Button } from "./ui/button"; 9 | import { Bell, Loader2 } from "lucide-react"; 10 | import { useQuery } from "@tanstack/react-query"; 11 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 12 | 13 | type Props = {}; 14 | 15 | function useNotifications() { 16 | return useQuery({ 17 | queryKey: ["notifications"], 18 | queryFn: () => { 19 | return []; 20 | }, 21 | }); 22 | } 23 | 24 | const Notifications = (props: Props) => { 25 | const { data: notifications, isLoading } = useNotifications(); 26 | 27 | if (isLoading) { 28 | return ( 29 | 32 | ); 33 | } 34 | if (!notifications) { 35 | return null; 36 | } 37 | return ( 38 | 39 |64 | ); 65 | }; 66 | 67 | export default Notifications; 68 | -------------------------------------------------------------------------------- /apps/web/src/components/Shortcut.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | shortcut: string; 3 | }; 4 | export default function Shortcut({ shortcut }: Props) { 5 | const keys = shortcut.split(" "); 6 | 7 | const formattedKeys = keys.map((k) => { 8 | if (k === "cmd") return "⌘"; 9 | if (k === "ctrl") return "⌃"; 10 | if (k === "alt") return "⌥"; 11 | if (k === "shift") return "⇧"; 12 | return k.toUpperCase(); 13 | }); 14 | 15 | return ( 16 |40 | 63 |41 | 47 | 48 |49 | {notifications.length === 0 ? ( 50 | 62 |51 |56 | ) : ( 57 | notifications.map((notifications) => { 58 | return52 |54 | No notifications 55 |53 | ; 59 | }) 60 | )} 61 | 17 | {formattedKeys.map((k, i) => ( 18 |26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Loader, Loader2 } from "lucide-react"; 2 | 3 | export default function Spinner() { 4 | return ( 5 |22 | {k} 23 |24 | ))} 25 |6 |8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/src/components/Tags/CreateTagForm.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from "sonner"; 2 | import { Button } from "../ui/button"; 3 | import { Input } from "../ui/input"; 4 | import { generateSlug } from "@/lib/utils/slugs"; 5 | import { useState } from "react"; 6 | import { useCreateBlogTag } from "../Editor/Editor.queries"; 7 | 8 | export const CreateTagForm = ({ 9 | blogId, 10 | onSubmit, 11 | }: { 12 | blogId: string; 13 | onSubmit: () => void; 14 | }) => { 15 | const [name, setName] = useState(""); 16 | const [slug, setSlug] = useState(""); 17 | 18 | const createTag = useCreateBlogTag(); 19 | 20 | return ( 21 | <> 22 | 69 | > 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /apps/web/src/components/Tags/UpdateTagDialog.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from "sonner"; 2 | import { Button } from "../ui/button"; 3 | import { Dialog, DialogContent } from "../ui/dialog"; 4 | import { Input } from "../ui/input"; 5 | import { Label } from "../ui/label"; 6 | import { useEffect, useState } from "react"; 7 | 8 | type Props = { 9 | tag: 10 | | { 11 | tag_id: string | null; 12 | tag_name: string | null; 13 | slug: string | null; 14 | } 15 | | undefined; 16 | onSubmit: (tag: { tag_name: string; slug: string; tag_id: string }) => void; 17 | open: boolean; 18 | onOpenChange: (value: boolean) => void; 19 | }; 20 | export function UpdateTagDialog({ onSubmit, tag, open, onOpenChange }: Props) { 21 | const [newTitle, setNewTitle] = useState(tag?.tag_name || ""); 22 | const [newSlug, setNewSlug] = useState(tag?.slug || ""); 23 | 24 | useEffect(() => { 25 | setNewTitle(tag?.tag_name || ""); 26 | setNewSlug(tag?.slug || ""); 27 | }, [tag]); 28 | 29 | return ( 30 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /apps/web/src/components/Tiptap.tsx: -------------------------------------------------------------------------------- 1 | import { useEditor, EditorContent } from "@tiptap/react"; 2 | import StarterKit from "@tiptap/starter-kit"; 3 | import Heading from "@tiptap/extension-heading"; 4 | import { useForm } from "react-hook-form"; 5 | import { generateSlug } from "@/lib/utils/slugs"; 6 | 7 | const Tiptap = () => { 8 | type FormData = { 9 | title: string; 10 | slug: string; 11 | content: string; 12 | }; 13 | const { handleSubmit, register, setValue } = useForm7 | (); 14 | 15 | const editor = useEditor({ 16 | extensions: [ 17 | StarterKit, 18 | Heading.configure({ 19 | levels: [2, 3, 4, 5, 6], 20 | }), 21 | ], 22 | content: "", 23 | onUpdate(d) {}, 24 | }); 25 | 26 | return ( 27 | 28 | 61 |62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /apps/web/src/components/UserButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuSeparator, 7 | DropdownMenuTrigger, 8 | } from "./ui/dropdown-menu"; 9 | import Link from "next/link"; 10 | import { useUser } from "@/utils/supabase/browser"; 11 | import { cn } from "@/lib/utils"; 12 | import { DoorClosed, DoorOpen, UserIcon } from "lucide-react"; 13 | 14 | type Props = {}; 15 | 16 | const UserButton = (props: Props) => { 17 | const user = useUser(); 18 | 19 | return ( 20 | <> 21 |22 | 57 | > 58 | ); 59 | }; 60 | 61 | export default UserButton; 62 | -------------------------------------------------------------------------------- /apps/web/src/components/ZendoLogo.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import Image from "next/image"; 3 | import { forwardRef } from "react"; 4 | 5 | type Props = { 6 | hideText?: boolean; 7 | size?: number; 8 | className?: string; 9 | }; 10 | export function ZendoLogo(props: Props) { 11 | return ( 12 |23 | 31 |28 | {user?.email?.slice(0, 1).toUpperCase()} 29 |30 |32 | 56 |33 | 38 |34 | Signed in as 35 | {user?.email} 36 |37 |39 | 40 | 41 | 45 |42 | Account 43 | 44 | 46 | 47 | 48 | 55 |49 |52 |50 | 51 | Sign out53 | 54 |18 |26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/components/code-block.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import { Check, Copy } from "lucide-react"; 5 | import { Prism as ReactSyntaxHighlighter } from "react-syntax-highlighter"; 6 | import { dracula } from "react-syntax-highlighter/dist/esm/styles/prism"; 7 | 8 | interface CodeBlockProps { 9 | language: string; 10 | filename?: string; 11 | highlightedLines?: number[]; 12 | children: string; 13 | } 14 | 15 | export function CodeBlockComponent({ 16 | language, 17 | filename, 18 | highlightedLines = [], 19 | children, 20 | }: CodeBlockProps) { 21 | const [isCopied, setIsCopied] = useState(false); 22 | 23 | const copyToClipboard = async () => { 24 | await navigator.clipboard.writeText(children); 25 | setIsCopied(true); 26 | setTimeout(() => setIsCopied(false), 2000); 27 | }; 28 | 29 | const languagesWithoutLineNumbers = ["bash", "sh"]; 30 | 31 | const showLineNumbers = !languagesWithoutLineNumbers.includes(language); 32 | 33 | return ( 34 |24 | {!props.hideText && "zenblog"} 25 | 35 | {filename && ( 36 |87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /apps/web/src/components/confirm-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useState } from "react"; 2 | import { Dialog, DialogContent, DialogHeader } from "./ui/dialog"; 3 | import { DialogTrigger } from "@radix-ui/react-dialog"; 4 | import { Button } from "./ui/button"; 5 | 6 | type Props = { 7 | label?: string; 8 | trigger?: JSX.Element; 9 | title?: string; 10 | description?: string | JSX.Element; 11 | onConfirm: () => void; 12 | onCancel?: () => void; 13 | dialogBody?: JSX.Element; 14 | open: boolean; 15 | onOpenChange: (value: boolean) => void; 16 | }; 17 | export function ConfirmDialog({ 18 | title, 19 | onConfirm, 20 | onCancel, 21 | label, 22 | description, 23 | dialogBody, 24 | open, 25 | onOpenChange, 26 | }: PropsWithChildren37 | {filename} 38 | 49 |50 | )} 51 |52 |86 |({ 66 | style: { 67 | display: "block", 68 | width: "100%", 69 | backgroundColor: highlightedLines.includes(lineNumber) 70 | ? "#4ade8030" 71 | : "transparent", 72 | borderLeft: highlightedLines.includes(lineNumber) 73 | ? "4px solid #4ade80" 74 | : "4px solid transparent", 75 | }, 76 | })} 77 | style={dracula} 78 | customStyle={{ 79 | backgroundColor: "transparent", 80 | padding: "1rem 0", 81 | }} 82 | > 83 | {children} 84 | 85 |) { 27 | return ( 28 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /apps/web/src/components/copy-cell.tsx: -------------------------------------------------------------------------------- 1 | import { Check, Clipboard } from "lucide-react"; 2 | import React from "react"; 3 | import { Button } from "./ui/button"; 4 | 5 | type Props = { 6 | text: string; 7 | }; 8 | 9 | export const CopyCell = (props: Props) => { 10 | const [isCopied, setIsCopied] = React.useState(false); 11 | 12 | return ( 13 | 14 |28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /apps/web/src/components/dev/zenblog-toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Settings } from "lucide-react"; 2 | import { Button } from "../ui/button"; 3 | import { 4 | Dialog, 5 | DialogTitle, 6 | DialogContent, 7 | DialogHeader, 8 | DialogTrigger, 9 | } from "../ui/dialog"; 10 | import { useSubscriptionQuery } from "@/queries/subscription"; 11 | import { 12 | Select, 13 | SelectContent, 14 | SelectItem, 15 | SelectTrigger, 16 | SelectValue, 17 | } from "../ui/select"; 18 | import { PRICING_PLANS } from "@/lib/pricing.constants"; 19 | import { useQueryClient } from "@tanstack/react-query"; 20 | 21 | export function ZenblogToolbar() { 22 | const sub = useSubscriptionQuery(); 23 | const queryClient = useQueryClient(); 24 | return ( 25 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /apps/web/src/components/integration-guide.tsx: -------------------------------------------------------------------------------- 1 | import { Info } from "lucide-react"; 2 | import { CodeBlock } from "./CodeBlock"; 3 | 4 | export function IntegrationGuide({ blogId }: { blogId: string }) { 5 | return ( 6 | <> 7 |{props.text}15 | 27 |8 | 55 | > 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /apps/web/src/components/is-dev-mode.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from "react"; 2 | 3 | export const IsDevMode = ({ children }: PropsWithChildren) => { 4 | const isDev = process.env.NODE_ENV === "development"; 5 | 6 | if (!isDev) { 7 | return null; 8 | } 9 | 10 | return ( 11 |Integration guide
9 |First, install the zenblog client
10 |{`npm install zenblog`} 11 | 12 |Next, store your blog id as an environment variable
13 | 14 |15 | {`BLOG_ID=${blogId}`} 16 | 17 | 18 |19 |
22 | 23 |20 | Avoid making your blog id public. You should store it in a secure way. 21 | Now, create a client with your blog id
24 | 25 |26 | {`import { createZenblogClient } from "zenblog"; 27 | 28 | const cms = createZenblogClient({ 29 | blogId: process.env.ZENBLOG_BLOG_ID, 30 | });`} 31 | 32 | 33 |Use the client to fetch posts and render them on your website.
34 |35 | {`import { cms } from "../lib/cms"; 36 | import Link from "next/link"; 37 | 38 | const posts = await cms.posts.list(); 39 | 40 | return 51 | 52 |41 | {posts.map((post) => 42 | 46 | {post.title} 47 | 48 | )} 49 |`} 50 |That's it! 👏 Your blog page is ready.
53 |Next, you should learn how to render the posts content.
54 |12 | 13 | Development Tip: 14 | 15 | {children} 16 |17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/web/src/components/magicui/marquee.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { ComponentPropsWithoutRef } from "react"; 3 | 4 | interface MarqueeProps extends ComponentPropsWithoutRef<"div"> { 5 | /** 6 | * Optional CSS class name to apply custom styles 7 | */ 8 | className?: string; 9 | /** 10 | * Whether to reverse the animation direction 11 | * @default false 12 | */ 13 | reverse?: boolean; 14 | /** 15 | * Whether to pause the animation on hover 16 | * @default false 17 | */ 18 | pauseOnHover?: boolean; 19 | /** 20 | * Content to be displayed in the marquee 21 | */ 22 | children: React.ReactNode; 23 | /** 24 | * Whether to animate vertically instead of horizontally 25 | * @default false 26 | */ 27 | vertical?: boolean; 28 | /** 29 | * Number of times to repeat the content 30 | * @default 4 31 | */ 32 | repeat?: number; 33 | } 34 | 35 | export function Marquee({ 36 | className, 37 | reverse = false, 38 | pauseOnHover = false, 39 | children, 40 | vertical = false, 41 | repeat = 4, 42 | ...props 43 | }: MarqueeProps) { 44 | return ( 45 |56 | {Array(repeat) 57 | .fill(0) 58 | .map((_, i) => ( 59 |72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /apps/web/src/components/onboarding.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronRightIcon, CircleDashedIcon, PlusIcon } from "lucide-react"; 2 | import { Button } from "./ui/button"; 3 | import { 4 | DropdownMenuContent, 5 | DropdownMenu, 6 | DropdownMenuTrigger, 7 | DropdownMenuItem, 8 | } from "./ui/dropdown-menu"; 9 | import Link from "next/link"; 10 | import { getOnboardingItems, useOnboardingQuery } from "@/queries/onboarding"; 11 | import { useOnboardingMutation } from "@/queries/onboarding"; 12 | import { CircleCheckIcon } from "lucide-react"; 13 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; 14 | import { useRouter } from "next/router"; 15 | 16 | export function OnboardingDropdown() { 17 | const router = useRouter(); 18 | const currentBlogId = router.query.blogId as string; 19 | 20 | const items = getOnboardingItems(currentBlogId); 21 | 22 | const { data, isLoading } = useOnboardingQuery(); 23 | const { mutate: markAsDone } = useOnboardingMutation(); 24 | 25 | const allAreDone = items.every((item) => data?.[item.id]); 26 | 27 | if (allAreDone || isLoading) return null; 28 | 29 | return ( 30 | <> 31 |68 | {children} 69 |70 | ))} 71 |32 | 76 | > 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as AccordionPrimitive from "@radix-ui/react-accordion"; 3 | import { ChevronDown } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Accordion = AccordionPrimitive.Root; 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef33 | 36 | 37 |38 | {items.map((item) => ( 39 | 75 |43 | 73 | ))} 74 |44 | 65 | 69 | {item.label} 70 |45 | 60 | 61 | {data?.[item.id] ? null : ( 62 |Mark as done 63 | )} 64 |71 | 72 | , 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 14 | )); 15 | AccordionItem.displayName = "AccordionItem"; 16 | 17 | const AccordionTrigger = React.forwardRef< 18 | React.ElementRef , 19 | React.ComponentPropsWithoutRef 20 | >(({ className, children, ...props }, ref) => ( 21 | 22 | 34 | )); 35 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 36 | 37 | const AccordionContent = React.forwardRef< 38 | React.ElementRefsvg]:rotate-180", 26 | className 27 | )} 28 | {...props} 29 | > 30 | {children} 31 | 33 |32 | , 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 46 | 48 | )); 49 | 50 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 51 | 52 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; 53 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 3 | import { Check } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef{children}47 |, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 25 | )); 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 27 | 28 | export { Checkbox }; 29 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 2 | 3 | const Collapsible = CollapsiblePrimitive.Root 4 | 5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 6 | 7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 8 | 9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 10 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes22 | 24 |23 | {} 7 | 8 | const Input = React.forwardRef ( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const labelVariants = cva( 8 | "text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef , 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Popover = PopoverPrimitive.Root; 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger; 9 | 10 | const PopoverContent = React.forwardRef< 11 | React.ElementRef , 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | // 15 | 26 | )); 27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 28 | 29 | export { Popover, PopoverTrigger, PopoverContent }; 30 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/resizable.tsx: -------------------------------------------------------------------------------- 1 | import { GripVertical } from "lucide-react"; 2 | import * as ResizablePrimitive from "react-resizable-panels"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const ResizablePanelGroup = ({ 7 | className, 8 | ...props 9 | }: React.ComponentProps25 | // ) => ( 10 | 17 | ); 18 | 19 | const ResizablePanel = ResizablePrimitive.Panel; 20 | 21 | const ResizableHandle = ({ 22 | withHandle, 23 | className, 24 | ...props 25 | }: React.ComponentProps & { 26 | withHandle?: boolean; 27 | }) => ( 28 | div]:rotate-90", 31 | className 32 | )} 33 | {...props} 34 | > 35 | {withHandle && ( 36 | 41 | ); 42 | 43 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; 44 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes37 |39 | )} 40 |38 | ) { 7 | return ( 8 | 12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef , 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 24 | )); 25 | Switch.displayName = SwitchPrimitives.Root.displayName; 26 | 27 | export { Switch }; 28 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Tabs = TabsPrimitive.Root; 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef23 | , 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | TabsList.displayName = TabsPrimitive.List.displayName; 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef , 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )); 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef , 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 47 | )); 48 | TabsContent.displayName = TabsPrimitive.Content.displayName; 49 | 50 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 51 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef ( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 | 19 | ); 20 | } 21 | ); 22 | Textarea.displayName = "Textarea"; 23 | 24 | export { Textarea }; 25 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/toggle-group.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" 3 | import { VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { toggleVariants } from "@/components/ui/toggle" 7 | 8 | const ToggleGroupContext = React.createContext< 9 | VariantProps 10 | >({ 11 | size: "default", 12 | variant: "default", 13 | }) 14 | 15 | const ToggleGroup = React.forwardRef< 16 | React.ElementRef , 17 | React.ComponentPropsWithoutRef & 18 | VariantProps 19 | >(({ className, variant, size, children, ...props }, ref) => ( 20 | 25 | 29 | )) 30 | 31 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName 32 | 33 | const ToggleGroupItem = React.forwardRef< 34 | React.ElementRef26 | {children} 27 | 28 |, 35 | React.ComponentPropsWithoutRef & 36 | VariantProps 37 | >(({ className, children, variant, size, ...props }, ref) => { 38 | const context = React.useContext(ToggleGroupContext) 39 | 40 | return ( 41 | 52 | {children} 53 | 54 | ) 55 | }) 56 | 57 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName 58 | 59 | export { ToggleGroup, ToggleGroupItem } 60 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TogglePrimitive from "@radix-ui/react-toggle" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const toggleVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-transparent", 13 | outline: 14 | "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", 15 | }, 16 | size: { 17 | default: "h-10 px-3", 18 | sm: "h-9 px-2.5", 19 | lg: "h-11 px-5", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | size: "default", 25 | }, 26 | } 27 | ) 28 | 29 | const Toggle = React.forwardRef< 30 | React.ElementRef, 31 | React.ComponentPropsWithoutRef & 32 | VariantProps 33 | >(({ className, variant, size, ...props }, ref) => ( 34 | 39 | )) 40 | 41 | Toggle.displayName = TogglePrimitive.Root.displayName 42 | 43 | export { Toggle, toggleVariants } 44 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider; 7 | 8 | const Tooltip = TooltipPrimitive.Root; 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger; 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef , 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 25 | )); 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 27 | 28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 29 | -------------------------------------------------------------------------------- /apps/web/src/env.mjs: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createEnv } from "@t3-oss/env-nextjs"; 3 | 4 | export const env = createEnv({ 5 | /** 6 | * Specify your server-side environment variables schema here. This way you can ensure the app 7 | * isn't built with invalid env vars. 8 | */ 9 | server: { 10 | NODE_ENV: z.enum([ "development", "test", "production" ]), 11 | }, 12 | 13 | /** 14 | * Specify your client-side environment variables schema here. This way you can ensure the app 15 | * isn't built with invalid env vars. To expose them to the client, prefix them with 16 | * `NEXT_PUBLIC_`. 17 | */ 18 | client: { 19 | NEXT_PUBLIC_SUPABASE_URL: z.string(), 20 | NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string(), 21 | // NEXT_PUBLIC_CLIENTVAR: z.string().min(1), 22 | }, 23 | 24 | /** 25 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. 26 | * middlewares) or client-side so we need to destruct manually. 27 | */ 28 | runtimeEnv: { 29 | NODE_ENV: process.env.NODE_ENV, 30 | NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, 31 | NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, 32 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /apps/web/src/hooks/use-blog-id.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | export function useBlogId() { 4 | const router = useRouter(); 5 | const blogId = router.query.blogId as string; 6 | 7 | return blogId; 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useKeyboard.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | type Key = "ctrl" | "shift" | "alt" | string; 4 | 5 | export const useKeyboardShortcut = (keys: Key[], callback: () => void) => { 6 | useEffect(() => { 7 | const handleKeyDown = (event: KeyboardEvent) => { 8 | if ( 9 | keys.every( 10 | (key) => 11 | (key === "ctrl" && event.ctrlKey) || 12 | (key === "shift" && event.shiftKey) || 13 | (key === "alt" && event.altKey) || 14 | (key === "cmd" && event.metaKey) || 15 | (typeof key === "string" && event.key.toLowerCase() === key) 16 | ) 17 | ) { 18 | event.preventDefault(); 19 | callback(); 20 | } 21 | }; 22 | 23 | window.addEventListener("keydown", handleKeyDown); 24 | 25 | return () => { 26 | window.removeEventListener("keydown", handleKeyDown); 27 | }; 28 | }, [keys, callback]); 29 | }; 30 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useRouterTabs.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | export function useRouterTabs(name = "tab") { 4 | const router = useRouter(); 5 | 6 | return { 7 | tabValue: router.query.tab as string, 8 | onTabChange: (tab: string) => { 9 | router.push({ 10 | query: { 11 | ...router.query, 12 | [name]: tab, 13 | }, 14 | }); 15 | }, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/src/lib/client/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useLocalStorage = ( 4 | key: string, 5 | initialValue: T 6 | // eslint-disable-next-line no-unused-vars 7 | ): [T, (value: T) => void] => { 8 | const [storedValue, setStoredValue] = useState(initialValue); 9 | 10 | useEffect(() => { 11 | // Retrieve from localStorage 12 | const item = window.localStorage.getItem(key); 13 | if (item) { 14 | setStoredValue(JSON.parse(item)); 15 | } 16 | }, [key]); 17 | 18 | const setValue = (value: T) => { 19 | // Save state 20 | setStoredValue(value); 21 | // Save to localStorage 22 | window.localStorage.setItem(key, JSON.stringify(value)); 23 | }; 24 | return [storedValue, setValue]; 25 | }; 26 | 27 | export default useLocalStorage; 28 | -------------------------------------------------------------------------------- /apps/web/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL as string; 2 | -------------------------------------------------------------------------------- /apps/web/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const RESERVED_SLUGS = [ 2 | "admin", 3 | "api", 4 | "auth", 5 | "auth-callback", 6 | "profile", 7 | "settings", 8 | "subscriptions", 9 | "posts", 10 | "blogs", 11 | "editor", 12 | "writer", 13 | "categories", 14 | "tags", 15 | "comments", 16 | "reactions", 17 | "notifications", 18 | "search", 19 | "offline", 20 | "authors", 21 | "about", 22 | "contact", 23 | "terms", 24 | "privacy", 25 | "cookies", 26 | "gdpr", 27 | "jobs", 28 | "pricing", 29 | "zen", 30 | "dashboard", 31 | "home", 32 | "login", 33 | "register", 34 | "docs", 35 | "careers", 36 | "team", 37 | "support", 38 | "status", 39 | "blog", 40 | "themes", 41 | "plugins", 42 | "integrations", 43 | "webhooks", 44 | "www", 45 | 46 | // Premium names 47 | "jon", 48 | "david", 49 | "michael", 50 | "james", 51 | "robert", 52 | "john", 53 | "william", 54 | "richard", 55 | "will", 56 | "tom", 57 | "tim", 58 | "joe", 59 | "jim", 60 | "jake", 61 | "jason", 62 | "paul", 63 | "peter", 64 | "phil", 65 | "philip", 66 | "steve", 67 | "stephen", 68 | "stevie", 69 | "steven", 70 | "chris", 71 | "brian", 72 | "ben", 73 | "bill", 74 | ]; 75 | 76 | export const IS_DEV = process.env.NODE_ENV === "development"; 77 | -------------------------------------------------------------------------------- /apps/web/src/lib/create-id.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from "nanoid"; 2 | 3 | type Type = "blog"; 4 | 5 | export const nanoid = customAlphabet( 6 | "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 7 | ); 8 | 9 | const PREFIXES: Record = { 10 | blog: "blog", 11 | }; 12 | 13 | const LENGTHS: Record = { 14 | blog: 64, 15 | }; 16 | 17 | export function createId({ secret, type }: { secret: boolean; type: Type }) { 18 | const key = nanoid(LENGTHS[type]); 19 | 20 | const keyWithPrefix = `${PREFIXES[type]}_${key}`; 21 | 22 | if (secret) { 23 | return `sk_${keyWithPrefix}`; 24 | } 25 | 26 | return `pk_${keyWithPrefix}`; 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/lib/models/blogs/Blogs.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "@/types/supabase"; 2 | import { z } from "zod"; 3 | 4 | export type BD_BLOG = Database["public"]["Tables"]["blogs"]["Row"]; 5 | 6 | const BASE_BLOG = { 7 | id: z.string(), 8 | title: z.string(), 9 | emoji: z.string(), 10 | description: z.string(), 11 | created_at: z.string(), 12 | }; 13 | 14 | export const Blog = z.object(BASE_BLOG); 15 | 16 | export type Blog = z.infer ; 17 | 18 | export const GetBlogRes = z.object(BASE_BLOG); 19 | 20 | export type GetBlogRes = z.infer ; 21 | 22 | export const PatchBlog = z.object({ 23 | title: BASE_BLOG.title.optional(), 24 | emoji: BASE_BLOG.emoji.optional(), 25 | description: BASE_BLOG.description.optional(), 26 | }); 27 | 28 | export type PatchBlog = z.infer ; 29 | 30 | export const DeleteBlogRes = z.object({ 31 | success: z.boolean(), 32 | }); 33 | 34 | export type DeleteBlogRes = z.infer ; 35 | -------------------------------------------------------------------------------- /apps/web/src/lib/models/posts/Posts.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "@/types/supabase"; 2 | import { z } from "zod"; 3 | 4 | export type DBPost = Database["public"]["Tables"]["posts"]["Row"]; 5 | 6 | export const getPostBySlugRes = z.object({ 7 | id: z.string(), 8 | title: z.string(), 9 | slug: z.string(), 10 | content: z.any(), 11 | published: z.boolean(), 12 | created_at: z.string(), 13 | updated_at: z.string(), 14 | blog_id: z.string(), 15 | user_id: z.string(), 16 | cover_image: z.string().optional(), 17 | metadata: z.any().optional(), 18 | }); 19 | 20 | export const getPostsRes = z.object({ 21 | blog: z.object({ 22 | id: z.string(), 23 | title: z.string(), 24 | emoji: z.string(), 25 | }), 26 | posts: z 27 | .array( 28 | z.object({ 29 | id: z.string(), 30 | title: z.string(), 31 | slug: z.string(), 32 | published: z.boolean(), 33 | created_at: z.string(), 34 | updated_at: z.string(), 35 | blog_id: z.string(), 36 | user_id: z.string(), 37 | cover_image: z.string().nullable(), 38 | }) 39 | ) 40 | .optional(), 41 | }); 42 | 43 | export type getPostsRes = z.infer ; 44 | 45 | export const PatchPost = z.object({ 46 | title: z.string(), 47 | slug: z.string(), 48 | content: z.any(), 49 | cover_image: z.string().nullable(), 50 | published: z.boolean(), 51 | metadata: z.any().nullable(), 52 | }); 53 | -------------------------------------------------------------------------------- /apps/web/src/lib/pricing.constants.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const TRIAL_PERIOD_DAYS = 14; 4 | 5 | export const PricingPlanId = z.enum(["pro", "free"]); 6 | export type PricingPlanId = z.infer ; 7 | export const isPricingPlanId = (value: string): value is PricingPlanId => 8 | PricingPlanId.safeParse(value).success; 9 | 10 | export const PricingPlanInterval = z.enum(["month", "year"]); 11 | export type PricingPlanIntervalType = z.infer ; 12 | export const isPricingPlanInterval = ( 13 | value: string 14 | ): value is PricingPlanIntervalType => 15 | PricingPlanInterval.safeParse(value).success; 16 | 17 | export type PricingPlan = { 18 | id: PricingPlanId; 19 | highlight?: boolean; 20 | title: string; 21 | description: string; 22 | monthlyPrice: number; 23 | yearlyPrice: number; 24 | features: string[]; 25 | }; 26 | 27 | /** 28 | * ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️ 29 | * ! Changing these values will change the pricing of the plans in Stripe. 30 | * ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️ 31 | */ 32 | 33 | export const MAX_BLOGS_PER_PLAN: Record = { 34 | free: 1, 35 | pro: 999, 36 | }; 37 | 38 | export const PRICING_PLANS: PricingPlan[] = [ 39 | // ALWAYS KEEP FREE PLAN FIRST IN THE ARRAY 40 | { 41 | id: "free", 42 | title: "Free", 43 | description: "For personal blogs or small projects", 44 | monthlyPrice: 0, 45 | yearlyPrice: 0, 46 | features: [ 47 | "1 blog", 48 | "1 author", 49 | "Unlimited posts", 50 | "Limited files", 51 | "40k API requests per month", 52 | "Limited images", 53 | "Limited videos", 54 | "Email support", 55 | ], 56 | }, 57 | { 58 | id: "pro", 59 | title: "Pro", 60 | description: "For growing teams", 61 | monthlyPrice: 20, 62 | yearlyPrice: 200, 63 | features: [ 64 | "Unlimited blogs", 65 | "Unlimited authors", 66 | "Unlimited posts", 67 | "Unlimited categories", 68 | "Unlimited tags", 69 | "Unlimited API requests", 70 | "Unlimited images *", 71 | "Unlimited videos *", 72 | "Email support", 73 | ], 74 | }, 75 | ]; 76 | -------------------------------------------------------------------------------- /apps/web/src/lib/server/deprecated/supabase.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "@/types/supabase"; 2 | import { 3 | createServerClient, 4 | type CookieOptions, 5 | serialize, 6 | } from "@supabase/ssr"; 7 | import type { NextApiRequest, NextApiResponse } from "next"; 8 | 9 | export async function getServerClient( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | try { 14 | const supabase = createServerClient ( 15 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 16 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 17 | { 18 | cookies: { 19 | get(name: string) { 20 | return req.cookies[name]; 21 | }, 22 | set(name: string, value: string, options: CookieOptions) { 23 | res.appendHeader("Set-Cookie", serialize(name, value, options)); 24 | }, 25 | remove(name: string, options: CookieOptions) { 26 | res.appendHeader("Set-Cookie", serialize(name, "", options)); 27 | }, 28 | }, 29 | } 30 | ); 31 | 32 | const userRes = await supabase.auth.getUser(); 33 | 34 | return { 35 | user: userRes?.data.user, 36 | db: supabase, 37 | }; 38 | } catch (error) { 39 | console.error(error); 40 | throw error; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/web/src/lib/server/stripe.constants.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordienr/zenblog/5f32f66370eff35bb59e24893fc5ef2e31ce6571/apps/web/src/lib/server/stripe.constants.ts -------------------------------------------------------------------------------- /apps/web/src/lib/server/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | 3 | export function createStripeClient() { 4 | const stripeKey = process.env.STRIPE_SECRET_KEY; 5 | if (!stripeKey) { 6 | throw new Error("Error getting stripe key."); 7 | } 8 | 9 | return new Stripe(stripeKey); 10 | } 11 | 12 | export async function createOrRetrieveCustomer({ 13 | userId, 14 | email, 15 | }: { 16 | userId: string; 17 | email: string; 18 | }): Promise { 19 | const stripe = createStripeClient(); 20 | 21 | const customers = await stripe.customers.search({ 22 | query: "metadata['userId']:'" + userId + "'", 23 | }); 24 | 25 | if (customers.data.length > 0) { 26 | const customer = customers.data[0]; 27 | 28 | if (!customer) { 29 | throw new Error("Error retrieving customer"); 30 | } 31 | 32 | return customer; 33 | } else { 34 | const newCustomer = await stripe.customers.create({ 35 | email, 36 | metadata: { 37 | userId, 38 | }, 39 | }); 40 | 41 | return newCustomer; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /apps/web/src/lib/server/supabase/admin.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "@/types/supabase"; 2 | import { createClient } from "@supabase/supabase-js"; 3 | 4 | export function createAdminClient() { 5 | const url = process.env.NEXT_PUBLIC_SUPABASE_URL; 6 | const key = process.env.SUPABASE_SERVICE_ROLE; 7 | 8 | if (!url || !key) { 9 | throw new Error("Missing env variables for Supabase"); 10 | } 11 | 12 | const client = createClient (url, key); 13 | 14 | return client; 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/src/lib/server/supabase/index.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "@/types/supabase"; 2 | import { createServerClient } from "@supabase/ssr"; 3 | import { cookies } from "next/headers"; 4 | 5 | export const createClient = () => { 6 | const cookieStore = cookies(); 7 | 8 | return createServerClient ( 9 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 10 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 11 | { 12 | cookies: { 13 | getAll() { 14 | return cookieStore.getAll(); 15 | }, 16 | setAll(cookiesToSet) { 17 | try { 18 | cookiesToSet.forEach(({ name, value, options }) => { 19 | cookieStore.set(name, value, options); 20 | }); 21 | } catch (error) { 22 | // The `set` method was called from a Server Component. 23 | // This can be ignored if you have middleware refreshing 24 | // user sessions. 25 | } 26 | }, 27 | }, 28 | } 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/web/src/lib/supabase.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from "@/types/supabase"; 2 | import { env } from "@/env.mjs"; 3 | import { createBrowserClient } from "@supabase/ssr"; 4 | 5 | const supabaseUrl = env.NEXT_PUBLIC_SUPABASE_URL; 6 | const supabaseKey = env.NEXT_PUBLIC_SUPABASE_ANON_KEY; 7 | 8 | if (!supabaseKey) { 9 | throw new Error("Missing supabaseKey"); 10 | } 11 | 12 | export function createSupabaseBrowserClient() { 13 | if (!supabaseKey) { 14 | throw new Error("Missing supabaseKey"); 15 | } 16 | const supabase = createBrowserClient (supabaseUrl, supabaseKey); 17 | 18 | return supabase; 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function formatDate(date: string, options?: Intl.DateTimeFormatOptions) { 9 | return new Date(date).toLocaleDateString("en-US", { 10 | month: "long", 11 | day: "numeric", 12 | year: "numeric", 13 | ...options, 14 | }); 15 | } 16 | 17 | const videoExtensions = [ 18 | ".mp4", 19 | ".mov", 20 | ".avi", 21 | ".wmv", 22 | ".flv", 23 | ".mpeg", 24 | ".mpg", 25 | ".m4v", 26 | ".webm", 27 | ".ogg", 28 | ".m3u8", 29 | ".ts", 30 | ".m3u8", 31 | ".m3u8", 32 | ]; 33 | const imageExtensions = [ 34 | ".jpg", 35 | ".jpeg", 36 | ".png", 37 | ".gif", 38 | ".bmp", 39 | ".webp", 40 | ".svg", 41 | ".ico", 42 | ".heic", 43 | ".heif", 44 | ".hevc", 45 | ".heif", 46 | ]; 47 | 48 | export function getMediaType(url: string): "video" | "image" { 49 | if (url.includes("youtube.com")) { 50 | return "video"; 51 | } 52 | // ends in video extension 53 | if (videoExtensions.some((ext) => url.endsWith(ext))) { 54 | return "video"; 55 | } 56 | // ends in image extension 57 | if (imageExtensions.some((ext) => url.endsWith(ext))) { 58 | return "image"; 59 | } 60 | return "image"; 61 | } 62 | 63 | // Utility function to format bytes 64 | export function formatBytes(bytes?: number | null, decimals = 1) { 65 | if (!bytes || bytes === 0) return "0 Bytes"; 66 | 67 | const k = 1024; 68 | const dm = decimals < 0 ? 0 : decimals; 69 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 70 | 71 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 72 | 73 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; 74 | } 75 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils/auth.ts: -------------------------------------------------------------------------------- 1 | export const publicPaths = ["/", "/sign-in*", "/sign-up*"]; 2 | 3 | export const isPublicPath = (path: string) => { 4 | return publicPaths.find((x) => 5 | path.match(new RegExp(`^${x}$`.replace("*$", "($|/)"))) 6 | ); 7 | }; 8 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils/slugs.ts: -------------------------------------------------------------------------------- 1 | export function generateSlug(title: string): string { 2 | const normalizedTitle: string = title.toLowerCase().replace(/[^\w\s]/g, ""); 3 | const slug: string = normalizedTitle.replace(/\s+/g, "-"); 4 | 5 | // Replace accented characters with their non-accented counterparts 6 | // https://stackoverflow.com/a/37511463/3015595 7 | const normalizedSlug = slug 8 | .normalize("NFKD") 9 | .replace(/\p{Diacritic}/gu, "") 10 | .replace(/-$/, ""); 11 | 12 | return normalizedSlug; 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { updateSession } from "./utils/supabase/middleware"; 3 | 4 | // inspired by https://github.com/vercel/platforms/blob/main/middleware.ts 5 | 6 | export const config = { 7 | matcher: [ 8 | /* 9 | * Match all paths except for: 10 | * 1. /api routes 11 | * 2. /_next (Next.js internals) 12 | * 3. /_static (inside /public) 13 | * 4. all root files inside /public (e.g. /favicon.ico) 14 | */ 15 | "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)", 16 | ], 17 | }; 18 | 19 | const invalidSubdomains = [ 20 | "www", 21 | "localhost:3000", 22 | "localhost:8082", 23 | "zenblog", 24 | "127", 25 | ]; 26 | 27 | export default async function middleware(req: NextRequest) { 28 | const subdomain = req.headers.get("host")?.split(".")[0]; 29 | const path = req.nextUrl.pathname; 30 | 31 | if (!subdomain || invalidSubdomains.includes(subdomain)) { 32 | return await updateSession(req); 33 | } 34 | 35 | const newPath = `/pub/${subdomain}${path}`; 36 | const url = new URL(newPath, req.url); 37 | 38 | return NextResponse.rewrite(url); 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import { Inter, IBM_Plex_Mono } from "next/font/google"; 3 | import "@/styles/globals.css"; 4 | import { useRouter } from "next/router"; 5 | import PlausibleProvider from "next-plausible"; 6 | import { 7 | HydrationBoundary, 8 | QueryClient, 9 | QueryClientProvider, 10 | } from "@tanstack/react-query"; 11 | import { Toaster } from "sonner"; 12 | import { useState } from "react"; 13 | import { UserProvider } from "@/utils/supabase/browser"; 14 | import { trpc } from "@/trpc/utils"; 15 | 16 | // Fonts 17 | const inter = Inter({ 18 | subsets: ["latin"], 19 | display: "swap", 20 | variable: "--font-sans", 21 | }); 22 | 23 | const ibmPlexMono = IBM_Plex_Mono({ 24 | subsets: ["latin"], 25 | weight: ["400", "500", "600"], 26 | display: "swap", 27 | variable: "--font-mono", 28 | }); 29 | 30 | // Main Component 31 | function MyApp({ Component, pageProps }: AppProps) { 32 | const { pathname, isReady } = useRouter(); 33 | const [queryClient] = useState( 34 | () => 35 | new QueryClient({ 36 | defaultOptions: { 37 | queries: { 38 | refetchOnWindowFocus: false, 39 | }, 40 | }, 41 | }) 42 | ); 43 | 44 | return ( 45 | 46 |68 | ); 69 | } 70 | 71 | export default trpc.withTRPC(MyApp); 72 | -------------------------------------------------------------------------------- /apps/web/src/pages/blogs/[blogId]/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import AppLayout from "@/layouts/AppLayout"; 3 | import React from "react"; 4 | 5 | type Props = {}; 6 | 7 | const Index = (props: Props) => { 8 | return ( 9 |47 | 67 |48 | 66 |49 | 65 |50 | 64 |51 | 63 | 10 | 12 | ); 13 | }; 14 | 15 | export default Index; 16 | -------------------------------------------------------------------------------- /apps/web/src/pages/blogs/[blogId]/usage/index.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/layouts/AppLayout"; 2 | import { 3 | Table, 4 | TableBody, 5 | TableCaption, 6 | TableCell, 7 | TableHead, 8 | TableHeader, 9 | TableRow, 10 | } from "@/components/ui/table"; 11 | import { useQuery } from "@tanstack/react-query"; 12 | import { getApiUsageForBlog } from "lib/axiom"; 13 | import { useRouter } from "next/router"; 14 | import { Skeleton } from "@/components/ui/skeleton"; 15 | import { API } from "app/utils/api-client"; 16 | 17 | export default function BlogAnalytics() { 18 | const router = useRouter(); 19 | const blogId = router.query.blogId as string; 20 | 21 | // start time = the start of current month 22 | // end time = now 23 | const startTime = new Date( 24 | new Date().getFullYear(), 25 | new Date().getMonth(), 26 | 1 27 | ).toISOString(); 28 | const endTime = new Date().toISOString(); 29 | 30 | const { data, isLoading } = useQuery({ 31 | queryKey: ["api-usage", startTime, endTime], 32 | queryFn: () => 33 | API().v2.blogs[":blog_id"].usage.$get({ 34 | param: { blog_id: blogId }, 35 | query: { start_time: startTime, end_time: endTime }, 36 | }), 37 | enabled: !!blogId, 38 | }); 39 | 40 | return ( 41 |1. Publish your first Post 2. Integrate into your app11 |42 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /apps/web/src/pages/blogs/_/[...rest].tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/layouts/AppLayout"; 2 | import { useBlogsQuery } from "@/queries/blogs"; 3 | import { ChevronRight } from "lucide-react"; 4 | import Link from "next/link"; 5 | import { useRouter } from "next/router"; 6 | 7 | export default function CatchAllBlogPickerPage() { 8 | const { data: blogs } = useBlogsQuery({ enabled: true }); 9 | 10 | const router = useRouter(); 11 | const blogIdToURL = (blogId: string) => router.asPath.replace(/_/, blogId); 12 | 13 | return ( 14 |43 | {isLoading ? ( 44 |49 |45 | ) : ( 46 | {JSON.stringify(data, null, 2)}47 | )} 48 |15 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/web/src/pages/reset-password-confirmation.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Input } from "@/components/ui/input"; 3 | import { Label } from "@/components/ui/label"; 4 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 5 | import { useRouter } from "next/router"; 6 | import { useEffect, useState } from "react"; 7 | import { toast } from "sonner"; 8 | 9 | export default function ResetPasswordConfirmation() { 10 | const [loading, setLoading] = useState(false); 11 | const supabase = createSupabaseBrowserClient(); 12 | const router = useRouter(); 13 | 14 | useEffect(() => { 15 | supabase.auth.getUser().then((res) => { 16 | if (!res.data.user) { 17 | router.push("/sign-in"); 18 | } 19 | }); 20 | }, []); 21 | 22 | async function onSubmit(e: React.FormEvent16 | {blogs?.map((blog) => ( 17 | 22 |33 |23 |27 |{blog.emoji}24 |{blog.title}25 |{blog.description}26 |28 |30 | 31 | ))} 32 |29 | ) { 23 | e.preventDefault(); 24 | 25 | setLoading(true); 26 | const formData = new FormData(e.currentTarget); 27 | const password = formData.get("password") as string; 28 | const password2 = formData.get("password2") as string; 29 | 30 | if (password !== password2) { 31 | alert("Passwords do not match"); 32 | return; 33 | } 34 | 35 | const { data, error } = await supabase.auth.updateUser({ 36 | password, 37 | }); 38 | if (error) { 39 | alert(error.message); 40 | } 41 | 42 | alert("Password updated"); 43 | toast.success("Password updated"); 44 | router.push("/blogs"); 45 | setLoading(false); 46 | } 47 | 48 | return ( 49 | <> 50 | 64 | > 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /apps/web/src/pages/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from "@/components/Spinner"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Label } from "@/components/ui/label"; 5 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 6 | import Link from "next/link"; 7 | import { useRouter } from "next/router"; 8 | import { useState } from "react"; 9 | 10 | export default function ResetPassword() { 11 | const [loading, setLoading] = useState(false); 12 | const [step1Success, setStep1Success] = useState(false); 13 | const supabase = createSupabaseBrowserClient(); 14 | const router = useRouter(); 15 | 16 | async function onSubmitStep1(e: React.FormEvent ) { 17 | e.preventDefault(); 18 | 19 | setLoading(true); 20 | const form = e.currentTarget; 21 | const email = form.email.value; 22 | 23 | const url = process.env.NEXT_PUBLIC_BASE_URL; 24 | 25 | const { data, error } = await supabase.auth.resetPasswordForEmail(email, { 26 | redirectTo: `${url || ""}/reset-password-confirmation`, 27 | }); 28 | 29 | if (error) { 30 | alert(error.message); 31 | setLoading(false); 32 | return; 33 | } 34 | 35 | setStep1Success(true); 36 | setLoading(false); 37 | } 38 | 39 | if (step1Success) { 40 | return ( 41 | <> 42 | 43 |51 | > 52 | ); 53 | } 54 | 55 | return ( 56 | <> 57 | 77 | > 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /apps/web/src/pages/sign-out.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from "@/components/Spinner"; 2 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 3 | import { useEffect } from "react"; 4 | 5 | export default function SignOut() { 6 | const supa = createSupabaseBrowserClient(); 7 | 8 | useEffect(() => { 9 | supa.auth.signOut().then((res) => { 10 | console.log("Sign out: ", res); 11 | if (res.error) { 12 | console.error(res.error); 13 | alert("An error occurred while signing out. Please try again."); 14 | return; 15 | } 16 | 17 | window.location.pathname = "/"; 18 | }); 19 | }, [supa]); 20 | 21 | return ( 22 |Reset password
44 |45 | We have sent you a link to reset your password. 46 |
47 | 48 | Open gmail 49 | 50 |23 |25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/src/pages/test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | 3 | export default function Test() { 4 | return ( 5 |24 | 6 |23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/src/pages/uploader.tsx: -------------------------------------------------------------------------------- 1 | import { ImageUploader } from "@/components/Images/ImageUploader"; 2 | import { useBlogId } from "@/hooks/use-blog-id"; 3 | 4 | export default function Uploader() { 5 | const blogId = useBlogId(); 6 | 7 | return ( 8 |7 |22 |8 |21 |9 |20 |16 |
17 | Hello world 18 |
19 |9 |11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/src/queries/onboarding.ts: -------------------------------------------------------------------------------- 1 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 3 | import { toast } from "sonner"; 4 | 5 | export const onboardingKeys = ["onboarding_steps"]; 6 | 7 | export const getOnboardingItems = (currentBlogId: string) => 8 | [ 9 | { 10 | id: "has_blog", 11 | label: "Create your blog", 12 | href: "/blogs/create", 13 | }, 14 | { 15 | id: "has_published_post", 16 | label: "Publish your first post", 17 | href: `/blogs/${currentBlogId || "_"}/create`, 18 | }, 19 | { 20 | id: "has_integrated_api", 21 | label: "Integrate to your website", 22 | href: "/docs/getting-started", 23 | }, 24 | ] as const; 25 | 26 | type OnboardingSteps = { 27 | has_blog: boolean; 28 | has_published_post: boolean; 29 | has_integrated_api: boolean; 30 | }; 31 | 32 | export const useOnboardingQuery = () => { 33 | const sb = createSupabaseBrowserClient(); 34 | 35 | return useQuery({ 36 | queryKey: onboardingKeys, 37 | queryFn: async () => { 38 | const { data } = await sb 39 | .from("onboarding_steps") 40 | .select("has_blog, has_published_post, has_integrated_api") 41 | .limit(1) 42 | .throwOnError(); 43 | 44 | return ( 45 | data?.[0] || { 46 | has_blog: false, 47 | has_published_post: false, 48 | has_integrated_api: false, 49 | } 50 | ); 51 | }, 52 | }); 53 | }; 54 | 55 | export const useOnboardingMutation = () => { 56 | const sb = createSupabaseBrowserClient(); 57 | const queryClient = useQueryClient(); 58 | return useMutation({ 59 | mutationFn: async (step: keyof OnboardingSteps) => { 60 | const { data } = await sb.auth.getUser(); 61 | if (!data.user?.id) return; 62 | await sb 63 | .from("onboarding_steps") 64 | .upsert( 65 | { [step]: true, user_id: data.user.id }, 66 | { 67 | onConflict: "user_id", 68 | } 69 | ) 70 | .eq("user_id", data.user.id) 71 | .throwOnError(); 72 | }, 73 | onSuccess: () => { 74 | toast.success("Onboarding step completed"); 75 | queryClient.invalidateQueries({ queryKey: onboardingKeys }); 76 | }, 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /apps/web/src/queries/prices.ts: -------------------------------------------------------------------------------- 1 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import Stripe from "stripe"; 4 | 5 | const PRICES_KEYS = ["prices"]; 6 | 7 | export function usePricesQuery() { 8 | const sb = createSupabaseBrowserClient(); 9 | 10 | return useQuery({ 11 | queryKey: PRICES_KEYS, 12 | queryFn: async () => { 13 | const { data, error } = await sb.from("prices").select("*"); 14 | 15 | if (error) { 16 | console.error(error); 17 | throw error; 18 | } 19 | 20 | type DataItemType = (typeof data)[0] & { price: Stripe.Price }; 21 | 22 | return data as DataItemType[]; 23 | }, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/src/queries/products.ts: -------------------------------------------------------------------------------- 1 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import Stripe from "stripe"; 4 | 5 | export function useProductsQuery() { 6 | const sb = createSupabaseBrowserClient(); 7 | 8 | return useQuery({ 9 | queryKey: ["products"], 10 | queryFn: async () => { 11 | const { data, error } = await sb.from("products").select("*"); 12 | 13 | if (error) { 14 | console.error(error); 15 | throw error; 16 | } 17 | 18 | type DataItemType = (typeof data)[0] & { product: Stripe.Product }; 19 | 20 | return data as DataItemType[]; 21 | }, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/queries/subscription.ts: -------------------------------------------------------------------------------- 1 | import { PricingPlanId } from "@/lib/pricing.constants"; 2 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 3 | import { useUser } from "@/utils/supabase/browser"; 4 | import { useQuery } from "@tanstack/react-query"; 5 | import Stripe from "stripe"; 6 | 7 | const SUBSCRIPTION_KEYS = ["subscription"]; 8 | 9 | /** 10 | * Returns the user's subscription data. 11 | * @returns 12 | * - plan: The user's subscription plan. 13 | * - interval: The user's subscription interval. 14 | * - status: The user's subscription status. 15 | * - isValidSubscription: Whether the user's subscription is valid (active, trialing, or past due). 16 | */ 17 | export function useSubscriptionQuery() { 18 | const sb = createSupabaseBrowserClient(); 19 | const user = useUser(); 20 | 21 | return useQuery({ 22 | queryKey: SUBSCRIPTION_KEYS, 23 | enabled: !!user, 24 | queryFn: async () => { 25 | const { data } = await sb 26 | .from("subscriptions") 27 | .select("subscription") 28 | .eq("user_id", user?.id || "") 29 | .limit(1) 30 | .throwOnError(); 31 | 32 | const res = data?.[0]?.subscription as unknown as Stripe.Subscription; 33 | 34 | const plan = (res?.metadata?.plan_id as PricingPlanId) || "free"; 35 | const interval = res?.items?.data[0]?.plan?.interval as 36 | | Stripe.Plan.Interval 37 | | undefined; 38 | const status = res?.status; 39 | 40 | const validSubscriptionStatus = ["active", "trialing", "past_due"]; 41 | const isValidSubscription = 42 | status && validSubscriptionStatus.includes(status); 43 | 44 | return { 45 | plan, 46 | interval, 47 | status, 48 | isValidSubscription, 49 | subscription: res, 50 | }; 51 | }, 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /apps/web/src/queries/tags.ts: -------------------------------------------------------------------------------- 1 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 3 | 4 | export const tagKeys = { 5 | tags: (blogId: string) => ["tags", blogId], 6 | postTags: (postId: string) => ["postTags", postId], 7 | tag: (tagId: string) => ["tags", tagId], 8 | }; 9 | 10 | export function useTagsWithUsageQuery( 11 | { blogId }: { blogId: string }, 12 | { 13 | enabled, 14 | }: { 15 | enabled: boolean; 16 | } 17 | ) { 18 | const supa = createSupabaseBrowserClient(); 19 | 20 | return useQuery({ 21 | queryKey: tagKeys.tags(blogId), 22 | enabled: !!blogId && enabled, 23 | queryFn: async () => { 24 | const { data } = await supa 25 | .from("tag_usage_count_v2") 26 | .select("*") 27 | .eq("blog_id", blogId); 28 | 29 | return data; 30 | }, 31 | }); 32 | } 33 | 34 | export function useDeleteTagMutation(blogId: string) { 35 | const queryClient = useQueryClient(); 36 | const supa = createSupabaseBrowserClient(); 37 | 38 | return useMutation({ 39 | mutationFn: async (tagId: string) => { 40 | const res = await supa 41 | .from("tags") 42 | .delete() 43 | .eq("id", tagId) 44 | .eq("blog_id", blogId); 45 | 46 | if (res.error) { 47 | throw new Error(res.error.message); 48 | } 49 | 50 | return res; 51 | }, 52 | onSuccess: () => { 53 | queryClient.invalidateQueries({ queryKey: tagKeys.tags(blogId) }); 54 | }, 55 | }); 56 | } 57 | 58 | export function useUpdateTagMutation(blogId: string) { 59 | const queryClient = useQueryClient(); 60 | const supa = createSupabaseBrowserClient(); 61 | 62 | return useMutation({ 63 | mutationFn: async (tag: { id: string; name: string; slug: string }) => { 64 | const res = await supa.from("tags").update(tag).eq("id", tag.id); 65 | 66 | if (res.error) { 67 | throw new Error(res.error.message); 68 | } 69 | 70 | return res; 71 | }, 72 | onSuccess: () => { 73 | queryClient.invalidateQueries({ queryKey: tagKeys.tags(blogId) }); 74 | }, 75 | }); 76 | } 77 | 78 | export function usePostTags({ 79 | post_id, 80 | blog_id, 81 | }: { 82 | post_id: string; 83 | blog_id: string; 84 | }) { 85 | const supa = createSupabaseBrowserClient(); 86 | 87 | return useQuery({ 88 | queryKey: tagKeys.postTags(post_id), 89 | enabled: !!post_id && !!blog_id, 90 | queryFn: async () => { 91 | const { data } = await supa 92 | .from("post_tags") 93 | .select("tags(id, name, slug)") 94 | .eq("post_id", post_id) 95 | .eq("tags.blog_id", blog_id); 96 | 97 | return data; 98 | }, 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /apps/web/src/scripts/stripe-sync.ts: -------------------------------------------------------------------------------- 1 | // Sync stripe data with supabase database 2 | 3 | import "dotenv/config"; 4 | import { createStripeClient } from "@/lib/server/stripe"; 5 | import { createAdminClient } from "@/lib/server/supabase/admin"; 6 | 7 | const supabase = createAdminClient(); 8 | const stripe = createStripeClient(); 9 | 10 | async function wait(ms: number) { 11 | return new Promise((resolve) => setTimeout(resolve, ms)); 12 | } 13 | 14 | // async function upsertSubscriptions() { 15 | // const subscriptions = await stripe.subscriptions.list({ limit: 100 }); 16 | // for (const subscription of subscriptions.data) { 17 | // await supabase.from("subscriptions").upsert({ 18 | // user_id: subscription.customer, 19 | // status: subscription.status, 20 | // stripe_subscription_id: subscription.id, 21 | // }); 22 | // } 23 | // } 24 | 25 | async function upsertProducts() { 26 | const products = await stripe.products.list({ limit: 100, active: true }); 27 | for (const product of products.data) { 28 | await supabase.from("products").upsert({ 29 | stripe_product_id: product.id, 30 | product: product as any, 31 | }); 32 | } 33 | } 34 | 35 | async function upsertPrices() { 36 | const prices = await stripe.prices.list({ limit: 100, active: true }); 37 | for (const price of prices.data) { 38 | await supabase.from("prices").upsert({ 39 | price: price as any, 40 | stripe_price_id: price.id, 41 | }); 42 | } 43 | } 44 | 45 | async function syncStripe() { 46 | await upsertProducts(); 47 | console.log("Updated products"); 48 | await wait(500); 49 | await upsertPrices(); 50 | console.log("Updated prices"); 51 | } 52 | 53 | syncStripe().catch((error) => { 54 | console.error(error); 55 | process.exit(1); 56 | }); 57 | -------------------------------------------------------------------------------- /apps/web/src/store/app.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type AppStore = { 4 | foo: string; 5 | }; 6 | 7 | export const useAppStore = create10 | ((set) => ({ 8 | foo: "bar", 9 | setFoo: (foo: string) => set({ foo }), 10 | })); 11 | -------------------------------------------------------------------------------- /apps/web/src/trpc/client/index.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCClient, httpBatchLink } from "@trpc/client"; 2 | import { AppRouter } from "../server"; 3 | 4 | // 👆 **type-only** import 5 | // Pass AppRouter as generic here. 👇 This lets the `trpc` object know 6 | // what procedures are available on the server and their input/output types. 7 | export const trpc = createTRPCClient ({ 8 | links: [ 9 | httpBatchLink({ 10 | url: "http://localhost:3000", 11 | }), 12 | ], 13 | }); 14 | -------------------------------------------------------------------------------- /apps/web/src/trpc/server/context.ts: -------------------------------------------------------------------------------- 1 | import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; 2 | import { createAdminClient } from "@/lib/server/supabase/admin"; 3 | import { createClient } from "app/supa"; 4 | 5 | /** 6 | * Defines your inner context shape. 7 | * Add fields here that the inner context brings. 8 | */ 9 | export interface CreateInnerContextOptions 10 | extends Partial {} 11 | 12 | /** 13 | * Inner context. Will always be available in your procedures, in contrast to the outer context. 14 | * 15 | * Also useful for: 16 | * - testing, so you don't have to mock Next.js' `req`/`res` 17 | * - tRPC's `createSSGHelpers` where we don't have `req`/`res` 18 | * 19 | * @link https://trpc.io/docs/v11/context#inner-and-outer-context 20 | */ 21 | export async function createInnerTRPCContext(opts?: CreateInnerContextOptions) { 22 | return { 23 | ...opts, 24 | }; 25 | } 26 | 27 | /** 28 | * Outer context. Used in the routers and will e.g. bring `req` & `res` to the context as "not `undefined`". 29 | * 30 | * @link https://trpc.io/docs/v11/context#inner-and-outer-context 31 | */ 32 | export const createTRPCContext = async (opts?: CreateNextContextOptions) => { 33 | const acceptLanguage = opts?.req.headers["accept-language"]; 34 | 35 | const supabase = createClient(); 36 | 37 | const innerContext = await createInnerTRPCContext({ 38 | req: opts?.req, 39 | }); 40 | 41 | return { 42 | ...innerContext, 43 | req: opts?.req, 44 | supabase, 45 | }; 46 | }; 47 | 48 | export type Context = Awaited >; 49 | -------------------------------------------------------------------------------- /apps/web/src/trpc/server/index.ts: -------------------------------------------------------------------------------- 1 | import { publicProcedure, router } from "./trpc"; 2 | import { z } from "zod"; 3 | 4 | export const appRouter = router({ 5 | posts: { 6 | get: { 7 | all: publicProcedure 8 | .input( 9 | z.object({ 10 | blogId: z.string(), 11 | limit: z.number().min(1).max(100).optional(), 12 | offset: z.number().min(0).optional(), 13 | }) 14 | ) 15 | .query(async ({ input: { limit = 50, offset = 0 }, ctx }) => { 16 | // TODO: implement this 17 | const { data, error } = await ctx.supabase 18 | .from("posts") 19 | .select("*") 20 | .range(offset, offset + limit); 21 | return data; 22 | }), 23 | bySlug: publicProcedure 24 | .input(z.object({ slug: z.string() })) 25 | .query(async ({ input: { slug } }) => { 26 | // TODO: implement this 27 | }), 28 | }, 29 | }, 30 | queryExample: publicProcedure 31 | .input( 32 | z.object({ 33 | id: z.string(), 34 | }) 35 | ) 36 | .query(async ({ input: { id } }) => { 37 | const users = {}; 38 | return users; 39 | }), 40 | mutationExample: publicProcedure 41 | .input(z.object({ name: z.string() })) 42 | .mutation(async (opts) => { 43 | const { input } = opts; 44 | 45 | // Create a new user in the database 46 | // const user = await db.user.create(input); 47 | // return user; 48 | }), 49 | }); 50 | 51 | // Export type router type signature, 52 | // NOT the router itself. 53 | export type AppRouter = typeof appRouter; 54 | -------------------------------------------------------------------------------- /apps/web/src/trpc/server/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from "@trpc/server"; 2 | import { Context } from "./context"; 3 | /** 4 | * Initialization of tRPC backend 5 | * Should be done only once per backend! 6 | */ 7 | const t = initTRPC.context ().create(); 8 | /** 9 | * Export reusable router and procedure helpers 10 | * that can be used throughout the router 11 | */ 12 | export const router = t.router; 13 | export const publicProcedure = t.procedure; 14 | -------------------------------------------------------------------------------- /apps/web/src/trpc/utils.ts: -------------------------------------------------------------------------------- 1 | import { httpBatchLink } from "@trpc/client"; 2 | import { createTRPCNext } from "@trpc/next"; 3 | import type { AppRouter } from "./server"; 4 | 5 | function getBaseUrl() { 6 | if (typeof window !== "undefined") 7 | // browser should use relative path 8 | return ""; 9 | 10 | if (process.env.VERCEL_URL) 11 | // reference for vercel.com 12 | return `https://${process.env.VERCEL_URL}`; 13 | 14 | if (process.env.RENDER_INTERNAL_HOSTNAME) 15 | // reference for render.com 16 | return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`; 17 | 18 | // assume localhost 19 | return `http://localhost:${process.env.PORT ?? 3000}`; 20 | } 21 | 22 | export const trpc = createTRPCNext ({ 23 | config(opts) { 24 | return { 25 | links: [ 26 | httpBatchLink({ 27 | /** 28 | * If you want to use SSR, you need to use the server's full URL 29 | * @link https://trpc.io/docs/v11/ssr 30 | **/ 31 | url: `${getBaseUrl()}/api/trpc`, 32 | 33 | // You can pass any HTTP headers you wish here 34 | async headers() { 35 | return { 36 | // authorization: getAuthCookie(), 37 | }; 38 | }, 39 | }), 40 | ], 41 | }; 42 | }, 43 | /** 44 | * @link https://trpc.io/docs/v11/ssr 45 | **/ 46 | ssr: false, 47 | }); 48 | -------------------------------------------------------------------------------- /apps/web/src/utils/get-hosted-blog-url.ts: -------------------------------------------------------------------------------- 1 | export function getHostedBlogUrl(slug: string) { 2 | const url = new URL(process.env.NEXT_PUBLIC_BASE_URL || ""); 3 | // add blog slug as subdomain 4 | // remove www 5 | if (url.hostname.startsWith("www.")) { 6 | url.hostname = url.hostname.slice(4); 7 | } 8 | url.hostname = `${slug}.${url.hostname}`; 9 | return url; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/src/utils/supabase/browser.tsx: -------------------------------------------------------------------------------- 1 | import { createSupabaseBrowserClient } from "@/lib/supabase"; 2 | import { UserResponse } from "@supabase/supabase-js"; 3 | import { 4 | PropsWithChildren, 5 | createContext, 6 | useContext, 7 | useEffect, 8 | useState, 9 | } from "react"; 10 | 11 | type User = UserResponse["data"]["user"]; 12 | 13 | const UserContext = createContext (null); 14 | const supabase = createSupabaseBrowserClient(); 15 | 16 | export function UserProvider({ children }: PropsWithChildren) { 17 | const [user, setUser] = useState (null); 18 | 19 | useEffect(() => { 20 | supabase.auth.onAuthStateChange((_, session) => { 21 | setUser(session?.user ?? null); 22 | }); 23 | }, []); 24 | 25 | return {children} ; 26 | } 27 | 28 | export function useUser() { 29 | const user = useContext(UserContext); 30 | 31 | return user; 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/utils/supabase/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient, type CookieOptions } from "@supabase/ssr"; 2 | import { NextResponse, type NextRequest } from "next/server"; 3 | 4 | export async function updateSession(request: NextRequest) { 5 | let response = NextResponse.next({ 6 | request: { 7 | headers: request.headers, 8 | }, 9 | }); 10 | 11 | const supabase = createServerClient( 12 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 13 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 14 | { 15 | cookies: { 16 | get(name: string) { 17 | return request.cookies.get(name)?.value; 18 | }, 19 | set(name: string, value: string, options: CookieOptions) { 20 | request.cookies.set({ 21 | name, 22 | value, 23 | ...options, 24 | }); 25 | response = NextResponse.next({ 26 | request: { 27 | headers: request.headers, 28 | }, 29 | }); 30 | response.cookies.set({ 31 | name, 32 | value, 33 | ...options, 34 | }); 35 | }, 36 | remove(name: string, options: CookieOptions) { 37 | request.cookies.set({ 38 | name, 39 | value: "", 40 | ...options, 41 | }); 42 | response = NextResponse.next({ 43 | request: { 44 | headers: request.headers, 45 | }, 46 | }); 47 | response.cookies.set({ 48 | name, 49 | value: "", 50 | ...options, 51 | }); 52 | }, 53 | }, 54 | } 55 | ); 56 | 57 | await supabase.auth.getUser(); 58 | 59 | return response; 60 | } 61 | -------------------------------------------------------------------------------- /apps/web/tinybird/index.ts: -------------------------------------------------------------------------------- 1 | import { Tinybird } from "@chronark/zod-bird"; 2 | import { z } from "zod"; 3 | 4 | const tb = new Tinybird({ token: process.env.TINYBIRD_TOKEN! }); 5 | 6 | const send_api_request = tb.buildIngestEndpoint({ 7 | datasource: "api_requests", 8 | event: z.object({ 9 | id: z.string(), 10 | blogId: z.string(), 11 | timestamp: z.number().int(), 12 | path: z.string(), 13 | }), 14 | }); 15 | 16 | const get_api_requests = tb.buildPipe({ 17 | pipe: "api_requests", 18 | parameters: z.object({ 19 | blogId: z.string(), 20 | }), 21 | data: z.object({ 22 | id: z.string(), 23 | blogId: z.string(), 24 | timestamp: z.number().int(), 25 | path: z.string(), 26 | }), 27 | }); 28 | 29 | export const tinybird = { 30 | send: { 31 | api_request: send_api_request, 32 | }, 33 | get: { 34 | api_requests: get_api_requests, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "checkJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "noUncheckedIndexedAccess": true, 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | }, 23 | "plugins": [ 24 | { 25 | "name": "next" 26 | } 27 | ] 28 | }, 29 | "include": [ 30 | ".eslintrc.cjs", 31 | "next-env.d.ts", 32 | "**/*.ts", 33 | "**/*.tsx", 34 | "**/*.cjs", 35 | "**/*.mjs", 36 | ".next/types/**/*.ts" 37 | ], 38 | "exclude": ["node_modules", "packages/client"] 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": [ 3 | { 4 | "source": "/api/public/(.*)", 5 | "headers": [ 6 | { "key": "Access-Control-Allow-Credentials", "value": "true" }, 7 | { "key": "Access-Control-Allow-Origin", "value": "*" }, 8 | { 9 | "key": "Access-Control-Allow-Methods", 10 | "value": "GET,OPTIONS" 11 | }, 12 | { 13 | "key": "Access-Control-Allow-Headers", 14 | "value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version" 15 | } 16 | ] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zenblog/root", 3 | "private": true, 4 | "scripts": { 5 | "build": "turbo run build", 6 | "build:web": "turbo run build --filter=web", 7 | "build:api": "turbo run build --filter=api", 8 | "build:zenblog": "turbo run build --filter=zenblog", 9 | "test": "vitest", 10 | "dev": "turbo run dev", 11 | "dev:web": "turbo run dev --filter=web", 12 | "dev:docs": "turbo run dev --filter=docs", 13 | "dev:nextjs": "cd templates/nextjs && turbo run dev", 14 | "dev:api": "turbo run dev --filter=api", 15 | "start": "concurrently --names \"APP,STRIPE\" -c \"bgBlue.bold,bgMagenta.bold\" \"npm run dev\" \"npm run stripe:webhook\"", 16 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 17 | "db:typegen": "npx supabase gen types typescript --local --schema public > apps/web/src/types/supabase.ts", 18 | "db:typegen:remote": "npx supabase gen types typescript --project-id ppfseefimhneysnokffx --schema public > apps/web/src/types/supabase.ts", 19 | "db:start": "npx supabase start", 20 | "db:stop": "npx supabase stop", 21 | "db:open": "open http://localhost:54323", 22 | "db:pull": "npx supabase db pull", 23 | "db:diff": "npx supabase db diff -f RENAME_ME", 24 | "db:local:diff": "npx supabase db diff", 25 | "qs": "git add . && git commit -m \"quick save\" && git push", 26 | "stripe:webhook": "stripe listen --forward-to localhost:8082/api/webhooks/stripe", 27 | "stripe:event": "stripe trigger payment_intent.succeeded", 28 | "stripe:sync": "tsx ./scripts/stripe-sync.ts" 29 | }, 30 | "devDependencies": { 31 | "@turbo/gen": "^1.9.7", 32 | "concurrently": "^8.2.2", 33 | "eslint": "^7.32.0", 34 | "prettier": "^2.5.1", 35 | "shiki": "^1.5.1", 36 | "turbo": "^2.0.3", 37 | "vitest": "^3.0.4" 38 | }, 39 | "workspaces": [ 40 | "apps/*", 41 | "packages/*", 42 | "templates/*" 43 | ], 44 | "dependencies": { 45 | "@ai-sdk/openai": "^0.0.24", 46 | "@radix-ui/react-dropdown-menu": "^2.0.6", 47 | "@supabase/ssr": "^0.5.0", 48 | "@types/inquirer": "^9.0.3", 49 | "hypertune": "^2.1.1", 50 | "next": "^14.1.0", 51 | "sugar-high": "^0.6.1", 52 | "tsx": "^4.7.1", 53 | "typescript": "^5.6.3", 54 | "web": "^0.1.0", 55 | "zenblog": "^0.2.1", 56 | "zod": "^3.23.8" 57 | }, 58 | "engines": { 59 | "node": ">=18.0.0", 60 | "npm": ">=8.0.0" 61 | }, 62 | "packageManager": "npm@10.2.4" 63 | } 64 | -------------------------------------------------------------------------------- /packages/code-block-sugar-high/README.md: -------------------------------------------------------------------------------- 1 | # Tiptap Code Block Sugar High 2 | 3 | This plugin allows you to use Sugar High theming with Tiptap. 4 | -------------------------------------------------------------------------------- /packages/code-block-sugar-high/code-block-sugar.tsx: -------------------------------------------------------------------------------- 1 | import CodeBlock, { CodeBlockOptions } from "@tiptap/extension-code-block"; 2 | import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; 3 | import { SugarPlugin } from "./sugar-plugin"; 4 | import React from "react"; 5 | 6 | type CodeBlockAttrs = { 7 | node: { 8 | attrs: { 9 | language: string; 10 | }; 11 | }; 12 | updateAttributes: (attrs: { language: string }) => void; 13 | }; 14 | const CodeBlockComp = ({ 15 | node: { 16 | attrs: { language: defaultLanguage = "typescript" }, 17 | }, 18 | }: CodeBlockAttrs) => ( 19 |20 | 24 | ); 25 | 26 | export const CodeBlockSugarHigh = CodeBlock.extend({ 27 | addProseMirrorPlugins() { 28 | return [ 29 | ...(this.parent?.() || []), 30 | SugarPlugin({ 31 | name: this.name, 32 | }), 33 | ]; 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /packages/code-block-sugar-high/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./code-block-sugar"; 2 | -------------------------------------------------------------------------------- /packages/code-block-sugar-high/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-block-sugar-high", 3 | "version": "1.0.0", 4 | "description": "Tiptap extension for code highlighting with Sugar High", 5 | "main": "index.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /packages/hash/index.ts: -------------------------------------------------------------------------------- 1 | const pepper = process.env.PEPPER; 2 | 3 | import crypto from "crypto"; 4 | 5 | export function encryptApiKey(apiKey: string): string { 6 | if (!pepper) { 7 | throw new Error("PEPPER environment variable is not set"); 8 | } 9 | 10 | const iv = crypto.randomBytes(16); 11 | const cipher = crypto.createCipheriv( 12 | "aes-256-cbc", 13 | Buffer.from(pepper, "hex"), 14 | iv 15 | ); 16 | 17 | let encrypted = cipher.update(apiKey, "utf8", "hex"); 18 | encrypted += cipher.final("hex"); 19 | 20 | return iv.toString("hex") + ":" + encrypted; 21 | } 22 | 23 | export function decryptApiKey(encryptedApiKey: string): string { 24 | if (!pepper) { 25 | throw new Error("PEPPER environment variable is not set"); 26 | } 27 | 28 | const [ivHex, encryptedHex] = encryptedApiKey.split(":"); 29 | const iv = Buffer.from(ivHex, "hex"); 30 | const decipher = crypto.createDecipheriv( 31 | "aes-256-cbc", 32 | Buffer.from(pepper, "hex"), 33 | iv 34 | ); 35 | 36 | let decrypted = decipher.update(encryptedHex, "hex", "utf8"); 37 | decrypted += decipher.final("utf8"); 38 | 39 | return decrypted; 40 | } 41 | -------------------------------------------------------------------------------- /packages/hash/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hash", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "private": true, 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "description": "" 12 | } 13 | -------------------------------------------------------------------------------- /packages/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Post = { 2 | title: string; 3 | slug: string; 4 | published_at: string; 5 | cover_image?: string; 6 | excerpt?: string; 7 | tags: Tag[]; 8 | category: Category | null; 9 | authors: Author[]; 10 | }; 11 | 12 | export type PostWithContent = Post & { 13 | html_content: string; 14 | }; 15 | 16 | export type Category = { 17 | slug: string; 18 | name: string; 19 | }; 20 | 21 | export type Tag = { 22 | slug: string; 23 | name: string; 24 | }; 25 | 26 | export type Author = { 27 | name: string; 28 | slug: string; 29 | image_url: string; 30 | bio?: string; 31 | twitter_url?: string; 32 | website_url?: string; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zenblog/types", 3 | "version": "1.0.0", 4 | "types": "index.ts", 5 | "main": "index.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "publish:prod": "npm publish --access public" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "description": "Types for zenblog, a headless blogging CMS." 14 | } 15 | -------------------------------------------------------------------------------- /packages/zenblog/README.md: -------------------------------------------------------------------------------- 1 | # Official zenblog API Client 2 | 3 | This is the official typescript client for zenblog. 4 | 5 | Link to docs: [https://zenblog.com/docs](https://zenblog.com/docs) 6 | Link to the official website: [https://zenblog.com](https://zenblog.com) 7 | 8 | ## Install 9 | 10 | ```bash 11 | npm install zenblog 12 | ``` 13 | 14 | ## Usage example 15 | 16 | ```typescript 17 | import { createZenblogClient } from "zenblog"; 18 | 19 | const cms = createZenblogClient({ 20 | blogId: "MY_BLOG_ID", // Go to your blog settings to get your blog id 21 | }); 22 | 23 | const posts = await cms.posts.list(); 24 | const post = await cms.posts.get({ slug: "post-slug" }); 25 | ``` 26 | -------------------------------------------------------------------------------- /packages/zenblog/demo/index.ts: -------------------------------------------------------------------------------- 1 | import { createZenblogClient } from "../dist"; 2 | 3 | const client = createZenblogClient({ blogId: "123" }); 4 | 5 | client.posts.list({ category: "nextjs" }); 6 | -------------------------------------------------------------------------------- /packages/zenblog/dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Author, Category, Post, PostWithContent, Tag } from "./types"; 2 | type ApiResponse21 |23 |22 | = { 3 | data: T; 4 | }; 5 | type PaginatedApiResponse = ApiResponse & { 6 | total: number; 7 | offset: number; 8 | limit: number; 9 | }; 10 | type CreateClientOpts = { 11 | blogId: string; 12 | _url?: string; 13 | _debug?: boolean; 14 | }; 15 | export declare function createZenblogClient({ blogId, _url, _debug, }: CreateClientOpts): { 16 | posts: { 17 | list: ({ limit, offset, cache, category, tags, author, }?: { 18 | cache?: RequestInit["cache"]; 19 | limit?: number; 20 | offset?: number; 21 | } & { 22 | category?: string; 23 | tags?: string[]; 24 | author?: string; 25 | }) => Promise >; 26 | get: ({ slug }: { 27 | slug: string; 28 | }, opts?: { 29 | cache?: RequestInit["cache"]; 30 | limit?: number; 31 | offset?: number; 32 | }) => Promise >; 33 | }; 34 | categories: { 35 | list: () => Promise >; 36 | }; 37 | tags: { 38 | list: () => Promise >; 39 | }; 40 | authors: { 41 | list: () => Promise >; 42 | get: ({ slug }: { 43 | slug: string; 44 | }, opts?: { 45 | cache?: RequestInit["cache"]; 46 | limit?: number; 47 | offset?: number; 48 | }) => Promise >; 49 | }; 50 | }; 51 | export {}; 52 | //# sourceMappingURL=index.d.ts.map -------------------------------------------------------------------------------- /packages/zenblog/dist/index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,SAAS,CAAC;AAEvE,KAAK,WAAW,CAAC,CAAC,IAAI;IACpB,IAAI,EAAE,CAAC,CAAC;CACT,CAAC;AAEF,KAAK,oBAAoB,CAAC,CAAC,IAAI,WAAW,CAAC,CAAC,CAAC,GAAG;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AA2CF,KAAK,gBAAgB,GAAG;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AACF,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,IAAI,EACJ,MAAM,GACP,EAAE,gBAAgB;;;oBAiBP,WAAW,CAAC,OAAO,CAAC;oBACpB,MAAM;qBACL,MAAM;;uBAIJ,MAAM;mBACV,MAAM,EAAE;qBACN,MAAM;cAWU,OAAO,CAAC,oBAAoB,CAAC,IAAI,EAAE,CAAC,CAAC;wBAkBhD;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE;oBArCtB,WAAW,CAAC,OAAO,CAAC;oBACpB,MAAM;qBACL,MAAM;cAqCV,OAAO,CAAC,WAAW,CAAC,eAAe,CAAC,CAAC;;;oBAUf,OAAO,CAAC,oBAAoB,CAAC,QAAQ,EAAE,CAAC,CAAC;;;oBASzC,OAAO,CAAC,oBAAoB,CAAC,GAAG,EAAE,CAAC,CAAC;;;oBASpC,OAAO,CAAC,oBAAoB,CAAC,MAAM,EAAE,CAAC,CAAC;wBAQpD;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE;oBA3EtB,WAAW,CAAC,OAAO,CAAC;oBACpB,MAAM;qBACL,MAAM;cA2EV,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;;EAUpC"} -------------------------------------------------------------------------------- /packages/zenblog/dist/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;AA2DA,kDA4GC;AAvKD,+BAAiD;AAajD,SAAS,aAAa,CAAC,GAAwB;IAC7C,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,MAAM,CAAC,QAAQ,EAAE,CAAC;AAC3B,CAAC;AAED,SAAS,aAAa,CACpB,MAAuC,EACvC,GAA6B;IAE7B,OAAO,KAAK,UAAU,MAAM,CAAC,IAAY,EAAE,IAAiB;QAC1D,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,GAAG,UAAU,MAAM,CAAC,MAAM,IAAI,IAAI,EAAE,CAAC;YAC3D,MAAM,OAAO,GAAG;gBACd,GAAG,IAAI;gBACP,OAAO,EAAE;oBACP,cAAc,EAAE,kBAAkB;oBAClC,GAAG,IAAI,CAAC,OAAO;iBAChB;aACF,CAAC;YAEF,GAAG,CAAC,QAAQ,EAAE,GAAG,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;YACnC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YACtC,IAAI,IAAI,CAAC;YACT,IAAI,CAAC;gBACH,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;YAC1B,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,IAAA,gBAAU,EAAC,wCAAwC,EAAE,CAAC,CAAC,CAAC;YAC1D,CAAC;YAED,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;gBACZ,IAAA,gBAAU,EAAC,8BAA8B,EAAE,GAAG,CAAC,CAAC;YAClD,CAAC;YAED,OAAO,IAAI,CAAC;QACd,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC;YACzC,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAOD,SAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,IAAI,EACJ,MAAM,GACW;IACjB,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;QAClC,OAAO,CAAC,IAAI,CACV,iJAAiJ,CAClJ,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,IAAA,kBAAY,EAAC,MAAM,IAAI,KAAK,CAAC,CAAC;IAC7C,MAAM,OAAO,GAAG,aAAa,CAC3B;QACE,GAAG,EAAE,IAAI,IAAI,gCAAgC;QAC7C,MAAM;KACP,EACD,MAAM,CACP,CAAC;IAaF,OAAO;QACL,KAAK,EAAE;YACL,IAAI,EAAE,KAAK,WAAW,EACpB,KAAK,GAAG,EAAE,EACV,MAAM,GAAG,CAAC,EACV,KAAK,GAAG,SAAS,EACjB,QAAQ,EACR,IAAI,EACJ,MAAM,MACU,EAAE;gBAClB,MAAM,IAAI,GAAG,MAAM,OAAO,CACxB,SAAS,aAAa,CAAC;oBACrB,KAAK;oBACL,MAAM;oBACN,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;oBACjC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;oBACzC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBAC9B,CAAC,EAAE,EACJ;oBACE,MAAM,EAAE,KAAK;oBACb,KAAK;iBACN,CACF,CAAC;gBAEF,OAAO,IAAoC,CAAC;YAC9C,CAAC;YACD,GAAG,EAAE,KAAK,WACR,EAAE,IAAI,EAAoB,EAC1B,IAAc;gBAEd,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,SAAS,IAAI,EAAE,EAAE;oBAC1C,MAAM,EAAE,KAAK;oBACb,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,SAAS;iBAChC,CAAC,CAAC;gBAEH,OAAO,IAAoC,CAAC;YAC9C,CAAC;SACF;QACD,UAAU,EAAE;YACV,IAAI,EAAE,KAAK;gBACT,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,YAAY,EAAE;oBACvC,MAAM,EAAE,KAAK;iBACd,CAAC,CAAC;gBAEH,OAAO,IAAwC,CAAC;YAClD,CAAC;SACF;QACD,IAAI,EAAE;YACJ,IAAI,EAAE,KAAK;gBACT,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,MAAM,EAAE;oBACjC,MAAM,EAAE,KAAK;iBACd,CAAC,CAAC;gBAEH,OAAO,IAAmC,CAAC;YAC7C,CAAC;SACF;QACD,OAAO,EAAE;YACP,IAAI,EAAE,KAAK;gBACT,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,SAAS,EAAE;oBACpC,MAAM,EAAE,KAAK;iBACd,CAAC,CAAC;gBAEH,OAAO,IAAsC,CAAC;YAChD,CAAC;YACD,GAAG,EAAE,KAAK,WACR,EAAE,IAAI,EAAoB,EAC1B,IAAc;gBAEd,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,WAAW,IAAI,EAAE,EAAE;oBAC5C,MAAM,EAAE,KAAK;oBACb,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,SAAS;iBAChC,CAAC,CAAC;gBAEH,OAAO,IAA2B,CAAC;YACrC,CAAC;SACF;KACF,CAAC;AACJ,CAAC"} -------------------------------------------------------------------------------- /packages/zenblog/dist/lib/index.d.ts: -------------------------------------------------------------------------------- 1 | export declare function logError(...args: any[]): void; 2 | export declare function throwError(msg: string, ...args: any[]): void; 3 | export declare function createLogger(debug: boolean): (...args: any[]) => void; 4 | //# sourceMappingURL=index.d.ts.map -------------------------------------------------------------------------------- /packages/zenblog/dist/lib/index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AAAA,wBAAgB,QAAQ,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,QAEtC;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,QAGrD;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,aACxB,GAAG,EAAE,UAKvB"} -------------------------------------------------------------------------------- /packages/zenblog/dist/lib/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.logError = logError; 4 | exports.throwError = throwError; 5 | exports.createLogger = createLogger; 6 | function logError(...args) { 7 | console.error("[zenblog error] ", ...args); 8 | } 9 | function throwError(msg, ...args) { 10 | logError(msg, ...args); 11 | throw new Error("[zenblog error] " + msg); 12 | } 13 | function createLogger(debug) { 14 | return (...args) => { 15 | if (debug) { 16 | console.log("[sdk] ", ...args); 17 | } 18 | }; 19 | } 20 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /packages/zenblog/dist/lib/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":";;AAAA,4BAEC;AAED,gCAGC;AAED,oCAMC;AAfD,SAAgB,QAAQ,CAAC,GAAG,IAAW;IACrC,OAAO,CAAC,KAAK,CAAC,kBAAkB,EAAE,GAAG,IAAI,CAAC,CAAC;AAC7C,CAAC;AAED,SAAgB,UAAU,CAAC,GAAW,EAAE,GAAG,IAAW;IACpD,QAAQ,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IACvB,MAAM,IAAI,KAAK,CAAC,kBAAkB,GAAG,GAAG,CAAC,CAAC;AAC5C,CAAC;AAED,SAAgB,YAAY,CAAC,KAAc;IACzC,OAAO,CAAC,GAAG,IAAW,EAAE,EAAE;QACxB,IAAI,KAAK,EAAE,CAAC;YACV,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,IAAI,CAAC,CAAC;QACjC,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"} -------------------------------------------------------------------------------- /packages/zenblog/dist/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Author, Category, Post, PostWithContent, Tag } from "@zenblog/types"; 2 | export type { Author, Category, Post, PostWithContent, Tag }; 3 | //# sourceMappingURL=types.d.ts.map -------------------------------------------------------------------------------- /packages/zenblog/dist/types.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AAI9E,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,eAAe,EAAE,GAAG,EAAE,CAAC"} -------------------------------------------------------------------------------- /packages/zenblog/dist/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | //# sourceMappingURL=types.js.map -------------------------------------------------------------------------------- /packages/zenblog/dist/types.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""} -------------------------------------------------------------------------------- /packages/zenblog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenblog", 3 | "version": "1.2.0", 4 | "description": "The typescript client for zenblog", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "public": true, 8 | "exports": { 9 | ".": "./dist/index.js", 10 | "./types": "./src/types.ts" 11 | }, 12 | "files": [ 13 | "dist", 14 | "types", 15 | "src" 16 | ], 17 | "scripts": { 18 | "test": "vitest", 19 | "build": "tsc", 20 | "dev": "tsc -w", 21 | "prepublishOnly": "npm i && npm run test && npm run build", 22 | "publish:prod": "npm publish --access public", 23 | "publish:beta": "npm publish --access public --tag beta" 24 | }, 25 | "keywords": [ 26 | "zenblog" 27 | ], 28 | "author": "Jordi Enric", 29 | "license": "MIT", 30 | "dependencies": { 31 | "@types/node": "^20.17.6", 32 | "@zenblog/types": "^1.0.0" 33 | }, 34 | "devDependencies": { 35 | "typescript": "^5.6.3", 36 | "vitest": "^3.0.5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/zenblog/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export function logError(...args: any[]) { 2 | console.error("[zenblog error] ", ...args); 3 | } 4 | 5 | export function throwError(msg: string, ...args: any[]) { 6 | logError(msg, ...args); 7 | throw new Error("[zenblog error] " + msg); 8 | } 9 | 10 | export function createLogger(debug: boolean) { 11 | return (...args: any[]) => { 12 | if (debug) { 13 | console.log("[sdk] ", ...args); 14 | } 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /packages/zenblog/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Author, Category, Post, PostWithContent, Tag } from "@zenblog/types"; 2 | 3 | // These are exported here so users don't have to install @zenblog/types 4 | 5 | export type { Author, Category, Post, PostWithContent, Tag }; 6 | -------------------------------------------------------------------------------- /packages/zenblog/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "files": ["src/index.ts"], 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "module": "commonjs", 8 | "outDir": "dist", 9 | "sourceMap": true, 10 | "target": "ES2020", 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "moduleResolution": "Node", 14 | "forceConsistentCasingInFileNames": true, 15 | "stripInternal": true, 16 | "allowSyntheticDefaultImports": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /todo/cleanup.md: -------------------------------------------------------------------------------- 1 | # Things to clean up 2 | 3 | - [ ] Refactor use of env variables so that they are not used directly in the code 4 | - [ ] Remove all console.log statements 5 | -------------------------------------------------------------------------------- /todo/mvp.md: -------------------------------------------------------------------------------- 1 | # MVP 2 | 3 | ## Backlog 4 | 5 | - [] Paddle for subscriptions. I aint risking it, need a MoR. 6 | - [] Nextjs example in homepage 7 | - [] Use a JWT with the blog id to authenticate the API, should make api faster by removing one req to get the blog id 8 | - [] Add categories to posts API 9 | - [] fix metadata format so that its {key:value} not [{key:foo,value:bar}] 10 | - [] pagination to media query 11 | - [] fix reset password 12 | 13 | ## Blogs 14 | 15 | - [] nextjs example 16 | - [] astro example 17 | - [] nuxt example 18 | 19 | ## Analytics 20 | 21 | - [x] create tinybird endpoint to get all views for a blog 22 | - [] store and display total requests for a blog (usage) 23 | - [] total pageviews for blog 24 | - [] usage based limit 25 | 26 | ## Docs 27 | 28 | - [] Add documentation for integrating with Zenblog 29 | 30 | ## Free tier 31 | 32 | - [x] limit to 1 blog 33 | - [] limit to 20 images 34 | 35 | ## QOL 36 | 37 | - Prevent users from leaving the page when they have unsaved changes 38 | - Save posts/create in localstorage so its not lost when you reload the page goddammit 39 | - Apple pay? 40 | - Stripe link? 41 | - fav blogs 42 | 43 | ## Themes 44 | 45 | - [] personal blog, docs, careers page, product listing pages, help center, changelogs, directory websites, vc website, etc. 46 | -------------------------------------------------------------------------------- /todo/postmvp.md: -------------------------------------------------------------------------------- 1 | # POST LAUNCH 2 | 3 | ## Ideas 4 | 5 | - [] Allow to fetch list of posts with content. 6 | 7 | ```typescript 8 | const posts = await client.posts.list({ withContent: true, limit: 10 }); 9 | ``` 10 | 11 | - [] Allow draft/publish from post list. 12 | - [] Allow copy slug from post list. 13 | - [] Allow users to add default metadata for posts in a blog. 14 | - [] Add PolyScale cache to Supabase for faster queries? 15 | - [] Featured Images 16 | - [] Excerpts 17 | - [] Authors 18 | - [] Analytics 19 | - [] Pin blogs to the top of the list 20 | - [] Redesign settings page to be similar to Plausible 21 | - [] Use a generic on createClient to pass the custom metadata 22 | - [] Add files to custom metadata 23 | - [] Allow "undo" for destructive actions - [] Delete post - [] Delete blog 24 | - Allow users to see their trash and restore items from the trash. 25 | - [] Make layout work on mobile 26 | - [] Make inputs not zoom in on mobile 27 | - [] Grace period for expired subscriptions 28 | - [] Auto generate excerpt with AI 29 | - [] Auto generate promotional tweet for a post with AI. With a short description of what the post is about. 30 | - [] "New blogs" page in zenblog.com that links to new blogs. Good for SEO. 31 | - [] "New posts" page in zenblog.com that links to new posts. Good for SEO. 32 | - [] Offline first app with https://github.com/LegendApp/legend-state ? 33 | 34 | ## Teams 35 | 36 | - Maybe blogs themselves can be the teams? You invite people to join your blog and they can create posts in your blog. 37 | 38 | ## Blog themes 39 | 40 | - [] Ask theme devs if they mind me porting their themes to Zenblog (with proper attribution) 41 | - [] https://andersnoren.se/ 42 | 43 | ## Hosted blogs 44 | 45 | - [x] Let zenblog host your blog for you 46 | - [x] Custom domain support 47 | - vercel: (https://github.com/orgs/vercel/discussions/31) 48 | - cloudflare: https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/create-custom-hostnames/ 49 | - [] Hosted blog themes 50 | 51 | # Analytics 52 | 53 | - [] Add analytics to blogs 54 | 55 | ## Self hosting 56 | 57 | - [] Add documentation for self hosting 58 | - [] Self hosting license 59 | 60 | ## Editor Themes 61 | 62 | - [] Pick your editor theme, serif, sans-serif, monospace. 63 | 64 | ## SEO? 65 | 66 | - [] List what SEO metadata is needed for posts 67 | - [] Add SEO metadata to blogs? 68 | 69 | ## Invitations 70 | 71 | Users can invite other users to join their blog. Perfect for teams and guest authors. 72 | 73 | - [] Create invitation 74 | - [] Accept invitation 75 | - [] Reject invitation 76 | - [] Delete invitation 77 | 78 | ## Members 79 | 80 | - [] Send invitation email to join blog 81 | - [] Add members to a blog 82 | - [] Remove members from a blog 83 | - [] As a member, view all posts in a blog 84 | - [] As a member, create a post in a blog 85 | - [] As a member, edit a post in a blog 86 | - [] As a member, delete a post in a blog 87 | 88 | ## User Feedback 89 | 90 | - [] Custom Supabase URL for API client 91 | -------------------------------------------------------------------------------- /todo/templates.md: -------------------------------------------------------------------------------- 1 | # Template ideas for Zenblog 2 | 3 | - [] Simple blog template 4 | - [] News website 5 | - [] Personal blog 6 | - [] Portfolio 7 | - [] Company blog 8 | - [] Documentation 9 | - [] Careers page 10 | - [] Multilanguage blog 11 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local"], 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": [".next/**", "!.next/cache/**", "dist/**"] 8 | }, 9 | "api#build": { 10 | "dependsOn": ["^build"], 11 | "env": [ 12 | "SUPABASE_URL", 13 | "SUPABASE_SERVICE_ROLE_KEY", 14 | "UPSTASH_REDIS_REST_URL", 15 | "BASE_API_URL", 16 | "UPSTASH_REDIS_REST_TOKEN" 17 | ], 18 | "outputs": [".next/**", "!.next/cache/**"] 19 | }, 20 | "lint": {}, 21 | "dev": { 22 | "cache": false, 23 | "persistent": true 24 | } 25 | } 26 | } 27 | --------------------------------------------------------------------------------