├── .env.local.example ├── .gitignore ├── README.md ├── TODOs.md ├── components ├── AddSiteModal.js ├── BMAC.js ├── DeleteSiteModal.js ├── EditSiteModal.js ├── Footer.js ├── Hero.js ├── HiddenEmail.js ├── LoginButtons.js ├── MDXComponents.js ├── NavBar.js ├── NextBreadcrumb.js ├── PageShell.js ├── Showcase.js ├── SiteTable.js └── icons.js ├── firestore.rules ├── jsconfig.json ├── lib ├── auth.js ├── db-admin.js ├── db.js ├── firebase-admin.js ├── firebase.js └── middlewares.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── _document.js ├── account.js ├── api │ ├── auth │ │ ├── ghostwebhooks.js │ │ ├── post2md.js │ │ ├── site │ │ │ └── [siteId].js │ │ └── sites.js │ ├── ghostApiTest.js │ ├── ghosthook.js │ ├── ghpreview.js │ └── tmp │ │ ├── addthis.js │ │ ├── firebaseTest.js │ │ └── hello.js ├── dashboard.js ├── faq.mdx ├── index.js └── test.js ├── public ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── images │ └── demo.webp ├── og.jpg └── static │ └── ghpreview.js ├── styles └── theme.js ├── utils ├── fetcher.js ├── logger.js ├── truncateHtml.js └── urltest.js └── yarn.lock /.env.local.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_FIREBASE_API_KEY = 2 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN = 3 | NEXT_PUBLIC_FIREBASE_PROJECT_ID = 4 | NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET = 5 | NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID = 6 | NEXT_PUBLIC_FIREBASE_APP_ID = 7 | NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID = 8 | NEXT_PUBLIC_FIREBASE_DATABASE_URL = 9 | 10 | FIREBASE_PRIVATE_KEY = 11 | FIREBASE_CLIENT_EMAIL = 12 | 13 | NEXT_PUBLIC_LOGFLARE_KEY = 14 | NEXT_PUBLIC_LOGFLARE_STREAM = 15 | -------------------------------------------------------------------------------- /.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 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | dingran-me-firebase-adminsdk-*.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /TODOs.md: -------------------------------------------------------------------------------- 1 | Next up 2 | 3 | - [ ] send timing stats to client in ghpreview 4 | - [ ] switch to plausible analytics 5 | - [ ] clean up API routes 6 | - [ ] need to put auth in front of post2md route 7 | - [ ] design stats collection to power billing 8 | - [ ] can I use logflare as Google Analytics (disable console logging only send to log drain) 9 | - [ ] establish the pattern of axios call inside of db.js from client side 10 | 11 | Done 12 | 13 | - [x] set up seo, at leset the link card should work in my blog, maybe use next-seo 14 | - [x] have a correct account page 15 | - [x] on use preview in db if site hasm't be updated after preview's creation time 16 | - [x] html to markdown converstion utils 17 | - [x] My sites page should have a good empty state 18 | - [x] set up logflare for vercel 19 | - [x] look into https://github.com/pinojs/pino/blob/master/docs/browser.md; and pino pretty 20 | - [x] site tabel shouldn't display preview ratio or any other settings 21 | - [x] in deletion flow need to delete Ghost webhook 22 | - [x] edit modal's default value need to be reset after update 23 | - [x] refactor add site vs edit site modal, too much repeated logic 24 | - [x] undo the refactor; we need to restrict what people can edit 25 | - [x] edit site modal used the write mutation flow, need to change add site modal to match that 26 | - [x] add add new site button to dashboard 27 | - [x] set up firestore access rule for webhook creation 28 | - [x] add the webhook creation into somewhere in the site registration flow 29 | - [x] adjust site table to show read only content (stats etc) 30 | - [x] add delete site button and logic 31 | - [x] add site edit flow, through a modal 32 | 33 | Backlog 34 | 35 | - [ ] show some stats/analytics for each site? 36 | - [ ] add site details view, as a modal? 37 | - [ ] display webhook status somewhere 38 | - [ ] add ways to create webhook later 39 | - [ ] test out post.unpublished and post.published event handling 40 | - [ ] log a list of blog topics 41 | 42 | Optional 43 | 44 | - [ ] try and fix the following fireabase auth error 45 | 46 | ``` 47 | FirebaseAuthError: Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired). See https://firebase.google.com/docs/auth/admin/verify-id-tokens for details on how to retrieve an ID token. 48 | at FirebaseAuthError.FirebaseError [as constructor] (/Users/rding/projects/ghost-utils/node_modules/firebase-admin/lib/utils/error.js:44:28) 49 | at FirebaseAuthError.PrefixedFirebaseError [as constructor] (/Users/rding/projects/ghost-utils/node_modules/firebase-admin/lib/utils/error.js:90:28) 50 | at new FirebaseAuthError (/Users/rding/projects/ghost-utils/node_modules/firebase-admin/lib/utils/error.js:149:16) 51 | at /Users/rding/projects/ghost-utils/node_modules/firebase-admin/lib/auth/token-verifier.js:212:39 52 | at /Users/rding/projects/ghost-utils/node_modules/jsonwebtoken/verify.js:152:16 53 | at getSecret (/Users/rding/projects/ghost-utils/node_modules/jsonwebtoken/verify.js:90:14) 54 | at Object.module.exports [as verify] (/Users/rding/projects/ghost-utils/node_modules/jsonwebtoken/verify.js:94:10) 55 | at /Users/rding/projects/ghost-utils/node_modules/firebase-admin/lib/auth/token-verifier.js:204:17 56 | at new Promise () 57 | at FirebaseTokenVerifier.verifyJwtSignatureWithKey (/Users/rding/projects/ghost-utils/node_modules/firebase-admin/lib/auth/token-verifier.js:203:16) 58 | at /Users/rding/projects/ghost-utils/node_modules/firebase-admin/lib/auth/token-verifier.js:188:30 59 | at runMicrotasks () 60 | at processTicksAndRejections (internal/process/task_queues.js:93:5) { 61 | errorInfo: { 62 | code: 'auth/id-token-expired', 63 | message: 'Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired). See https://firebase.google.com/docs/auth/admin/verify-id-tokens for details on how to retrieve an ID token.' 64 | }, 65 | codePrefix: 'auth' 66 | ``` 67 | -------------------------------------------------------------------------------- /components/AddSiteModal.js: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { mutate } from 'swr'; 3 | import axios from 'axios'; 4 | 5 | import { 6 | Modal, 7 | ModalOverlay, 8 | ModalContent, 9 | ModalHeader, 10 | ModalFooter, 11 | ModalBody, 12 | ModalCloseButton, 13 | Button, 14 | useToast, 15 | useDisclosure, 16 | HStack, 17 | Input, 18 | FormControl, 19 | FormLabel, 20 | FormErrorMessage, 21 | FormHelperText, 22 | } from '@chakra-ui/react'; 23 | 24 | import * as db from '@/lib/db'; 25 | import { useAuth } from '@/lib/auth'; 26 | 27 | import { useState } from 'react'; 28 | import { startsWithHttp, validUrl, reachUrl } from '@/utils/urltest'; 29 | 30 | const AddSiteModal = ({ children }) => { 31 | const toast = useToast(); 32 | const auth = useAuth(); 33 | const { isOpen, onOpen, onClose } = useDisclosure(); 34 | const { handleSubmit, register, errors, getValues } = useForm({ 35 | mode: 'onTouched', 36 | }); 37 | const [apiValidated, setApiValidated] = useState(false); 38 | 39 | const onCreateSite = async ({ name, url, apiKey, apiUrl }) => { 40 | const uid = auth.user.uid; 41 | const token = auth.user.token; 42 | const newSite = { 43 | authorId: uid, 44 | createdAt: new Date().toISOString(), 45 | name, 46 | url, 47 | apiKey, 48 | apiUrl, 49 | previewRatio: 0.4, 50 | maxLength: 10000, 51 | }; 52 | 53 | let siteId = null; 54 | try { 55 | ({ id: siteId } = await db.createSite(newSite)); 56 | 57 | mutate(['/api/auth/sites', token]); 58 | 59 | toast({ 60 | title: 'Success! 🎉', 61 | description: "We've added your site.", 62 | status: 'success', 63 | duration: 5000, 64 | isClosable: true, 65 | }); 66 | } catch (err) { 67 | toast({ 68 | title: 'Failed! 😢', 69 | description: `We were not able to add you site, due to ${err.name}: ${err.message}`, 70 | status: 'error', 71 | duration: 15000, 72 | isClosable: true, 73 | }); 74 | } 75 | 76 | try { 77 | const resp = await axios.get(`/api/auth/ghostwebhooks?siteId=${siteId}`, { 78 | headers: { Authorization: `Bearer ${token}` }, 79 | }); 80 | 81 | toast({ 82 | title: 'Success! 🎉', 83 | description: 84 | "We've added webhooks to sync your post updates with preview.", 85 | status: 'success', 86 | duration: 5000, 87 | isClosable: true, 88 | }); 89 | } catch (err) { 90 | toast({ 91 | title: 'Warning 😢', 92 | description: `We were not able to add webhooks. But your preview should still work, it's just slower.\n (${err.name}: ${err.message}.)`, 93 | status: 'warning', 94 | duration: 15000, 95 | isClosable: true, 96 | }); 97 | } 98 | }; 99 | 100 | const validateApiKey = async (apiKey) => { 101 | // console.log('validating apikey'); 102 | let apiUrl = getValues('apiUrl'); 103 | apiUrl = apiUrl.replace(/\/$/, ''); 104 | // console.log(apiUrl, apiKey); 105 | if (apiUrl && apiKey) { 106 | const res = await fetch( 107 | `/api/ghostApiTest?apiUrl=${apiUrl}&apiKey=${apiKey}`, 108 | { 109 | method: 'GET', 110 | } 111 | ); 112 | // console.log(res.status); 113 | if (res.status !== 200) { 114 | setApiValidated(false); 115 | return false; 116 | } 117 | 118 | setApiValidated(true); 119 | return true; 120 | } 121 | 122 | setApiValidated(false); 123 | return false; 124 | }; 125 | 126 | return ( 127 | <> 128 | 145 | 146 | 147 | { 150 | onCreateSite(formData); 151 | onClose(); 152 | })} 153 | > 154 | Add Site 155 | 156 | 157 | 158 | Name 159 | 165 | {errors.name && ( 166 | {'Name is required'} 167 | )} 168 | 169 | 170 | Website Url 171 | 184 | {errors.url?.type === 'required' && ( 185 | {'Website Url is required'} 186 | )} 187 | {errors.url?.type === 'startsWithHttp' && ( 188 | 189 | {'Remember to add https:// or http://'} 190 | 191 | )} 192 | {errors.url?.type === 'validUrl' && ( 193 | 194 | {'The provided is not a valid url'} 195 | 196 | )} 197 | {errors.url?.type === 'reachUrl' && ( 198 | 199 | {'The provide url is not reachable.'} 200 | 201 | )} 202 | 203 | 204 | Ghost API Url 205 | str.replace(/\/$/, ''), 217 | })} 218 | /> 219 | 220 | {errors.apiUrl?.type === 'required' && ( 221 | 222 | {'Ghost API Url is required'} 223 | 224 | )} 225 | {errors.apiUrl?.type === 'startsWithHttp' && ( 226 | 227 | {'Remember to add https:// or http://'} 228 | 229 | )} 230 | {errors.apiUrl?.type === 'validUrl' && ( 231 | 232 | {'The provided is not a valid url'} 233 | 234 | )} 235 | {errors.apiUrl?.type === 'reachUrl' && ( 236 | 237 | {'The provide url is not reachable.'} 238 | 239 | )} 240 | 241 | 242 | Ghost Admin API Key 243 | str.length === 89, 251 | format: (str) => { 252 | const parts = str.split(':'); 253 | if (parts.length !== 2) return false; 254 | if (parts[0].length !== 24 || parts[1].length !== 64) 255 | return false; 256 | return true; 257 | }, 258 | validateApiKey, 259 | }, 260 | })} 261 | /> 262 | {errors.apiKey?.type === 'required' && ( 263 | 264 | {'Ghost Admin API Key is required'} 265 | 266 | )} 267 | {errors.apiKey?.type === 'length89' && ( 268 | 269 | {'Ghost Admin API Key should have 89 characters'} 270 | 271 | )} 272 | {errors.apiKey?.type === 'format' && ( 273 | 274 | { 275 | 'Ghost Admin API Key should have have the following format {A}:{B}, where A is 24 hex characters and B is 64 hex characters' 276 | } 277 | 278 | )} 279 | {errors.apiKey?.type === 'validateApiKey' && ( 280 | 281 | { 282 | 'Ghost API test call failed, please double check API Key and Url and try again' 283 | } 284 | 285 | )} 286 | {!errors.apiKey && apiValidated ? ( 287 | 288 | Ghost Admin API key and url works! 🎉{' '} 289 | 290 | ) : null} 291 | 292 | 293 | 294 | 295 | 296 | 299 | 308 | 309 | 310 | 311 | 312 | 313 | ); 314 | }; 315 | 316 | export default AddSiteModal; 317 | -------------------------------------------------------------------------------- /components/BMAC.js: -------------------------------------------------------------------------------- 1 | const BMAC = () => { 2 | return ( 3 | <> 4 | 5 | Buy Me A Coffee 10 | 11 | 24 | 25 | ); 26 | }; 27 | 28 | export default BMAC; 29 | -------------------------------------------------------------------------------- /components/DeleteSiteModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import axios from 'axios'; 3 | import { mutate } from 'swr'; 4 | import { 5 | AlertDialog, 6 | AlertDialogBody, 7 | AlertDialogFooter, 8 | AlertDialogHeader, 9 | AlertDialogContent, 10 | AlertDialogOverlay, 11 | IconButton, 12 | Button, 13 | Link, 14 | useToast, 15 | Box, 16 | } from '@chakra-ui/react'; 17 | 18 | import { deleteSite } from '@/lib/db'; 19 | import { useAuth } from '@/lib/auth'; 20 | import { FaTrashAlt } from 'react-icons/fa'; 21 | 22 | const DeleteSiteModal = ({ siteId }) => { 23 | const [isOpen, setIsOpen] = useState(); 24 | const toast = useToast(); 25 | const cancelRef = useRef(); 26 | const auth = useAuth(); 27 | 28 | const onClose = () => setIsOpen(false); 29 | const onDelete = async () => { 30 | try { 31 | mutate( 32 | ['/api/auth/sites', auth.user.token], 33 | async (data) => { 34 | return { 35 | sites: data.sites.filter((site) => site.id !== siteId), 36 | }; 37 | }, 38 | false 39 | ); 40 | 41 | const resp = await deleteSite(siteId, auth.user.token); 42 | console.log(resp.data); 43 | 44 | toast({ 45 | title: 'Success! 🎉', 46 | description: "We've deleted your site.", 47 | status: 'success', 48 | duration: 5000, 49 | isClosable: true, 50 | }); 51 | } catch (err) { 52 | console.log(err); 53 | toast({ 54 | title: 'Failed! 😢', 55 | description: `We were not able to delete the site ${siteId}, due to ${err.name}: ${err.message}`, 56 | status: 'error', 57 | duration: 15000, 58 | isClosable: true, 59 | }); 60 | } finally { 61 | mutate(['/api/auth/sites', auth.user.token]); // trigger revalidation 62 | } 63 | 64 | onClose(); 65 | }; 66 | 67 | return ( 68 | <> 69 | 70 | setIsOpen(true)}> 71 | 72 | 73 | 74 | 79 | 80 | 81 | 82 | Delete Site 83 | 84 | 85 | Are you sure? This will deletel all info associated with the site. 86 | 87 | 88 | 91 | 99 | 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default DeleteSiteModal; 107 | -------------------------------------------------------------------------------- /components/EditSiteModal.js: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { mutate } from 'swr'; 3 | import axios from 'axios'; 4 | 5 | import { 6 | Box, 7 | Link, 8 | Modal, 9 | ModalOverlay, 10 | ModalContent, 11 | ModalHeader, 12 | ModalFooter, 13 | ModalBody, 14 | ModalCloseButton, 15 | Button, 16 | useToast, 17 | useDisclosure, 18 | HStack, 19 | Input, 20 | FormControl, 21 | FormLabel, 22 | FormErrorMessage, 23 | FormHelperText, 24 | } from '@chakra-ui/react'; 25 | 26 | import * as db from '@/lib/db'; 27 | import { useAuth } from '@/lib/auth'; 28 | 29 | import { useState, useEffect } from 'react'; 30 | import { startsWithHttp, validUrl, reachUrl } from '@/utils/urltest'; 31 | import { FaEdit } from 'react-icons/fa'; 32 | import useSWR from 'swr'; 33 | import fetcher from '@/utils/fetcher'; 34 | 35 | const EditSiteModal = ({ site: siteToEdit }) => { 36 | const toast = useToast(); 37 | const auth = useAuth(); 38 | const { isOpen, onOpen, onClose } = useDisclosure(); 39 | 40 | const { handleSubmit, register, errors, getValues, reset } = useForm({ 41 | mode: 'onTouched', 42 | defaultValues: { 43 | id: siteToEdit.id, 44 | name: siteToEdit.name, 45 | previewRatio: siteToEdit.previewRatio || 0.4, 46 | maxLength: siteToEdit.maxLength || 10000, 47 | }, 48 | }); 49 | 50 | // useEffect(() => { 51 | // db.getSite(siteToEdit.id).then((data) => { 52 | // reset(data); 53 | // console.log(data); 54 | // }); 55 | // }, [isOpen]); // calls on every Open of the modal 56 | 57 | const onUpdateSite = async ({ name, previewRatio, maxLength }) => { 58 | const token = auth.user.token; 59 | const newSiteData = { 60 | updatedAt: new Date().toISOString(), 61 | name, 62 | previewRatio: parseFloat(previewRatio), 63 | }; 64 | 65 | if (maxLength) { 66 | newSiteData.maxLength = parseInt(maxLength); 67 | } 68 | 69 | try { 70 | mutate( 71 | ['/api/auth/sites', token], 72 | async (data) => { 73 | const newSites = data.sites.map((site) => { 74 | if (site.id !== siteToEdit.id) return site; 75 | const updated = { ...site, ...newSiteData }; 76 | return updated; 77 | }); 78 | return { sites: newSites }; 79 | }, 80 | false 81 | ); 82 | 83 | await db.updateSite(siteToEdit.id, newSiteData); 84 | 85 | reset({ 86 | id: siteToEdit.id, // previously with reset(newsSiteData) we missed id field, it seems prevent submit button from working, probably due to silently failed validation 87 | name: newSiteData.name, 88 | previewRatio: newSiteData.previewRatio, 89 | maxLength: newSiteData.maxLength, 90 | }); 91 | console.log(newSiteData); 92 | 93 | toast({ 94 | title: 'Success! 🎉', 95 | description: "We've updated your site.", 96 | status: 'success', 97 | duration: 5000, 98 | isClosable: true, 99 | }); 100 | } catch (err) { 101 | toast({ 102 | title: 'Failed! 😢', 103 | description: `We were not able to update the site ${siteToEdit.id}, due to ${err.name}: ${err.message}`, 104 | status: 'error', 105 | duration: 15000, 106 | isClosable: true, 107 | }); 108 | } finally { 109 | mutate(['/api/auth/sites', token]); // trigger revalidation 110 | } 111 | }; 112 | 113 | return ( 114 | <> 115 | 116 | { 119 | onOpen(); 120 | }} 121 | > 122 | 123 | 124 | 125 | 126 | 127 | { 130 | console.log('handleSubmit called'); 131 | onUpdateSite(formData); 132 | onClose(); 133 | })} 134 | > 135 | Edit Site Settings 136 | 137 | 138 | 139 | Site Id 140 | 145 | 146 | 147 | Name 148 | 154 | {errors.name && ( 155 | {'Name is required'} 156 | )} 157 | 158 | 159 | 160 | Preview Ratio 161 | !isNaN(parseFloat(str)), 171 | }, 172 | })} 173 | /> 174 | 175 | {errors.previewRatio?.type === 'required' && ( 176 | {'Required'} 177 | )} 178 | 179 | {errors.previewRatio?.type === 'isNumber' && ( 180 | {'Should be a number'} 181 | )} 182 | 183 | {(errors.previewRatio?.type === 'min' || 184 | errors.previewRatio?.type === 'max') && ( 185 | 186 | {'Should be between 0.0 and 1.0'} 187 | 188 | )} 189 | 190 | 191 | 192 | Max Length (number of characters) 193 | !str || !isNaN(parseInt(str)), // empty ok 202 | }, 203 | })} 204 | /> 205 | {/* {errors.maxLength?.type === 'required' && ( 206 | {'Required'} 207 | )} */} 208 | 209 | {errors.maxLength?.type === 'isNumber' && ( 210 | {'Should be a number'} 211 | )} 212 | 213 | {errors.maxLength?.type === 'min' && ( 214 | 215 | { 216 | 'Should be greater than 500, this is the max number of characters to preview.' 217 | } 218 | 219 | )} 220 | 221 | 222 | 223 | 224 | 225 | 228 | 237 | 238 | 239 | 240 | 241 | 242 | ); 243 | }; 244 | 245 | export default EditSiteModal; 246 | -------------------------------------------------------------------------------- /components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import NextLink from 'next/link'; 3 | import { Link, Flex, HStack } from '@chakra-ui/react'; 4 | 5 | const FooterLink = ({ href, children }) => { 6 | return ( 7 | 8 | 9 | {children} 10 | 11 | 12 | ); 13 | }; 14 | 15 | const Footer = () => { 16 | return ( 17 | 18 | Privacy 19 | Terms 20 | FAQ 21 | Home 22 | 23 | ); 24 | }; 25 | 26 | export default Footer; 27 | -------------------------------------------------------------------------------- /components/Hero.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import NextLink from 'next/link'; 3 | import { 4 | Box, 5 | Button, 6 | Avatar, 7 | HStack, 8 | Flex, 9 | Link, 10 | Icon, 11 | SkeletonCircle, 12 | Heading, 13 | Text, 14 | } from '@chakra-ui/react'; 15 | import LoginButtons from '@/components/LoginButtons'; 16 | 17 | const Hero = () => { 18 | return ( 19 | 26 | 27 | 28 | 29 | Automatically add{' '} 30 | 31 | 37 | content preview 38 | 39 | 40 | {' '} 41 | for member-only posts on Ghost 42 | 43 | 44 | {/* Something */} 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default Hero; 54 | -------------------------------------------------------------------------------- /components/HiddenEmail.js: -------------------------------------------------------------------------------- 1 | export default function HiddenEmail() { 2 | const clickHander = (e) => { 3 | window.location.href = 4 | 'mailto:' + 5 | e.target.dataset.name + 6 | '@' + 7 | e.target.dataset.domain + 8 | '.' + 9 | e.target.dataset.tld; 10 | return false; 11 | }; 12 | return ( 13 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /components/LoginButtons.js: -------------------------------------------------------------------------------- 1 | import { Button, Flex } from '@chakra-ui/react'; 2 | import { useAuth } from '@/lib/auth'; 3 | import { FcGoogle } from 'react-icons/fc'; 4 | 5 | const LoginButtons = () => { 6 | const auth = useAuth(); 7 | 8 | return ( 9 | 10 | {/* */} 22 | 33 | 34 | ); 35 | }; 36 | 37 | export default LoginButtons; 38 | -------------------------------------------------------------------------------- /components/MDXComponents.js: -------------------------------------------------------------------------------- 1 | import NextLink from 'next/link'; 2 | import { Link } from '@chakra-ui/react'; 3 | import Image from 'next/image'; 4 | import { Box, Code, Heading } from '@chakra-ui/react'; 5 | 6 | const CustomLink = (props) => { 7 | const href = props.href; 8 | const isInternalLink = href && (href.startsWith('/') || href.startsWith('#')); 9 | 10 | if (isInternalLink) { 11 | return ( 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | return ; 19 | }; 20 | 21 | const MDXComponents = { 22 | Image, 23 | a: CustomLink, 24 | pre: (props) =>
, 25 | // code: CodeBlock, 26 | inlineCode: Code, 27 | h1: (props) => , 28 | h2: (props) => , 29 | h3: (props) => , 30 | p: (props) => , 31 | }; 32 | 33 | export default MDXComponents; 34 | -------------------------------------------------------------------------------- /components/NavBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import NextLink from 'next/link'; 3 | import Router from 'next/router'; 4 | import { Logo } from './icons'; 5 | import { 6 | Box, 7 | Button, 8 | Heading, 9 | Avatar, 10 | HStack, 11 | Flex, 12 | Link, 13 | Icon, 14 | SkeletonCircle, 15 | useMediaQuery, 16 | } from '@chakra-ui/react'; 17 | 18 | import { useAuth } from '@/lib/auth'; 19 | 20 | const WrappedLink = ({ href, children }) => { 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | }; 27 | 28 | const Navbar = (props) => { 29 | const { user, loading } = useAuth(); 30 | const innerWidth = props.innerWidth || '1040px'; 31 | const [largeScreen] = useMediaQuery('(min-width: 700px)'); 32 | 33 | return ( 34 | 43 | 52 | 53 | 54 | 55 | 56 | {largeScreen ? ( 57 | 58 | 59 | Ghost Preview 60 | 61 | 62 | ) : null} 63 | 64 | 65 | 66 | Set Up Instructions 67 | 68 | 69 | FAQ 70 | 71 | {user ? ( 72 | <> 73 | 80 | 81 | 82 | 83 | 84 | ) : null} 85 | 86 | 87 | 88 | ); 89 | }; 90 | 91 | export default Navbar; 92 | -------------------------------------------------------------------------------- /components/NextBreadcrumb.js: -------------------------------------------------------------------------------- 1 | import { Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@chakra-ui/react'; 2 | import NextLink from 'next/link'; 3 | 4 | const NextBreadcrumb = ({ pagePath, pageName }) => { 5 | return ( 6 | 7 | 8 | 9 | Home 10 | 11 | 12 | 13 | 14 | {pageName} 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default NextBreadcrumb; 22 | -------------------------------------------------------------------------------- /components/PageShell.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import NextLink from 'next/link'; 3 | import { Box, Button, Flex, Link, Avatar, Icon } from '@chakra-ui/react'; 4 | 5 | import Footer from './Footer'; 6 | import Navbar from './NavBar'; 7 | 8 | const inner = { 9 | m: '0 auto', 10 | maxW: '1040px', 11 | width: '100%', 12 | flexGrow: 1, 13 | }; 14 | 15 | const DashboardShell = ({ children }) => { 16 | return ( 17 | 18 | 19 | 20 | {children} 21 | 22 |