├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── bun.lockb ├── components ├── BlogCreateSuccess │ └── index.tsx ├── DropZone │ └── index.tsx ├── Header │ └── index.tsx ├── Meta │ └── index.tsx ├── NoContent │ └── index.tsx ├── aside │ ├── MainAside │ │ └── index.tsx │ └── WriteAside │ │ └── index.tsx ├── cards │ ├── BlogCard │ │ └── index.tsx │ └── PostCard │ │ └── index.tsx ├── footers │ ├── Footer │ │ └── index.tsx │ └── PublicFooter │ │ └── index.tsx ├── tabs │ ├── Analytics │ │ └── index.tsx │ ├── BlogSettings │ │ └── index.tsx │ └── Posts │ │ └── [domain] │ │ └── index.tsx └── ui │ ├── Badge │ └── index.tsx │ ├── Button │ └── index.tsx │ ├── Dialog │ └── index.tsx │ ├── Label │ └── index.tsx │ ├── Loader │ └── index.tsx │ ├── PopOver │ └── index.tsx │ └── Switch │ └── index.tsx ├── config └── store.ts ├── hooks ├── usePost.ts └── useWindow.ts ├── interface.ts ├── layouts ├── authLayout.tsx └── widthConstraint.tsx ├── lib ├── constants │ ├── index.ts │ └── nav.tsx ├── schema.ts └── utils.ts ├── middleware.ts ├── next.config.js ├── package.json ├── pages ├── [domain].tsx ├── [domain] │ └── [slug].tsx ├── _app.tsx ├── _document.tsx ├── api │ ├── blog │ │ ├── create.ts │ │ └── fetch-all.ts │ └── post │ │ └── create.ts ├── auth.tsx ├── dashboard.tsx ├── feedback.tsx ├── index.tsx ├── new.tsx ├── pub │ └── [domain] │ │ └── [pub-id].tsx ├── settings.tsx ├── sign-in │ └── [[...index]].tsx ├── sign-up │ └── [[...index]].tsx └── write │ └── [domain] │ └── [pub-id] │ └── [id].tsx ├── postcss.config.js ├── prisma ├── index.ts └── schema.prisma ├── public ├── assets │ ├── blob.webp │ ├── logo.png │ ├── logo.svg │ ├── test-2.avif │ ├── test-3.png │ ├── test.avif │ └── themes │ │ ├── 1.png │ │ ├── 2.png │ │ └── 3.png ├── favicon.ico ├── next.svg └── vercel.svg ├── routes.ts ├── services └── uploadFile.ts ├── styles └── globals.css ├── tailwind.config.ts ├── tsconfig.json └── types.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push, pull_request] 3 | jobs: 4 | run-ci: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: checkout repo 8 | uses: actions/checkout@v4 9 | 10 | - name: setup node 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: "18" 14 | 15 | - name: install bun 16 | run: npm install -g bun 17 | 18 | - name: install dependencies 19 | run: bun i 20 | 21 | - name: lint 22 | run: bun run lint 23 | -------------------------------------------------------------------------------- /.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 | 15 | # production 16 | /build 17 | 18 | # prisma 19 | prisma/client 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deepscan.enable": true 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pingu 2 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langf00rd/pingu/460a632f9df6a5c31e68578d28b938237ab2ed99/bun.lockb -------------------------------------------------------------------------------- /components/BlogCreateSuccess/index.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/routes"; 2 | import { Twitter, Linkedin } from "lucide-react"; 3 | import Link from "next/link"; 4 | import { Button } from "../ui/Button"; 5 | import { DialogFooter } from "../ui/Dialog"; 6 | 7 | export function BlogCreateSuccessView(props: { uid: string }): JSX.Element { 8 | return ( 9 | <> 10 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /components/DropZone/index.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import Image from "next/image"; 3 | import { useState } from "react"; 4 | import Dropzone from "react-dropzone"; 5 | 6 | export default function DropZone(props: { 7 | onUploadedFile: (field: string, value: any) => Promise; 8 | fieldName: string; 9 | label?: string; 10 | className?: string; 11 | value: string; 12 | }): JSX.Element { 13 | const [uploading, setUploading] = useState(false); 14 | const [uploaded, setUploaded] = useState(!!props.value); 15 | const [imageURL, setImageURL] = useState(props.value ?? ""); 16 | const [imageBlob, setImageBlob] = useState(""); 17 | 18 | console.log(props.value); 19 | 20 | return ( 21 | { 23 | // setUploading(true); 24 | setImageBlob(URL.createObjectURL(acceptedFiles[0])); 25 | // setImageURL(result); 26 | // setUploaded(true); 27 | // props.onUploadedFile(props.fieldName, result); 28 | // setUploading(false); 29 | }} 30 | > 31 | {({ getRootProps, getInputProps }) => ( 32 |
33 |
34 | 35 |
41 | 42 | {uploading === true ? ( 43 | "uploading..." 44 | ) : ( 45 | <>{props.label ?? "Drop or click to select an image"} 46 | )} 47 | 48 | {imageBlob && ( 49 |
50 | uploaded image 57 |
58 | )} 59 |
60 |
61 |
62 | )} 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import { UserButton } from "@clerk/nextjs"; 3 | import { Menu, Plus } from "lucide-react"; 4 | import { Button } from "../ui/Button"; 5 | import { useRouter } from "next/router"; 6 | import { useStore } from "@/config/store"; 7 | import { AnimatePresence } from "framer-motion"; 8 | import { ReactNode } from "react"; 9 | import MainAside from "../aside/MainAside"; 10 | import { ROUTES } from "@/routes"; 11 | import Link from "next/link"; 12 | import { useParams } from "next/navigation"; 13 | import { cn, generate12ByteID } from "@/lib/utils"; 14 | 15 | export default function Header(props: { 16 | title?: string; 17 | children?: ReactNode; 18 | className?: string; 19 | }): JSX.Element { 20 | const params = useParams(); 21 | const { pathname } = useRouter(); 22 | const showMainAside = useStore((state) => state.showMainAside); 23 | const setShowMainAside = useStore((state) => state.setShowMainAside); 24 | return ( 25 | <> 26 |
32 |
33 |
34 | {!showMainAside && ( 35 | 38 | )} 39 | {pathname.split("/").includes("pub") ? ( 40 | 45 | 49 | 50 | ) : ( 51 | 52 | 53 | 54 | )} 55 |
56 | 57 |
58 |
59 |
60 |

{props.title}

61 | {props.children} 62 |
63 |
64 |
65 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /components/Meta/index.tsx: -------------------------------------------------------------------------------- 1 | import { MetaProps } from "@/interface"; 2 | import { META_CONTENT } from "@/lib/constants"; 3 | import Head from "next/head"; 4 | 5 | const Meta = (props: MetaProps) => { 6 | return ( 7 | <> 8 | 9 | {/* Global Metadata */} 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | {props.title || META_CONTENT.title} 21 | 22 | {/* Open Graph / Facebook */} 23 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | {/* Twitter */} 35 | 36 | 37 | 38 | 42 | 43 | 44 | 45 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default Meta; 55 | -------------------------------------------------------------------------------- /components/NoContent/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Button } from "../ui/Button"; 3 | 4 | export default function NoContent(props: { 5 | page: string; 6 | label: string; 7 | buttonLabel: string; 8 | buttonIcon?: JSX.Element; 9 | }): JSX.Element { 10 | return ( 11 |
12 |

{props.label}

13 | 14 | 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /components/aside/MainAside/index.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from "framer-motion"; 2 | import { ROUTES } from "@/routes"; 3 | import { ChevronsLeft } from "lucide-react"; 4 | import { Button } from "../../ui/Button"; 5 | import { useStore } from "@/config/store"; 6 | import Link from "next/link"; 7 | import Image from "next/image"; 8 | import { useRouter } from "next/router"; 9 | import { MAIN_ASIDE_NAV_LINKS } from "@/lib/constants/nav"; 10 | 11 | export default function MainAside(props: { show?: boolean }): JSX.Element { 12 | const { pathname } = useRouter(); 13 | const showMainAside = useStore((state) => state.showMainAside); 14 | const setShowMainAside = useStore((state) => state.setShowMainAside); 15 | 16 | return ( 17 | 18 | {showMainAside && ( 19 | 26 |
27 |
28 | 32 |
33 | pingu logo 34 |

pingu

35 |
36 | 37 | 44 |
45 | 62 |
63 |
64 | 65 | 66 | 67 |
68 |
69 | )} 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /components/aside/WriteAside/index.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from "framer-motion"; 2 | import { ChevronsLeft } from "lucide-react"; 3 | import { Button } from "../../ui/Button"; 4 | import { useStore } from "@/config/store"; 5 | import { useState } from "react"; 6 | import { Field, Form, Formik } from "formik"; 7 | import { Label } from "../../ui/Label"; 8 | import axios from "axios"; 9 | import { toast } from "sonner"; 10 | import { FormPost, IPost } from "@/types"; 11 | import { useParams } from "next/navigation"; 12 | 13 | export default function WriteAside(props: { 14 | show?: boolean; 15 | data: FormPost; 16 | contentHTML: any; 17 | contentJSON: any; 18 | subtitle: string; 19 | title: string; 20 | }): JSX.Element { 21 | const params = useParams(); 22 | const [loading, setLoading] = useState(false); 23 | const showWriteAside = useStore((state) => state.showWriteAside); 24 | const setShowWriteAside = useStore((state) => state.setShowWriteAside); 25 | 26 | async function onSubmitForm(values: FormPost) { 27 | setLoading(true); 28 | console.log(values); 29 | const data: IPost = { 30 | meta: { 31 | title: values.meta.title, 32 | description: values.meta.description, 33 | image: values.meta.image, 34 | }, 35 | title: props.title, 36 | sub_title: props.subtitle, 37 | tags: ["tag #1", "tag #2"], 38 | id: params["id"].toString(), 39 | parent_id: params["pub-id"].toString(), 40 | slug: props.title 41 | .replace(/[^a-zA-Z0-9]/g, "-") 42 | .toLowerCase() 43 | .trim(), 44 | content_html: props.contentHTML, 45 | content_json: props.contentJSON, 46 | sub_domain: params.domain.toString(), 47 | show_stats: values.show_stats, 48 | show_toc: values.show_toc, 49 | is_published: true, 50 | banner: values.banner, 51 | }; 52 | 53 | await axios 54 | .post("/api/post/create", data) 55 | .then((res) => { 56 | toast.success(res.data.message); 57 | }) 58 | .catch((error) => { 59 | console.log(error.message); 60 | toast.error(error.message); 61 | }); 62 | setLoading(false); 63 | } 64 | 65 | return ( 66 | 67 | {showWriteAside && ( 68 | 75 |
76 |
77 |

Ready to publish?

78 | 85 |
86 | onSubmitForm(values)} 88 | initialValues={{ 89 | show_stats: props.data?.show_stats, 90 | show_toc: props.data?.show_toc, 91 | banner: 92 | "https://res.cloudinary.com/follio/image/upload/v1696762121/bfnmtin2bi95h6ty5qxi.png", 93 | meta: { 94 | title: props.data?.meta.title ?? "", 95 | description: props.data?.meta.description ?? "", 96 | image: props.data?.meta.image ?? "https://img.link", 97 | }, 98 | }} 99 | > 100 | {({}) => ( 101 |
102 |
103 |

104 | {window.location.host}/{params.domain}/ 105 | {props.title 106 | .replace(/[^a-zA-Z0-9]/g, "-") 107 | .toLowerCase() 108 | .trim()} 109 |

110 |
111 |
112 | 120 |
121 |
122 | 130 |
131 |
132 | 135 |
136 |

137 | This will appear in place of your post title on search engine 138 | results 139 |

140 | 145 |
146 |
147 |
148 | 151 |
152 |

153 | This will be shown in place of your subtitle on search engine 154 | results pages. Ideally, summarize the article and keep it between 155 | 140 and 156 characters in length. 156 |

157 | 163 |
164 |
165 | 168 |
169 | )} 170 |
171 |
172 |
173 | )} 174 |
175 | ); 176 | } 177 | -------------------------------------------------------------------------------- /components/cards/BlogCard/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { BarChart, Globe, Link2 } from "lucide-react"; 3 | import { ROUTES } from "@/routes"; 4 | import Image from "next/image"; 5 | import { IBlog } from "@/types"; 6 | 7 | export default function BlogCard(props: { data: IBlog }): JSX.Element { 8 | return ( 9 |
  • 10 |
    11 |
    12 | 16 | 23 |
    24 |

    25 | {props.data.name} 26 |

    27 | {props.data.sub_domain}.pingu.sh 28 |
    29 | 30 |
    31 |
      32 |
    • 33 | 34 |

      1 domain

      35 |
    • 36 |
    • 37 | 38 |

      200 clicks

      39 |
    • 40 |
    • 41 | 47 | 48 |

      Go to blog

      49 | 50 |
    • 51 |
    52 |
    53 |
  • 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /components/cards/PostCard/index.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import Link from "next/link"; 3 | import { ROUTES } from "@/routes"; 4 | import Image from "next/image"; 5 | import { IPost } from "@/types"; 6 | import { useParams } from "next/navigation"; 7 | import { Badge } from "../../ui/Badge"; 8 | 9 | export default function PostCard(props: { 10 | index: number; 11 | data: IPost; 12 | page?: string; 13 | }): JSX.Element { 14 | const params = useParams(); 15 | return ( 16 | 22 | 23 |
    24 |
    25 | published 🎉 26 | ... 33 |
    34 |
    35 |

    36 | {props.data.title} 37 |

    38 |
    39 |
    40 | 41 |
    42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/footers/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/routes"; 2 | import Link from "next/link"; 3 | 4 | export default function Footer(): JSX.Element { 5 | return ( 6 |
    7 |

    8 | © {new Date().getFullYear()}{" "} 9 | 10 | Pingu Labs 11 | 12 |

    13 |
    14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /components/footers/PublicFooter/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/Button"; 2 | import WidthConstraint from "@/layouts/widthConstraint"; 3 | import { ROUTES } from "@/routes"; 4 | import Link from "next/link"; 5 | 6 | export default function PublicFooter(props: { blogName?: string }): JSX.Element { 7 | return ( 8 |
    9 | 10 |
      11 |
    • 12 |

      13 | ©{new Date().getFullYear()}  14 | {props.blogName} 15 |

      16 |
    • 17 |
    • 18 | 19 | 20 | 21 |
    • 22 |
    • 23 |

      24 | Powered by  25 | 26 | Pingu Labs  27 | 28 | - a blogging app 29 |

      30 |
    • 31 |
    32 |
    33 |
    34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /components/tabs/Analytics/index.tsx: -------------------------------------------------------------------------------- 1 | // import { AreaChart, BarList, Bold, Card, Flex, Title } from "@tremor/react"; 2 | // import WidthConstraint from "@/layouts/widthConstraint"; 3 | export default function Analytics(): JSX.Element { 4 | // const chartdata = [ 5 | // { 6 | // date: "Jan 22", 7 | // SemiAnalysis: 2890, 8 | // "The Pragmatic Engineer": 2338, 9 | // }, 10 | // { 11 | // date: "Feb 22", 12 | // SemiAnalysis: 2756, 13 | // "The Pragmatic Engineer": 2103, 14 | // }, 15 | // { 16 | // date: "Mar 22", 17 | // SemiAnalysis: 3322, 18 | // "The Pragmatic Engineer": 2194, 19 | // }, 20 | // { 21 | // date: "Apr 22", 22 | // SemiAnalysis: 3470, 23 | // "The Pragmatic Engineer": 2108, 24 | // }, 25 | // { 26 | // date: "May 22", 27 | // SemiAnalysis: 3475, 28 | // "The Pragmatic Engineer": 1812, 29 | // }, 30 | // { 31 | // date: "Jun 22", 32 | // SemiAnalysis: 3129, 33 | // "The Pragmatic Engineer": 1726, 34 | // }, 35 | // ]; 36 | 37 | // const data = [ 38 | // { 39 | // name: "Twitter", 40 | // value: 456, 41 | // href: "https://twitter.com/tremorlabs", 42 | // icon: function TwitterIcon() { 43 | // return ( 44 | // 51 | // 52 | // 53 | // 54 | // ); 55 | // }, 56 | // }, 57 | // { 58 | // name: "Google", 59 | // value: 351, 60 | // href: "https://google.com", 61 | // icon: function GoogleIcon() { 62 | // return ( 63 | // 70 | // 71 | // 72 | // 73 | // ); 74 | // }, 75 | // }, 76 | // { 77 | // name: "GitHub", 78 | // value: 271, 79 | // href: "https://github.com/tremorlabs/tremor", 80 | // icon: function GitHubIcon() { 81 | // return ( 82 | // 89 | // 90 | // 91 | // 92 | // ); 93 | // }, 94 | // }, 95 | // { 96 | // name: "Reddit", 97 | // value: 191, 98 | // href: "https://reddit.com", 99 | // icon: function RedditIcon() { 100 | // return ( 101 | // 108 | // 109 | // 110 | // 111 | // ); 112 | // }, 113 | // }, 114 | // { 115 | // name: "Youtube", 116 | // value: 91, 117 | // href: "https://www.youtube.com/@tremorlabs3079", 118 | // icon: function YouTubeIcon() { 119 | // return ( 120 | // 127 | // 128 | // 129 | // 130 | // ); 131 | // }, 132 | // }, 133 | // ]; 134 | 135 | // const dataFormatter = (number: number) => { 136 | // return "$ " + Intl.NumberFormat("us").format(number).toString(); 137 | // }; 138 | 139 | // return ( 140 | // 141 | // 142 | // Newsletter revenue over time (USD) 143 | // 151 | // 152 | // 153 | // Website Analytics 154 | // 155 | //

    156 | // Source 157 | //

    158 | //

    159 | // Visits 160 | //

    161 | //
    162 | // 163 | //
    164 | //
    165 | // ); 166 | return <>; 167 | } 168 | -------------------------------------------------------------------------------- /components/tabs/BlogSettings/index.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import { useState } from "react"; 3 | import Image from "next/image"; 4 | import { Badge } from "@/components/ui/Badge"; 5 | import { BLOG_THEMES, BLOG_SETTINGS_TABS } from "@/lib/constants/nav"; 6 | import WidthConstraint from "@/layouts/widthConstraint"; 7 | import Meta from "@/components/Meta"; 8 | import { useParams } from "next/navigation"; 9 | 10 | export default function Themes(): JSX.Element { 11 | const [selectedTheme, setSelectedTheme] = useState(BLOG_THEMES[0].name); 12 | return ( 13 | 41 | ); 42 | } 43 | 44 | export function BlogSettings(): JSX.Element { 45 | const params = useParams(); 46 | const [selectedTab, setSelectedTab] = useState(BLOG_SETTINGS_TABS[0]); 47 | 48 | function tabViews(): JSX.Element { 49 | switch (selectedTab) { 50 | case BLOG_SETTINGS_TABS[0]: 51 | return ; 52 | default: 53 | return

    :eyes:

    ; 54 | } 55 | } 56 | 57 | return ( 58 | <> 59 | 60 | 61 |
    62 |
      63 | {BLOG_SETTINGS_TABS.map((tab, index) => ( 64 |
    • setSelectedTab(tab)} 67 | className={`cursor-pointer hover:text-primary transition-colors flex items-center space-x-1 md:first:ml-0 first:ml-10 ${ 68 | selectedTab === tab && "text-primary" 69 | }`} 70 | > 71 |
      {tab.icon}
      72 |

      {tab.label}

      73 |
    • 74 | ))} 75 |
    76 |
    {tabViews()}
    77 |
    78 |
    79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /components/tabs/Posts/[domain]/index.tsx: -------------------------------------------------------------------------------- 1 | import Meta from "@/components/Meta"; 2 | import PostCard from "@/components/cards/PostCard"; 3 | import { Button } from "@/components/ui/Button"; 4 | import WidthConstraint from "@/layouts/widthConstraint"; 5 | import { generate12ByteID } from "@/lib/utils"; 6 | import { ROUTES } from "@/routes"; 7 | import { IPost } from "@/types"; 8 | import Link from "next/link"; 9 | import { useParams } from "next/navigation"; 10 | 11 | export function Posts(props: { posts: IPost[] }): JSX.Element { 12 | const params = useParams(); 13 | return ( 14 | <> 15 | 16 | 17 | {props.posts.length > 0 ? ( 18 |
      19 | {props.posts.map((post, index) => ( 20 | 26 | ))} 27 |
    28 | ) : ( 29 |
    30 |

    You don't have any posts yet!

    31 | 36 | 37 | 38 |
    39 | )} 40 |
    41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/ui/Badge/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
    33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /components/ui/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-all active:scale-[0.95] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-white shadow hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "underline-offset-4 hover:underline", 21 | tab: "active:scale-1", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | md: "h-14 rounded-md px-3", 27 | lg: "h-10 rounded-md px-8", 28 | icon: "h-9 w-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | } 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /components/ui/Dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { X } from "lucide-react"; 6 | 7 | const Dialog = DialogPrimitive.Root; 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger; 10 | 11 | const DialogPortal = DialogPrimitive.Portal; 12 | 13 | const DialogOverlay = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )); 26 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 27 | 28 | const DialogContent = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, children, ...props }, ref) => ( 32 | 33 | 34 | 42 | {children} 43 | 44 | 45 | Close 46 | 47 | 48 | 49 | )); 50 | DialogContent.displayName = DialogPrimitive.Content.displayName; 51 | 52 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( 53 |
    57 | ); 58 | DialogHeader.displayName = "DialogHeader"; 59 | 60 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( 61 |
    68 | ); 69 | DialogFooter.displayName = "DialogFooter"; 70 | 71 | const DialogTitle = React.forwardRef< 72 | React.ElementRef, 73 | React.ComponentPropsWithoutRef 74 | >(({ className, ...props }, ref) => ( 75 | 80 | )); 81 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 82 | 83 | const DialogDescription = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 92 | )); 93 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 94 | 95 | export { 96 | Dialog, 97 | DialogPortal, 98 | DialogOverlay, 99 | DialogTrigger, 100 | DialogContent, 101 | DialogHeader, 102 | DialogFooter, 103 | DialogTitle, 104 | DialogDescription, 105 | }; 106 | -------------------------------------------------------------------------------- /components/ui/Label/index.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-sm font-normal 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 | 17 | )); 18 | Label.displayName = LabelPrimitive.Root.displayName; 19 | 20 | export { Label }; 21 | -------------------------------------------------------------------------------- /components/ui/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function Loader(): JSX.Element { 4 | return ( 5 |
    6 | pingu logo 13 | {/* */} 14 |
    15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /components/ui/PopOver/index.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 | 25 | 26 | )); 27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 28 | 29 | export { Popover, PopoverTrigger, PopoverContent }; 30 | -------------------------------------------------------------------------------- /components/ui/Switch/index.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 | 23 | 24 | )); 25 | Switch.displayName = SwitchPrimitives.Root.displayName; 26 | 27 | export { Switch }; 28 | -------------------------------------------------------------------------------- /config/store.ts: -------------------------------------------------------------------------------- 1 | import { StoreState } from "@/interface"; 2 | import { create } from "zustand"; 3 | 4 | export const useStore = create((set) => ({ 5 | showMainAside: false, 6 | showWriteAside: false, 7 | setShowMainAside: (showMainAside) => set(() => ({ showMainAside: showMainAside })), 8 | setShowWriteAside: (showWriteAside) => set(() => ({ showWriteAside: showWriteAside })), 9 | })); 10 | -------------------------------------------------------------------------------- /hooks/usePost.ts: -------------------------------------------------------------------------------- 1 | import { IPost } from "@/types"; 2 | import { useUser } from "@clerk/nextjs"; 3 | import { useEffect, useState } from "react"; 4 | 5 | export function usePost(props: IPost) { 6 | console.log(props); 7 | const { user } = useUser(); 8 | const [isOwner, setIsOwner] = useState(); 9 | 10 | useEffect(() => { 11 | setIsOwner(props && props.author === user?.emailAddresses[0].emailAddress); 12 | }, [props, user?.emailAddresses]); 13 | 14 | return { isOwner }; 15 | } 16 | -------------------------------------------------------------------------------- /hooks/useWindow.ts: -------------------------------------------------------------------------------- 1 | import { IWindow } from "@/interface"; 2 | import { useEffect, useState } from "react"; 3 | /** returns info about window */ 4 | export default function useWindow(): IWindow { 5 | const [screenSize, setScreenSize] = useState<{ width: number; height: number }>({ 6 | width: 300, 7 | height: 300, 8 | }); 9 | 10 | useEffect(() => { 11 | setScreenSize({ width: window.innerWidth, height: window.innerHeight }); 12 | }, []); 13 | 14 | return { width: screenSize?.width, height: screenSize?.height }; 15 | } 16 | -------------------------------------------------------------------------------- /interface.ts: -------------------------------------------------------------------------------- 1 | import { IBlog, IPost } from "./types"; 2 | 3 | export interface StoreState extends StoreAction { 4 | showMainAside: boolean; 5 | showWriteAside: boolean; 6 | } 7 | 8 | export interface StoreAction { 9 | setShowMainAside: (showMainAside: StoreState["showMainAside"]) => void; 10 | setShowWriteAside: (showWriteAside: StoreState["showWriteAside"]) => void; 11 | } 12 | 13 | export interface IWindow { 14 | width: number; 15 | height: number; 16 | } 17 | 18 | export interface BlogProps extends IBlog { 19 | posts: IPost[]; 20 | } 21 | 22 | export interface MetaProps { 23 | imageAlt?: string; 24 | description?: string; 25 | image?: string; 26 | title: string; 27 | url?: string; 28 | } 29 | -------------------------------------------------------------------------------- /layouts/authLayout.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/routes"; 2 | import { useAuth } from "@clerk/nextjs"; 3 | import { useRouter } from "next/router"; 4 | import { PropsWithChildren, ReactNode } from "react"; 5 | 6 | export default function AuthLayout(props: PropsWithChildren): ReactNode { 7 | const { userId, isLoaded } = useAuth(); 8 | const { replace } = useRouter(); 9 | 10 | if (isLoaded) { 11 | if (!userId) replace(ROUTES.auth); 12 | else return props.children; 13 | } else return <>; 14 | } 15 | -------------------------------------------------------------------------------- /layouts/widthConstraint.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { ReactNode } from "react"; 3 | export default function WidthConstraint(props: { 4 | children?: ReactNode; 5 | className?: string; 6 | }): JSX.Element { 7 | return ( 8 |
    9 | {props.children} 10 |
    11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const META_CONTENT = { 2 | title: "pingu", 3 | description: "the cool blogging app", 4 | image: 5 | "https://res.cloudinary.com/follio/image/upload/v1696762121/bfnmtin2bi95h6ty5qxi.png", 6 | imageAlt: "pingu banner image", 7 | siteName: "pingu", 8 | url: "https://www.pingu.sh", 9 | twitterCreator: "@pingulabs_", 10 | keywords: "blog app", 11 | }; 12 | -------------------------------------------------------------------------------- /lib/constants/nav.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PaintBucketIcon, 3 | Globe, 4 | Workflow, 5 | Code, 6 | Library, 7 | Settings2, 8 | TrendingUp, 9 | BookOpenIcon, 10 | Folder, 11 | Settings, 12 | } from "lucide-react"; 13 | 14 | export const BLOG_SETTINGS_TABS = [ 15 | { 16 | label: "Theme", 17 | icon: , 18 | }, 19 | { label: "Domains", icon: }, 20 | { label: "Integration", icon: }, 21 | { label: "Developer", icon: }, 22 | ]; 23 | 24 | export const BLOG_THEMES: { name: string; image: string; isPremium?: boolean }[] = [ 25 | { 26 | name: "Astral", 27 | image: "/assets/themes/1.png", 28 | isPremium: true, 29 | }, 30 | { 31 | name: "Demo", 32 | image: "/assets/themes/2.png", 33 | }, 34 | { 35 | name: "Super", 36 | image: "/assets/themes/3.png", 37 | isPremium: true, 38 | }, 39 | ]; 40 | 41 | export const PUB_TABS: { label: string; icon: JSX.Element }[] = [ 42 | { label: "Posts", icon: }, 43 | { label: "Analytics", icon: }, 44 | { label: "Settings", icon: }, 45 | ]; 46 | 47 | export const MAIN_ASIDE_NAV_LINKS = [ 48 | { label: "My Publications", page: "/dashboard", icon: }, 49 | { label: "Settings", page: "/settings", icon: }, 50 | { label: "Docs", page: "/documentation", icon: }, 51 | ]; 52 | -------------------------------------------------------------------------------- /lib/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const blogSchema = z.object({ 4 | id: z.string(), 5 | sub_domain: z.string(), 6 | owner: z.string(), 7 | name: z.string(), 8 | custom_domain: z.string().optional(), 9 | disable_comments: z.boolean().optional(), 10 | logo: z.string().optional(), 11 | favicon: z.string().optional(), 12 | theme: z.string().optional(), 13 | show_stats: z.boolean().optional(), 14 | custom_css: z.string().optional(), 15 | theme_color: z.string().optional(), 16 | created_at: z.string().optional(), 17 | updated_at: z.string().optional(), 18 | banner: z.string().optional(), 19 | description: z.string().optional(), 20 | }); 21 | 22 | export const editorContentSchema = z.object({ 23 | type: z.string().optional(), 24 | attrs: z.record(z.any()).optional(), 25 | content: z.array(z.record(z.any())).optional(), 26 | marks: z 27 | .array( 28 | z.object({ 29 | type: z.string(), 30 | attrs: z.record(z.any()).optional(), 31 | }) 32 | ) 33 | .optional(), 34 | text: z.string().optional(), 35 | }); 36 | 37 | export const postSchema = z.object({ 38 | id: z.string(), 39 | title: z.string(), 40 | sub_title: z.string(), 41 | tags: z.array(z.string()), 42 | sub_domain: z.string(), 43 | parent_id: z.string(), 44 | slug: z.string(), 45 | content_html: z.string(), 46 | content_json: editorContentSchema, 47 | show_stats: z.boolean().optional(), 48 | banner: z.string().optional(), 49 | show_toc: z.boolean().optional(), 50 | created_at: z.string().optional(), 51 | updated_at: z.string().optional(), 52 | is_published: z.boolean(), 53 | meta: z.object({ 54 | title: z.string(), 55 | description: z.string().optional(), 56 | image: z.string(), 57 | }), 58 | author: z 59 | .object({ 60 | id: z.string(), 61 | name: z.string(), 62 | email: z.string(), 63 | photo: z.string(), 64 | }) 65 | .optional(), 66 | }); 67 | 68 | // a copy of `postSchema` with only reqquired fields for form validation 69 | export const formPostSchema = z.object({ 70 | show_toc: postSchema.shape.show_toc, 71 | show_stats: postSchema.shape.show_stats, 72 | meta: postSchema.shape.meta, 73 | banner: postSchema.shape.banner, 74 | }); 75 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import clsx, { ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | export function cn(...inputs: ClassValue[]) { 4 | return twMerge(clsx(inputs)); 5 | } 6 | 7 | /** generate a unique mongodb 12byte ID */ 8 | export function generate12ByteID() { 9 | const timestamp = Math.floor(Date.now() / 1000) 10 | .toString(16) 11 | .padStart(8, "0"); // 4 bytes 12 | const randomValue = generateRandomBytes(5); // 5 bytes 13 | const incrementingCounter = generateRandomBytes(3); // 3 bytes 14 | const objectId = timestamp + randomValue + incrementingCounter; // Combine the parts into a 24-character hexadecimal string 15 | return objectId; 16 | } 17 | 18 | function generateRandomBytes(byteCount: number) { 19 | const bytes = []; 20 | for (let i = 0; i < byteCount; i++) { 21 | const randomByte = Math.floor(Math.random() * 256) 22 | .toString(16) 23 | .padStart(2, "0"); 24 | bytes.push(randomByte); 25 | } 26 | return bytes.join(""); 27 | } 28 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from "@clerk/nextjs"; 2 | 3 | export default authMiddleware({ 4 | publicRoutes: [ 5 | "/", 6 | "/:path", 7 | "/:path/:path", 8 | "/:path/:path/:path", 9 | "/:path/:path/:path/:path", 10 | ], 11 | }); 12 | 13 | export const config = { 14 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 15 | }; 16 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | domains: ["img.clerk.com", "res.cloudinary.com"], 6 | }, 7 | }; 8 | 9 | module.exports = nextConfig; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pingu", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "npx prisma generate && next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@clerk/nextjs": "^4.25.1", 13 | "@prisma/client": "5.4.1", 14 | "@radix-ui/react-dialog": "^1.0.5", 15 | "@radix-ui/react-label": "^2.0.2", 16 | "@radix-ui/react-popover": "^1.0.7", 17 | "@radix-ui/react-slot": "^1.0.2", 18 | "@radix-ui/react-switch": "^1.0.3", 19 | "@tailwindcss/line-clamp": "^0.4.4", 20 | "@tanstack/react-query": "^4.35.7", 21 | "@tanstack/react-query-devtools": "^4.35.7", 22 | "@tremor/react": "^3.8.2", 23 | "axios": "^1.5.1", 24 | "class-variance-authority": "^0.7.0", 25 | "clsx": "^2.0.0", 26 | "formik": "^2.4.5", 27 | "framer-motion": "^10.16.4", 28 | "lucide-react": "^0.284.0", 29 | "next": "latest", 30 | "novel": "^0.1.22", 31 | "react": "latest", 32 | "react-confetti": "^6.1.0", 33 | "react-dom": "latest", 34 | "react-dropzone": "^14.2.3", 35 | "react-query": "^3.39.3", 36 | "short-uuid": "^4.2.2", 37 | "sonner": "^1.0.3", 38 | "tailwind-merge": "^1.14.0", 39 | "zod": "^3.22.4", 40 | "zod-formik-adapter": "^1.2.0", 41 | "zustand": "^4.4.2" 42 | }, 43 | "devDependencies": { 44 | "@types/node": "latest", 45 | "@types/react": "latest", 46 | "@types/react-dom": "latest", 47 | "autoprefixer": "latest", 48 | "eslint": "latest", 49 | "eslint-config-next": "latest", 50 | "postcss": "latest", 51 | "prisma": "^5.4.1", 52 | "tailwindcss": "latest", 53 | "typescript": "latest" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pages/[domain].tsx: -------------------------------------------------------------------------------- 1 | import Meta from "@/components/Meta"; 2 | import PublicFooter from "@/components/footers/PublicFooter"; 3 | import { Button } from "@/components/ui/Button"; 4 | import { BlogProps } from "@/interface"; 5 | import WidthConstraint from "@/layouts/widthConstraint"; 6 | import prisma from "@/prisma"; 7 | import { IServerSideProps } from "@/types"; 8 | import { Field, Form, Formik } from "formik"; 9 | import Link from "next/link"; 10 | import { toast } from "sonner"; 11 | 12 | export default function Blog(props: { blog: BlogProps }): JSX.Element { 13 | console.log(props); 14 | 15 | function subscribeToNewsLetter() { 16 | toast.success(`You have subscribed to ${props.blog.name}'s newsletter`); 17 | } 18 | 19 | if (!props.blog) return <>; 20 | return ( 21 | <> 22 | 28 |
    29 |
    30 | 31 |

    {props.blog.name}

    32 |
    33 |
    34 | 35 | {props.blog.posts.length > 0 ? ( 36 |
      37 | {props.blog.posts.map((post, index) => ( 38 |
    • 39 | 43 |

      44 | {post.title} 45 |

      46 |
        47 | {post.show_stats &&
      • 2.3K views
      • } 48 |
      • 10mins read
      • 49 |
      • 50 | {new Date(post.created_at ?? "").toDateString().substring(0, 15)} 51 |
      • 52 |
      53 |

      {post.meta.description}

      54 | 55 |
    • 56 | ))} 57 |
    58 | ) : ( 59 |

    There are no posts

    60 | )} 61 |
    62 |
    63 | 64 |

    Subscribe to the newsletter

    65 |

    66 | Your trusted source for wide-ranging perspectives, thought-provoking 67 | analysis, and deep insights that resonate. 68 |

    69 | 70 | {({ values }) => ( 71 |
    72 |
    73 | 80 | 83 |
    84 |
    85 | )} 86 |
    87 |
    88 |
    89 | 90 |
    91 | 92 | ); 93 | } 94 | 95 | export async function getStaticPaths() { 96 | return { 97 | paths: ["/domain"], 98 | fallback: true, 99 | }; 100 | } 101 | 102 | export async function getStaticProps(context: IServerSideProps) { 103 | const domain = context.params.domain; 104 | const blog = await prisma.blog.findFirst({ where: { sub_domain: { equals: domain } } }); 105 | const posts = await prisma.post.findMany({ 106 | where: { 107 | parent_id: { 108 | equals: blog?.id, 109 | }, 110 | }, 111 | }); 112 | const _ = JSON.parse(JSON.stringify({ ...blog, posts })); 113 | return { 114 | props: { blog: _ }, 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /pages/[domain]/[slug].tsx: -------------------------------------------------------------------------------- 1 | import Meta from "@/components/Meta"; 2 | import PublicFooter from "@/components/footers/PublicFooter"; 3 | import { Button } from "@/components/ui/Button"; 4 | import WidthConstraint from "@/layouts/widthConstraint"; 5 | import prisma from "@/prisma"; 6 | import { IServerSideProps, IPost } from "@/types"; 7 | import { ArrowLeft } from "lucide-react"; 8 | import Image from "next/image"; 9 | import { useParams } from "next/navigation"; 10 | import { useRouter } from "next/router"; 11 | 12 | export default function ReadPost(props: { post: IPost }): JSX.Element { 13 | const params = useParams(); 14 | const { push } = useRouter(); 15 | 16 | // return a 404 component if post does not exist 17 | 18 | if (!props.post || !params) return <>; 19 | 20 | return ( 21 | <> 22 | 27 | 28 |
    29 | 37 |

    {props.post.title}

    38 |
      39 |
    • 40 | {`${props.post.author?.name}'s 47 |

      {props.post.author?.name} · 

      48 |
    • 49 | {props.post.show_stats &&
    • 2.3K views · 
    • } 50 |
    • 10mins read · 
    • 51 |
    • 52 | {new Date(props.post.created_at ?? "").toDateString().substring(0, 15)} 53 |
    • 54 |
    55 | {props.post.banner && ( 56 | {`banner 63 | )} 64 |
    65 |
    69 | 70 | 71 | 72 | ); 73 | } 74 | 75 | export async function getStaticPaths() { 76 | return { 77 | paths: ["/domain/slug"], 78 | fallback: true, 79 | }; 80 | } 81 | 82 | export async function getStaticProps(context: IServerSideProps) { 83 | const domain = context.params.domain; 84 | const slug = context.params.slug; 85 | const post = await prisma.post.findFirst({ 86 | where: { 87 | slug: { 88 | equals: slug, 89 | }, 90 | AND: [{ sub_domain: { equals: domain } }], 91 | }, 92 | }); 93 | console.log(post); 94 | return { 95 | props: { post: JSON.parse(JSON.stringify(post)) }, 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/routes"; 2 | import "@/styles/globals.css"; 3 | import { ClerkProvider } from "@clerk/nextjs"; 4 | import type { AppProps } from "next/app"; 5 | import { Inter } from "next/font/google"; 6 | import { Toaster } from "sonner"; 7 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 8 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 9 | 10 | const inter = Inter({ subsets: ["latin"] }); 11 | const queryClient = new QueryClient(); 12 | 13 | export default function App({ Component, pageProps }: AppProps) { 14 | return ( 15 | 21 | 22 | 23 |
    24 | 25 | 26 |
    27 |
    28 |
    29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
    9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/blog/create.ts: -------------------------------------------------------------------------------- 1 | import { blogSchema } from "@/lib/schema"; 2 | import prisma from "@/prisma"; 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | import { getAuth } from "@clerk/nextjs/server"; 5 | import { clerkClient } from "@clerk/nextjs"; 6 | 7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 8 | console.log(req.body); 9 | try { 10 | const { userId } = getAuth(req); 11 | if (!userId) { 12 | res.status(401).json({ error: "Unauthorized" }); 13 | return; 14 | } 15 | 16 | const user = await clerkClient.users.getUser(userId); 17 | req.body.owner = user.emailAddresses[0].emailAddress; 18 | const validation = blogSchema.safeParse(req.body); 19 | 20 | if (!validation.success) { 21 | res.status(400).json({ error: validation.error.issues }); 22 | } else { 23 | console.log(validation.data); 24 | 25 | const blogExists = await prisma.blog.findFirst({ 26 | where: { 27 | sub_domain: { 28 | equals: validation.data.sub_domain, 29 | }, 30 | }, 31 | }); 32 | console.log({ blogExists }); 33 | if (blogExists) { 34 | res.status(400).json({ error: "Subdomain is taken" }); 35 | } else { 36 | await prisma.blog.create({ data: { ...validation.data } }); 37 | res.status(200).send({ message: "Blog created successfully!" }); 38 | } 39 | } 40 | } catch (error) { 41 | console.log(error); 42 | res.status(500).json({ error }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pages/api/blog/fetch-all.ts: -------------------------------------------------------------------------------- 1 | import prisma from "@/prisma"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import { getAuth } from "@clerk/nextjs/server"; 4 | import { clerkClient } from "@clerk/nextjs"; 5 | 6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | const { userId } = getAuth(req); 8 | console.log(userId); 9 | if (!userId) { 10 | res.status(401).json({ error: "Unauthorized" }); 11 | return; 12 | } 13 | try { 14 | const user = await clerkClient.users.getUser(userId); 15 | const blogs = await prisma.blog.findMany({ 16 | where: { 17 | owner: { 18 | equals: user.emailAddresses[0].emailAddress, 19 | }, 20 | }, 21 | }); 22 | res.status(200).json({ data: blogs }); 23 | } catch (error) { 24 | console.log(error); 25 | res.status(500).json(error); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pages/api/post/create.ts: -------------------------------------------------------------------------------- 1 | import { postSchema } from "@/lib/schema"; 2 | import prisma from "@/prisma"; 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | import { getAuth } from "@clerk/nextjs/server"; 5 | import { clerkClient } from "@clerk/nextjs"; 6 | 7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 8 | try { 9 | const { userId } = getAuth(req); 10 | if (!userId) { 11 | res.status(401).json({ error: "Unauthorized" }); 12 | return; 13 | } 14 | 15 | const user = await clerkClient.users.getUser(userId); 16 | const validation = postSchema.safeParse(req.body); 17 | 18 | if (!validation.success) { 19 | console.log(validation.error.issues); 20 | res.status(400).json({ error: validation.error.issues }); 21 | } else { 22 | const exists = await prisma.post.findFirst({ 23 | where: { 24 | id: validation.data.id, 25 | }, 26 | }); 27 | 28 | console.log(validation.data); 29 | 30 | if (!exists) { 31 | console.log("creating..."); 32 | const doc = await prisma.post.create({ 33 | data: { 34 | ...validation.data, 35 | created_at: new Date().toISOString(), 36 | author: { 37 | id: user.id, 38 | email: user.emailAddresses[0].emailAddress, 39 | name: `${user.firstName} ${user.lastName ?? ""}`, 40 | photo: user.imageUrl, 41 | }, 42 | }, 43 | }); 44 | res.status(200).json({ data: doc, message: "created!" }); 45 | } else { 46 | console.log("updating..."); 47 | const updatedPostData = { 48 | ...validation.data, 49 | updated_at: new Date().toISOString(), 50 | id: undefined, 51 | sub_domain: undefined, 52 | }; 53 | const updatedDoc = await prisma.post.update({ 54 | data: updatedPostData, 55 | where: { 56 | id: validation.data.id, 57 | }, 58 | }); 59 | res.status(200).json({ data: updatedDoc, message: "updated!" }); 60 | } 61 | } 62 | } catch (error) { 63 | console.log(error); 64 | res.status(500).json({ error }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /pages/auth.tsx: -------------------------------------------------------------------------------- 1 | import Meta from "@/components/Meta"; 2 | import { Button } from "@/components/ui/Button"; 3 | import { ROUTES } from "@/routes"; 4 | import Link from "next/link"; 5 | import { motion } from "framer-motion"; 6 | 7 | export default function Auth() { 8 | return ( 9 | <> 10 | 11 |
    12 | 17 |
    18 |

    Welcome to pingu

    19 |

    Some cool text should come here

    20 |
    21 |
      22 |
    • 23 | 24 | 25 | 26 |
    • 27 |
    • 28 | 29 | 32 | 33 |
    • 34 |
    35 |
    36 | 37 | By signing up or creating an account, you acknowledge that you have read and 38 | understood our   39 | 40 | terms & conditions 41 | 42 | 43 |
    44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /pages/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import BlogCard from "@/components/cards/BlogCard"; 2 | import Header from "@/components/Header"; 3 | import Loader from "@/components/ui/Loader"; 4 | import Meta from "@/components/Meta"; 5 | import AuthLayout from "@/layouts/authLayout"; 6 | import { IBlog } from "@/types"; 7 | import { useQuery } from "@tanstack/react-query"; 8 | import axios from "axios"; 9 | import { ROUTES } from "@/routes"; 10 | import NoContent from "@/components/NoContent"; 11 | import { Plus } from "lucide-react"; 12 | import Footer from "@/components/footers/Footer"; 13 | 14 | export default function Dashboard(): JSX.Element { 15 | const { isLoading, isError, error, data } = useQuery(["fetch user blogs"], async () => { 16 | const blog = await axios.get("/api/blog/fetch-all"); 17 | return (blog.data.data as IBlog[]) ?? []; 18 | }); 19 | 20 | console.log(data); 21 | console.log({ isError, error }); 22 | 23 | return ( 24 | <> 25 | 26 | 27 |
    28 |
    29 | {isError && !isLoading &&

    An error occured loading your blogs

    } 30 | {isLoading && !isError ? ( 31 | 32 | ) : ( 33 | <> 34 | {data && data.length > 0 ? ( 35 |
      36 | {data.map((blog: IBlog, index: number) => ( 37 | 38 | ))} 39 |
    40 | ) : ( 41 | } 45 | buttonLabel="Create new publication" 46 | /> 47 | )} 48 | 49 | )} 50 |
    51 | 52 |