├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── components ├── charts │ └── bar-chart │ │ └── index.tsx ├── dialogs │ └── Confirm.tsx ├── examples │ └── analytics.tsx ├── files │ ├── FileDnd.tsx │ ├── GitHub.tsx │ └── Playground.tsx ├── icons │ ├── AngelList.tsx │ ├── Cal.tsx │ ├── Check.tsx │ ├── CheckFull.tsx │ ├── Discord.tsx │ ├── GitHub.tsx │ ├── Markprompt.tsx │ ├── Motif.tsx │ ├── Replo.tsx │ ├── Spinner.tsx │ ├── Sync.tsx │ └── Twitter.tsx ├── layouts │ ├── AppNavbar.tsx │ ├── LandingNavbar.tsx │ ├── NavLayout.tsx │ ├── NavSubtabsLayout.tsx │ ├── ProjectSettingsLayout.tsx │ ├── SubTabs.tsx │ └── TeamSettingsLayout.tsx ├── onboarding │ ├── AddFiles.tsx │ ├── Onboarding.tsx │ └── Query.tsx ├── pages │ ├── App.tsx │ └── Landing.tsx ├── team │ └── TeamProjectPicker.tsx ├── ui │ ├── Blurs.tsx │ ├── Button.tsx │ ├── Checkbox.tsx │ ├── Code.tsx │ ├── Flashing.tsx │ ├── Forms.tsx │ ├── Input.tsx │ ├── ListItem.tsx │ ├── LoadingDots.tsx │ ├── MDX.tsx │ ├── Pattern.tsx │ ├── Segment.tsx │ ├── SettingsCard.tsx │ ├── Slash.tsx │ ├── Tag.tsx │ ├── Toaster.tsx │ └── ToggleMessage.tsx └── user │ ├── AuthPage.tsx │ ├── ChatWindow.tsx │ └── ProfileMenu.tsx ├── config └── schema.sql ├── example.env ├── lib ├── api.ts ├── constants.ts ├── context │ ├── app.tsx │ └── training.tsx ├── generate-embeddings.ts ├── github.ts ├── hooks │ ├── use-domains.ts │ ├── use-files.ts │ ├── use-project.ts │ ├── use-projects.ts │ ├── use-team.ts │ ├── use-teams.ts │ ├── use-tokens.ts │ └── use-user.ts ├── middleware │ ├── app.ts │ ├── common.ts │ ├── completions.ts │ └── train.ts ├── openai.edge.ts ├── rate-limits.ts ├── redis.ts ├── stripe │ ├── client.ts │ ├── server.ts │ └── tiers.ts ├── supabase.ts ├── utils.node.ts └── utils.ts ├── middleware.ts ├── next.config.js ├── package.json ├── packages └── markprompt-react │ ├── .gitignore │ ├── README.md │ ├── build │ └── index.cjs │ ├── jest.config.cjs │ ├── jest.setup.js │ ├── package.json │ ├── scripts │ ├── build.sh │ ├── resolve-files.js │ └── rewrite-imports.js │ ├── src │ ├── Markprompt.tsx │ ├── index.ts │ └── types.ts │ ├── tsconfig.json │ ├── types │ └── jest.d.ts │ └── yarn.lock ├── pages ├── [team] │ ├── [project] │ │ ├── [analytics].tsx │ │ ├── component.tsx │ │ ├── data.tsx │ │ ├── index.tsx │ │ ├── playground.tsx │ │ ├── settings.tsx │ │ └── usage.tsx │ └── index.tsx ├── _app.tsx ├── _document.tsx ├── api │ ├── openai │ │ ├── completions │ │ │ └── [project].ts │ │ ├── train-file.ts │ │ └── train │ │ │ └── [project].ts │ ├── project │ │ └── [id] │ │ │ ├── checksums.ts │ │ │ ├── domains.ts │ │ │ ├── files.ts │ │ │ ├── index.ts │ │ │ └── tokens.ts │ ├── slug │ │ ├── generate-project-slug.ts │ │ ├── generate-team-slug.ts │ │ ├── is-project-slug-available.ts │ │ └── is-team-slug-available.ts │ ├── subscriptions │ │ ├── cancel.ts │ │ ├── create-checkout-session.ts │ │ └── webhook.ts │ ├── team │ │ └── [id] │ │ │ ├── index.ts │ │ │ └── projects.ts │ ├── teams │ │ └── index.ts │ └── user │ │ ├── index.ts │ │ ├── init.ts │ │ └── jwt.ts ├── home.tsx ├── index.tsx ├── legal │ ├── privacy.mdx │ └── terms.mdx ├── login.tsx ├── settings │ └── [team] │ │ ├── index.tsx │ │ ├── plans.tsx │ │ ├── projects │ │ └── new.tsx │ │ ├── team.tsx │ │ └── usage.tsx └── signup.tsx ├── postcss.config.js ├── prettier.config.js ├── public └── static │ ├── cover.png │ └── favicons │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon.ico │ ├── favicon.png │ ├── favicon.svg │ └── site.webmanifest ├── styles ├── Home.module.css ├── globals.css └── prism.css ├── tailwind.config.js ├── tsconfig.json ├── types ├── supabase.ts └── types.ts └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } -------------------------------------------------------------------------------- /.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 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env.* 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Motif 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Markprompt – Open-source GPT-4 platform for Markdown, Markdoc and MDX with built-in analytics 3 |

Markprompt

4 |
5 | 6 | Markprompt is a platform for building GPT-powered prompts. It scans Markdown, Markdoc and MDX files in your GitHub repo and creates embeddings that you can use to create a prompt, for instance using the companion [Markprompt React](https://github.com/motifland/markprompt/blob/main/packages/markprompt-react/README.md) component. Markprompt also offers analytics, so you can gain insights on how visitors interact with your docs. 7 | 8 |
9 | 10 |

11 | 12 | Twitter 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |

21 | 22 | ## Self-hosting 23 | 24 | Markprompt is built on top of the following stack: 25 | 26 | - [Next.js](https://nextjs.org/) - framework 27 | - [Vercel](https://vercel.com/) - hosting 28 | - [Typescript](https://www.typescriptlang.org/) - language 29 | - [Tailwind](https://tailwindcss.com/) - CSS 30 | - [Upstash](https://upstash.com/) - Redis and rate limiting 31 | - [Supabase](https://planetscale.com/) - database and auth 32 | - [Stripe](https://stripe.com/) - payments 33 | - [Plain](https://plain.com/) - support chat 34 | - [Fathom](https://plain.com/) - analytics 35 | 36 | ### Supabase 37 | 38 | Supabase is used for storage and auth, and if self-hosting, you will need to set it up on the [Supabase admin console](https://app.supabase.com/). 39 | 40 | #### Schema 41 | 42 | The schema is defined in [schema.sql](https://github.com/motifland/markprompt/blob/main/config/schema.sql). Create a Supabase database and paste the content of this file into the SQL editor. Then run the Typescript types generation script using: 43 | 44 | ```sh 45 | npx supabase gen types typescript --project-id --schema public > types/supabase.ts 46 | ``` 47 | 48 | where `` is the id of your Supabase project. 49 | 50 | #### Auth provider 51 | 52 | Authentication is handled by Supabase Auth. Follow the [Login with GitHub](https://supabase.com/docs/guides/auth/social-login/auth-github) and [Login with Google](https://supabase.com/docs/guides/auth/social-login/auth-google) guides to set it up. 53 | 54 | ### Setting environment variables 55 | 56 | A sample file containing required environment variables can be found in [example.env](https://github.com/motifland/markprompt/blob/main/example.env). In addition to the keys for the above services, you will need keys for [Upstash](https://upstash.com/) (rate limiting and key-value storage), [Plain.com](https://plain.com) (support chat), and [Fathom](https://usefathom.com/) (analytics). 57 | 58 | ## Using the React component 59 | 60 | Markprompt React is a headless React component for building a prompt interface, based on the Markprompt API. With a single line of code, you can provide a prompt interface to your React application. Follow the steps in the [Markprompt React README](https://github.com/motifland/markprompt/blob/main/packages/markprompt-react/README.md) to get started using it. 61 | 62 | ## Usage 63 | 64 | Currently, the Markprompt API has basic protection against misuse when making requests from public websites, such as rate limiting, IP blacklisting, allowed origins, and prompt moderation. These are not strong guarantees against misuse though, and it is always safer to expose an API like Markprompt's to authenticated users, and/or in non-public systems using private access tokens. We do plan to offer more extensive tooling on that front (hard limits, spike protection, notifications, query analysis, flagging). 65 | 66 | ## Community 67 | 68 | - [Twitter @markprompt](https://twitter.com/markprompt) 69 | - [Twitter @motifland](https://twitter.com/motifland) 70 | - [Discord](https://discord.gg/MBMh4apz6X) 71 | 72 | ## Authors 73 | 74 | Created by the team behind [Motif](https://motif.land) 75 | ([@motifland](https://twitter.com/motifland)). 76 | 77 | ## License 78 | 79 | MIT 80 | -------------------------------------------------------------------------------- /components/dialogs/Confirm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react'; 2 | import * as Dialog from '@radix-ui/react-dialog'; 3 | import Button, { ButtonVariant } from '../ui/Button'; 4 | // import { AppState } from '@/types/types'; 5 | import { CTABar } from '../ui/SettingsCard'; 6 | 7 | type ConfirmDialogProps = { 8 | cta: string; 9 | title: string; 10 | description?: string; 11 | variant?: ButtonVariant; 12 | loading?: boolean; 13 | onCTAClick: () => Promise; 14 | }; 15 | 16 | const ConfirmDialog: FC = ({ 17 | cta, 18 | title, 19 | description, 20 | variant, 21 | loading, 22 | onCTAClick, 23 | }) => { 24 | return ( 25 | 26 | 27 | 28 | {title} 29 | 30 | {description} 31 | 32 | 33 | 34 | 37 | 38 | 46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | export default ConfirmDialog; 53 | -------------------------------------------------------------------------------- /components/examples/analytics.tsx: -------------------------------------------------------------------------------- 1 | import BarChart from '@/components/charts/bar-chart'; 2 | import { sampleVisitsData } from '@/lib/utils'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | export const AnalyticsExample = () => { 6 | const [isMounted, setIsMounted] = useState(false); 7 | 8 | useEffect(() => { 9 | // Prevent SSR/hydration errors. 10 | setIsMounted(true); 11 | }, []); 12 | 13 | if (!isMounted) { 14 | return <>; 15 | } 16 | 17 | return ( 18 | <> 19 |
20 |

Daily queries

21 | 28 |
29 |
30 |
31 |

Most asked

32 |
33 |
34 |

223

35 |

What is an RUV?

36 |
37 |
38 |

185

39 |

When do I need to submit by 83b?

40 |
41 |
42 |

159

43 |

What are preferred rights?

44 |
45 |
46 |

152

47 |

What is the difference between a SAFE and an RUV?

48 |
49 |
50 |

152

51 |

What is the difference between a SAFE and an RUV?

52 |
53 |
54 |
55 |
56 |

57 | Most visited resources 58 |

59 |
60 |
61 |

99

62 |

Roll-ups

63 |
64 |
65 |

87

66 |

Manage your Raise

67 |
68 |
69 |

152

70 |

Cap Tables

71 |
72 |
73 |

151

74 |

409A Valuations

75 |
76 |
77 |

133

78 |

Data Rooms

79 |
80 |
81 |
82 | 83 |
84 |

Reported prompts

85 |
86 |
87 |

9

88 |

How do I send an update?

89 |
90 |
91 |

8

92 |

What is a SAFE?

93 |
94 |
95 |

5

96 |

Where can I see my cap table?

97 |
98 |
99 |
100 |
101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /components/icons/AngelList.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type AngeListIconProps = { 4 | className?: string 5 | } 6 | 7 | export const AngeListIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/icons/Cal.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type CalIconProps = { 4 | className?: string 5 | } 6 | 7 | export const CalIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /components/icons/Check.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type CheckIconProps = { 4 | className?: string 5 | } 6 | 7 | export const CheckIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/icons/CheckFull.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type CheckFullIconProps = { 4 | className?: string 5 | } 6 | 7 | export const CheckFullIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/icons/Discord.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | 3 | type DiscordIconProps = { 4 | className?: string; 5 | }; 6 | 7 | export const DiscordIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 11 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /components/icons/GitHub.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type GitHubIconProps = { 4 | className?: string 5 | } 6 | 7 | export const GitHubIcon: FC = ({ className }) => { 8 | return ( 9 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /components/icons/Markprompt.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type MarkpromptIconProps = { 4 | className?: string 5 | } 6 | 7 | export const MarkpromptIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 22 | 26 | 27 | ) 28 | } 29 | 30 | /* 31 | 32 | 33 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | } 47 | */ 48 | -------------------------------------------------------------------------------- /components/icons/Motif.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type MotifIconProps = { 4 | className?: string 5 | } 6 | 7 | export const MotifIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/icons/Replo.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type ReploIconProps = { 4 | className?: string 5 | } 6 | 7 | export const ReploIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/icons/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | 3 | type SpinnerIconProps = { 4 | className?: string; 5 | }; 6 | 7 | export const SpinnerIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 18 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /components/icons/Sync.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type SyncIconProps = { 4 | className?: string 5 | } 6 | 7 | export const SyncIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/icons/Twitter.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type TwitterIconProps = { 4 | className?: string 5 | } 6 | 7 | export const TwitterIcon: FC = ({ className }) => { 8 | return ( 9 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /components/layouts/AppNavbar.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import Link from 'next/link'; 3 | import { FC } from 'react'; 4 | import { MarkpromptIcon } from '../icons/Markprompt'; 5 | import ProfileMenu from '../user/ProfileMenu'; 6 | import TeamProjectPicker from '../team/TeamProjectPicker'; 7 | 8 | type AppNavbarProps = { 9 | animated?: boolean; 10 | }; 11 | 12 | export const AppNavbar: FC = ({ animated }) => { 13 | return ( 14 |
20 |
21 | 22 | 23 | 24 |
25 | 26 |
27 |
28 | 29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /components/layouts/LandingNavbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { useSession } from '@supabase/auth-helpers-react'; 3 | import { MotifIcon } from '../icons/Motif'; 4 | import { Slash } from '../ui/Slash'; 5 | import { GitHubIcon } from '../icons/GitHub'; 6 | import { DiscordIcon } from '../icons/Discord'; 7 | 8 | export default function LandingNavbar() { 9 | const session = useSession(); 10 | 11 | return ( 12 |
13 |
14 | 15 | 16 | {' '} 17 | 18 | 22 | Markprompt 23 | 24 |
25 |
26 | 30 | Pricing 31 | 32 | {session ? ( 33 | 37 | Go to app 38 | 39 | ) : ( 40 | <> 41 | 45 | Sign up 46 | 47 | 51 | Sign in 52 | 53 | 54 | )} 55 | 59 | 60 | 61 | 65 | 66 | 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /components/layouts/NavLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react'; 2 | import { AppNavbar } from '@/components/layouts/AppNavbar'; 3 | 4 | type NavLayoutProps = { 5 | animated?: boolean; 6 | children?: ReactNode; 7 | }; 8 | 9 | export const NavLayout: FC = ({ animated, children }) => { 10 | return ( 11 |
12 | 13 |
{children}
14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /components/layouts/NavSubtabsLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FC, JSXElementConstructor, ReactNode } from 'react'; 2 | import { NavLayout } from './NavLayout'; 3 | import SubTabs, { SubTabItem } from './SubTabs'; 4 | import Head from 'next/head'; 5 | import cn from 'classnames'; 6 | import useUser from '@/lib/hooks/use-user'; 7 | 8 | export type NavSubtabsLayoutProps = { 9 | title: string; 10 | titleComponent?: ReactNode; 11 | noHeading?: boolean; 12 | width?: 'xs' | 'sm' | 'md' | 'lg'; 13 | subTabItems?: SubTabItem[]; 14 | SubHeading?: JSXElementConstructor; 15 | RightHeading?: JSXElementConstructor; 16 | children?: ReactNode; 17 | }; 18 | 19 | export const NavSubtabsLayout: FC = ({ 20 | title, 21 | titleComponent, 22 | noHeading, 23 | width: w, 24 | subTabItems, 25 | SubHeading, 26 | RightHeading, 27 | children, 28 | }) => { 29 | const { user, loading: loadingUser } = useUser(); 30 | const width = !w ? 'lg' : w; 31 | 32 | return ( 33 | <> 34 | 35 | {`${title} | Markprompt`} 36 | 37 | 38 | {!!user?.has_completed_onboarding && !loadingUser && ( 39 | <> 40 |
41 | {subTabItems && } 42 |
43 | 44 |
54 | {!noHeading && ( 55 |
56 |
57 |

58 | {titleComponent ?? title} 59 |

60 | {RightHeading && ( 61 | <> 62 |
63 |
64 | 65 |
66 | 67 | )} 68 |
69 | {SubHeading && } 70 |
71 | )} 72 | {children} 73 |
74 | 75 | )} 76 | {!loadingUser && !user?.has_completed_onboarding && ( 77 |
78 |
79 | Loading... 80 |
81 |
82 | )} 83 | 84 | 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /components/layouts/ProjectSettingsLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useMemo } from 'react'; 2 | import useTeam from '@/lib/hooks/use-team'; 3 | import useProject from '@/lib/hooks/use-project'; 4 | import { NavSubtabsLayout, NavSubtabsLayoutProps } from './NavSubtabsLayout'; 5 | 6 | export const ProjectSettingsLayout: FC = (props) => { 7 | const { team } = useTeam(); 8 | const { project } = useProject(); 9 | 10 | const subTabItems = useMemo(() => { 11 | if (!team?.slug || !project?.slug) { 12 | return undefined; 13 | } 14 | const basePath = `/${team?.slug}/${project?.slug}`; 15 | return [ 16 | { label: 'Home', href: basePath }, 17 | { label: 'Data', href: `${basePath}/data` }, 18 | // { label: 'Analytics', href: `${basePath}/analytics` }, 19 | { label: 'Playground', href: `${basePath}/playground` }, 20 | { label: 'Component', href: `${basePath}/component` }, 21 | { label: 'Settings', href: `${basePath}/settings` }, 22 | ]; 23 | }, [team?.slug, project?.slug]); 24 | 25 | return ; 26 | }; 27 | -------------------------------------------------------------------------------- /components/layouts/SubTabs.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import cn from 'classnames'; 3 | import * as NavigationMenu from '@radix-ui/react-navigation-menu'; 4 | import Link from 'next/link'; 5 | import { useRouter } from 'next/router'; 6 | 7 | export type SubTabItem = { label: string; href: string }; 8 | 9 | type SubTabsProps = { 10 | items: SubTabItem[]; 11 | }; 12 | 13 | const SubTabs: FC = ({ items }) => { 14 | const { asPath } = useRouter(); 15 | 16 | return ( 17 | 18 | 19 | {items.map((item, i) => ( 20 | 21 | 32 | {item.label} 33 | 34 | 35 | ))} 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default SubTabs; 42 | -------------------------------------------------------------------------------- /components/layouts/TeamSettingsLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useMemo } from 'react'; 2 | import useTeam from '@/lib/hooks/use-team'; 3 | import { NavSubtabsLayout, NavSubtabsLayoutProps } from './NavSubtabsLayout'; 4 | 5 | export const TeamSettingsLayout: FC = (props) => { 6 | const { team } = useTeam(); 7 | 8 | const subTabItems = useMemo(() => { 9 | if (!team?.slug) { 10 | return undefined; 11 | } 12 | const basePath = `/${team?.slug || ''}`; 13 | return [ 14 | { label: 'Home', href: basePath }, 15 | ...(!team?.is_personal 16 | ? [{ label: 'Team', href: `/settings${basePath}/team` }] 17 | : []), 18 | { label: 'Usage', href: `/settings${basePath}/usage` }, 19 | { label: 'Plans', href: `/settings${basePath}/plans` }, 20 | { label: 'Settings', href: `/settings${basePath}` }, 21 | ]; 22 | }, [team?.slug, team?.is_personal]); 23 | 24 | return ; 25 | }; 26 | -------------------------------------------------------------------------------- /components/onboarding/AddFiles.tsx: -------------------------------------------------------------------------------- 1 | import { FileDnd } from '@/components/files/FileDnd'; 2 | import { Code } from '@/components/ui/Code'; 3 | import { ClipboardIcon } from '@radix-ui/react-icons'; 4 | import { copyToClipboard, getHost, getOrigin, pluralize } from '@/lib/utils'; 5 | import { toast } from 'react-hot-toast'; 6 | import { FC, ReactNode } from 'react'; 7 | import cn from 'classnames'; 8 | import useFiles from '@/lib/hooks/use-files'; 9 | import useTokens from '@/lib/hooks/use-tokens'; 10 | import { GitHub } from '../files/GitHub'; 11 | 12 | type TagProps = { 13 | children: ReactNode; 14 | className?: string; 15 | variant?: 'fuchsia' | 'sky'; 16 | }; 17 | 18 | const Tag: FC = ({ className, variant, children }) => { 19 | return ( 20 | 30 | {children} 31 | 32 | ); 33 | }; 34 | 35 | type AddFilesProps = { 36 | onTrainingComplete: () => void; 37 | onNext: () => void; 38 | }; 39 | 40 | const AddFiles: FC = ({ onTrainingComplete, onNext }) => { 41 | const { files } = useFiles(); 42 | const { tokens } = useTokens(); 43 | 44 | const curlCode = ` 45 | curl -d @docs.zip \\ 46 | https://api.${getHost()}/generate-embeddings \\ 47 | -H "Authorization: Bearer ${tokens?.[0]?.value || ''}" 48 | `.trim(); 49 | 50 | return ( 51 |
52 |
53 |

Step 1: Import files

54 |

55 | Accepted: .md 56 | 57 | .mdoc 58 | 59 | 60 | .mdx 61 | 62 | 63 | .txt 64 | 65 |

66 |
67 |
68 | 69 |
70 |

or

71 |
72 | 73 |
74 |

or

75 |
76 |
{ 79 | copyToClipboard(curlCode); 80 | toast.success('Copied!'); 81 | }} 82 | > 83 | 84 |
85 |
86 | 91 |
92 |
93 |

0, 99 | }, 100 | )} 101 | onClick={() => onNext()} 102 | > 103 | {pluralize(files?.length || 0, 'file', 'files')} ready. Go to 104 | playground → 105 |

106 |
107 |
108 |
109 | ); 110 | }; 111 | 112 | export default AddFiles; 113 | -------------------------------------------------------------------------------- /components/onboarding/Onboarding.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { NavLayout } from '@/components/layouts/NavLayout'; 3 | import { useCallback, useState } from 'react'; 4 | import cn from 'classnames'; 5 | import AddFiles from './AddFiles'; 6 | import Query from './Query'; 7 | import Button from '../ui/Button'; 8 | import { toast } from 'react-hot-toast'; 9 | import useUser from '@/lib/hooks/use-user'; 10 | import { updateUser } from '@/lib/api'; 11 | import { showConfetti } from '@/lib/utils'; 12 | import Router from 'next/router'; 13 | import useTeam from '@/lib/hooks/use-team'; 14 | import useProject from '@/lib/hooks/use-project'; 15 | 16 | const Onboarding = () => { 17 | const { team } = useTeam(); 18 | const { project } = useProject(); 19 | const { user, mutate: mutateUser } = useUser(); 20 | const [step, setStep] = useState(0); 21 | const [ctaVisible, setCtaVisible] = useState(false); 22 | 23 | if (!user) { 24 | return <>; 25 | } 26 | 27 | return ( 28 | <> 29 | 30 | Get started | Markprompt 31 | 32 | 33 |
34 |
39 | { 41 | toast.success('Processing complete'); 42 | setTimeout(() => { 43 | setStep(1); 44 | }, 1000); 45 | }} 46 | onNext={() => { 47 | setStep(1); 48 | }} 49 | /> 50 |
51 |
59 | { 61 | setStep(0); 62 | }} 63 | didCompleteFirstQuery={async () => { 64 | setTimeout(() => { 65 | showConfetti(); 66 | }, 1000); 67 | setTimeout(() => { 68 | setCtaVisible(true); 69 | }, 2000); 70 | }} 71 | isReady={step === 1} 72 | /> 73 |
74 | 94 |
95 |
96 |
97 |
98 | 99 | ); 100 | }; 101 | 102 | export default Onboarding; 103 | -------------------------------------------------------------------------------- /components/onboarding/Query.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode, useState } from 'react'; 2 | import cn from 'classnames'; 3 | import { Code } from '../ui/Code'; 4 | import { ClipboardIcon } from '@radix-ui/react-icons'; 5 | import { copyToClipboard, pluralize } from '@/lib/utils'; 6 | import { toast } from 'react-hot-toast'; 7 | import { Playground } from '../files/Playground'; 8 | import useFiles from '@/lib/hooks/use-files'; 9 | import useTeam from '@/lib/hooks/use-team'; 10 | import useProject from '@/lib/hooks/use-project'; 11 | 12 | const npmCode = ` 13 | npm install markprompt 14 | `.trim(); 15 | 16 | const reactCode = (teamSlug: string, projectSlug: string) => 17 | ` 18 | // Use on whitelisted domain 19 | import { Markprompt } from "markprompt" 20 | 21 | 23 | `.trim(); 24 | 25 | type QueryProps = { 26 | goBack: () => void; 27 | didCompleteFirstQuery: () => void; 28 | isReady?: boolean; 29 | }; 30 | 31 | const Query: FC = ({ goBack, didCompleteFirstQuery, isReady }) => { 32 | const { team } = useTeam(); 33 | const { project } = useProject(); 34 | const { files } = useFiles(); 35 | const [showCode, setShowCode] = useState(false); 36 | 37 | if (!team || !project) { 38 | return <>; 39 | } 40 | 41 | return ( 42 |
43 |
44 |

Step 2: Query docs

45 |

46 | Trained on {pluralize(files?.length || 0, 'file', 'files')}.{' '} 47 | 51 | Add more files 52 | 53 |

54 |
55 |
60 |
61 |
68 | 75 |
76 |
77 |
78 | 79 | $ 80 | 81 | 82 |
83 |
{ 86 | copyToClipboard(npmCode); 87 | toast.success('Copied!'); 88 | }} 89 | > 90 | 91 |
92 |
93 |
94 |
95 |
96 | 100 |
101 |
{ 104 | copyToClipboard(reactCode(team.slug, project.slug)); 105 | toast.success('Copied!'); 106 | }} 107 | > 108 | 109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |

setShowCode(false)} 122 | > 123 | Playground 124 |

125 |

setShowCode(true)} 131 | > 132 | Code 133 |

134 |
135 |
136 |
137 |
138 | ); 139 | }; 140 | 141 | export default Query; 142 | -------------------------------------------------------------------------------- /components/pages/App.tsx: -------------------------------------------------------------------------------- 1 | import { TeamSettingsLayout } from '../layouts/TeamSettingsLayout'; 2 | 3 | const AppPage = () => { 4 | return ; 5 | }; 6 | 7 | export default AppPage; 8 | -------------------------------------------------------------------------------- /components/ui/Blurs.tsx: -------------------------------------------------------------------------------- 1 | export const Blurs = () => ( 2 |
3 |
4 |
5 |
6 |
7 | ); 8 | -------------------------------------------------------------------------------- /components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import Link from 'next/link'; 3 | import { forwardRef, JSXElementConstructor, ReactNode, useRef } from 'react'; 4 | import LoadingDots from './LoadingDots'; 5 | 6 | export type ButtonVariant = 7 | | 'cta' 8 | | 'glow' 9 | | 'danger' 10 | | 'ghost' 11 | | 'plain' 12 | | 'fuchsia'; 13 | 14 | type ButtonProps = { 15 | buttonSize?: 'sm' | 'base' | 'md' | 'lg'; 16 | variant?: ButtonVariant; 17 | href?: string; 18 | Icon?: JSXElementConstructor | string; 19 | children?: ReactNode; 20 | target?: string; 21 | rel?: string; 22 | className?: string; 23 | asLink?: boolean; 24 | disabled?: boolean; 25 | loading?: boolean; 26 | loadingMessage?: string; 27 | Component?: JSXElementConstructor | string; 28 | } & React.HTMLProps; 29 | 30 | const Button = forwardRef( 31 | ( 32 | { 33 | buttonSize, 34 | variant, 35 | href, 36 | children, 37 | Icon, 38 | className, 39 | asLink, 40 | disabled, 41 | loading, 42 | loadingMessage, 43 | Component = 'button', 44 | ...props 45 | }, 46 | ref, 47 | ) => { 48 | const Comp: any = asLink ? Link : href ? 'a' : Component; 49 | 50 | let size = buttonSize ?? 'base'; 51 | return ( 52 | 78 | {Icon && } 79 | 85 | {loadingMessage} 86 | 87 | {loading && !loadingMessage && ( 88 | 89 | 95 | 96 | )} 97 |
103 | {children} 104 |
105 |
106 | ); 107 | }, 108 | ); 109 | 110 | Button.displayName = 'Button'; 111 | 112 | export default Button; 113 | -------------------------------------------------------------------------------- /components/ui/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { FC, HTMLProps, useEffect, useRef } from 'react'; 2 | 3 | type CheckboxProps = { indeterminate?: boolean } & HTMLProps; 4 | 5 | export const Checkbox: FC = ({ 6 | indeterminate, 7 | className = '', 8 | ...rest 9 | }: { indeterminate?: boolean } & HTMLProps) => { 10 | const ref = useRef(null!); 11 | 12 | useEffect(() => { 13 | if (typeof indeterminate === 'boolean') { 14 | ref.current.indeterminate = !rest.checked && indeterminate; 15 | } 16 | }, [ref, indeterminate, rest.checked]); 17 | 18 | return ( 19 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /components/ui/Code.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import Highlight, { defaultProps, Language } from 'prism-react-renderer'; 3 | 4 | type CodeProps = { 5 | code: string; 6 | language: Language; 7 | className?: string; 8 | }; 9 | 10 | export const Code: FC = ({ code, language, className }) => { 11 | return ( 12 |
13 | 14 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 15 |
16 |             {tokens.map((line, i) => (
17 |               
18 | {line.map((token, key) => ( 19 | 23 | ))} 24 |
25 | ))} 26 |
27 | )} 28 |
29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /components/ui/Flashing.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { 3 | Children, 4 | cloneElement, 5 | FC, 6 | isValidElement, 7 | ReactNode, 8 | useState, 9 | } from 'react'; 10 | 11 | type FlashingProps = { 12 | active: number; 13 | children: ReactNode; 14 | }; 15 | 16 | export const Flashing: FC = ({ active, children }) => { 17 | return ( 18 |
19 | {Children.map(children, (child, i) => { 20 | if (isValidElement(child)) { 21 | return cloneElement(child, { 22 | ...child.props, 23 | className: cn( 24 | child.props.className, 25 | 'transition duration-500 transform-all', 26 | { 27 | // Let the first child dictate the size. Subsequent children 28 | // get absolute positioning 29 | 'absolute inset-x': i > 0, 30 | 'opacity-100 delay-300': i === active, 31 | 'opacity-0': i !== active, 32 | }, 33 | ), 34 | }); 35 | } 36 | return child; 37 | })} 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /components/ui/Forms.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react'; 2 | 3 | type ErrorLabelProps = { 4 | children?: ReactNode; 5 | }; 6 | 7 | export const ErrorLabel: FC = ({ children }) => { 8 | return
{children}
; 9 | }; 10 | -------------------------------------------------------------------------------- /components/ui/Input.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { FC, ReactNode } from 'react'; 3 | 4 | type InputProps = { 5 | inputSize?: 'sm' | 'base' | 'md' | 'lg'; 6 | variant?: 'plain' | 'glow'; 7 | children?: ReactNode; 8 | className?: string; 9 | } & any; 10 | 11 | export const NoAutoInput = (props: any) => { 12 | return ( 13 | 20 | ); 21 | }; 22 | 23 | const Input: FC = ({ 24 | inputSize: s, 25 | variant, 26 | children, 27 | className, 28 | ...props 29 | }) => { 30 | let inputSize = s ?? 'base'; 31 | return ( 32 | 40 | ); 41 | }; 42 | 43 | export default Input; 44 | -------------------------------------------------------------------------------- /components/ui/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { CheckIcon } from '@/components/icons/Check'; 3 | import cn from 'classnames'; 4 | 5 | export const ListItem = ({ 6 | size = 'base', 7 | variant, 8 | children, 9 | }: { 10 | size?: 'sm' | 'base'; 11 | variant?: 'discreet'; 12 | children: ReactNode; 13 | }) => { 14 | return ( 15 |
  • 21 | {variant === 'discreet' && ( 22 | 23 | )} 24 | {variant !== 'discreet' && ( 25 |
    26 | 27 |
    28 | )} 29 |

    34 | {children} 35 |

    36 |
  • 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /components/ui/LoadingDots.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | type LoadingDotsProps = { 4 | className?: string; 5 | }; 6 | 7 | const LoadingDots: FC = ({ className }) => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default LoadingDots; 18 | -------------------------------------------------------------------------------- /components/ui/MDX.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react' 2 | 3 | type MDXComponentProps = { 4 | children: ReactNode 5 | } 6 | 7 | export const MDXComponent: FC = ({ children }) => { 8 | return ( 9 |
    10 | {children} 11 |
    12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/ui/Pattern.tsx: -------------------------------------------------------------------------------- 1 | export const Pattern = () => ( 2 |
    3 |
    4 | 27 |
    28 | 35 |
    36 | ) 37 | -------------------------------------------------------------------------------- /components/ui/Segment.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { FC, ReactNode, useEffect, useRef, useState } from 'react'; 3 | 4 | type SegmentProps = { 5 | id: string; 6 | selected: number; 7 | items: (string | ReactNode)[]; 8 | variant?: 'text' | 'toggle'; 9 | size?: 'xs' | 'sm' | 'base'; 10 | onChange: (selected: number) => void; 11 | }; 12 | 13 | export const Segment: FC = ({ 14 | id, 15 | selected, 16 | items, 17 | variant = 'toggle', 18 | size = 'base', 19 | onChange, 20 | }) => { 21 | const [pos, setPos] = useState({ left: 0, width: 0 }); 22 | const [hasInteractedOnce, setHasInteractedOnce] = useState(false); 23 | const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); 24 | 25 | useEffect(() => { 26 | function setTabPosition() { 27 | const currentTab = buttonRefs.current[selected]; 28 | setPos({ 29 | left: currentTab?.offsetLeft ?? 0, 30 | width: currentTab?.clientWidth ?? 0, 31 | }); 32 | } 33 | 34 | setTabPosition(); 35 | 36 | window.addEventListener('resize', setTabPosition); 37 | 38 | return () => window.removeEventListener('resize', setTabPosition); 39 | }, [selected]); 40 | 41 | return ( 42 |
    48 | {items.map((item, i) => { 49 | return ( 50 | 75 | ); 76 | })} 77 | {variant === 'toggle' && ( 78 |
    90 | )} 91 |
    92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /components/ui/SettingsCard.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren, ReactNode } from 'react'; 2 | 3 | export const CTABar: FC = ({ children }) => { 4 | return ( 5 |
    6 | {children} 7 |
    8 | ); 9 | }; 10 | 11 | export const DescriptionLabel: FC = ({ children }) => { 12 | return
    {children}
    ; 13 | }; 14 | 15 | type SettingsCardProps = { 16 | title: string | ReactNode; 17 | description?: string; 18 | children?: ReactNode; 19 | }; 20 | 21 | export const SettingsCard: FC = ({ 22 | title, 23 | description, 24 | children, 25 | }) => { 26 | return ( 27 |
    28 |
    29 |

    {title}

    30 | {description && ( 31 |

    {description}

    32 | )} 33 |
    34 | {children} 35 |
    36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /components/ui/Slash.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { FC } from 'react'; 3 | 4 | type SlashProps = { 5 | size?: 'sm' | 'md' | 'lg'; 6 | className?: string; 7 | }; 8 | 9 | export const Slash: FC = ({ size, className }) => { 10 | return ( 11 |
    22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /components/ui/Tag.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { FC, ReactNode } from 'react'; 3 | 4 | type TagProps = { 5 | className?: string; 6 | color?: 'fuchsia' | 'orange' | 'sky'; 7 | size?: 'sm' | 'base'; 8 | children: ReactNode; 9 | }; 10 | 11 | export const Tag: FC = ({ 12 | className, 13 | color = 'fuchsia', 14 | size = 'sm', 15 | children, 16 | }) => { 17 | return ( 18 | 30 | {children} 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /components/ui/Toaster.tsx: -------------------------------------------------------------------------------- 1 | import { ToastBar, Toaster as ReactHotToaster } from 'react-hot-toast'; 2 | 3 | export const Toaster = () => ( 4 | 12 | {(t) => ( 13 | 22 | )} 23 | 24 | ); 25 | -------------------------------------------------------------------------------- /components/ui/ToggleMessage.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { FC, ReactNode } from 'react'; 3 | 4 | type ToggleMessageProps = { 5 | message1: ReactNode; 6 | message2: ReactNode; 7 | showMessage1: boolean; 8 | }; 9 | 10 | export const ToggleMessage: FC = ({ 11 | message1, 12 | message2, 13 | showMessage1, 14 | }) => { 15 | return ( 16 |
    17 |

    26 | {message1} 27 |

    28 |

    37 | {message2} 38 |

    39 |
    40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /components/user/AuthPage.tsx: -------------------------------------------------------------------------------- 1 | import { Auth } from '@supabase/auth-ui-react'; 2 | import { ThemeMinimal } from '@supabase/auth-ui-shared'; 3 | import { useSession, useSupabaseClient } from '@supabase/auth-helpers-react'; 4 | import { MarkpromptIcon } from '@/components/icons/Markprompt'; 5 | import Button from '@/components/ui/Button'; 6 | import { FC } from 'react'; 7 | import Link from 'next/link'; 8 | import { getOrigin } from '@/lib/utils'; 9 | import useUser from '@/lib/hooks/use-user'; 10 | 11 | type AuthPageProps = { 12 | type: 'signin' | 'signup'; 13 | }; 14 | 15 | const AuthPage: FC = ({ type }) => { 16 | const session = useSession(); 17 | const supabase = useSupabaseClient(); 18 | const { signOut } = useUser(); 19 | 20 | return ( 21 | <> 22 |
    23 |
    24 | 25 | 26 | 27 |
    28 | {!session ? ( 29 |
    30 | 51 |
    52 | ) : ( 53 |
    54 |

    You are already signed in.

    55 | 58 | 61 |
    62 | )} 63 |
    64 | 65 | ); 66 | }; 67 | 68 | export default AuthPage; 69 | -------------------------------------------------------------------------------- /components/user/ChatWindow.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react'; 2 | import { Chat } from '@team-plain/react-chat-ui'; 3 | import { ChatBubbleIcon, Cross2Icon } from '@radix-ui/react-icons'; 4 | import * as Popover from '@radix-ui/react-popover'; 5 | import cn from 'classnames'; 6 | import colors from 'tailwindcss/colors'; 7 | import { useSession } from '@supabase/auth-helpers-react'; 8 | 9 | type ChatWindowProps = {}; 10 | 11 | export const plainTheme = { 12 | input: { 13 | borderColor: colors.neutral['200'], 14 | borderColorFocused: colors.neutral['500'], 15 | borderColorError: colors.rose['500'], 16 | borderColorDisabled: colors.neutral['100'], 17 | focusBoxShadow: '', 18 | textColorPlaceholder: colors.neutral['400'], 19 | }, 20 | buttonPrimary: { 21 | background: colors.neutral['900'], 22 | backgroundHover: colors.neutral['800'], 23 | backgroundDisabled: colors.neutral['200'], 24 | textColor: colors.white, 25 | textColorDisabled: colors.neutral['400'], 26 | borderRadius: '6px', 27 | }, 28 | composer: { 29 | iconButtonColor: colors.neutral['900'], 30 | iconButtonColorHover: colors.neutral['500'], 31 | }, 32 | textColor: { 33 | base: colors.neutral['900'], 34 | muted: colors.neutral['500'], 35 | error: colors.rose['500'], 36 | }, 37 | }; 38 | 39 | export const ChatWindow: FC = () => { 40 | const [chatOpen, setChatOpen] = useState(false); 41 | const session = useSession(); 42 | 43 | return ( 44 | 45 | 46 |
    47 | 70 |
    71 |
    72 | 73 | e.preventDefault()} 77 | > 78 |
    79 | 80 | 84 | 85 | 86 |
    87 |
    88 |
    89 |
    90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /components/user/ProfileMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from '@supabase/auth-helpers-react'; 2 | import { FC } from 'react'; 3 | import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; 4 | import Image from 'next/image'; 5 | import useUser from '@/lib/hooks/use-user'; 6 | import useTeams from '@/lib/hooks/use-teams'; 7 | import Link from 'next/link'; 8 | 9 | type ProfileMenuProps = {}; 10 | 11 | const ProfileMenu: FC = () => { 12 | const session = useSession(); 13 | const { user, signOut } = useUser(); 14 | const { teams } = useTeams(); 15 | 16 | const personalTeam = teams?.find((t) => t.is_personal); 17 | 18 | return ( 19 | 20 | 21 | 37 | 38 | 39 | 43 | {user && ( 44 | 45 |
    46 |

    {user.full_name}

    47 |

    {user.email}

    48 |
    49 |
    50 | )} 51 | 52 | {personalTeam && ( 53 | 54 | 58 | Settings 59 | 60 | 61 | )} 62 | 63 | 64 | 70 | Twitter 71 | 72 | 73 | 74 | 80 | Discord 81 | 82 | 83 | 84 | 90 | GitHub 91 | 92 | 93 | 94 | 100 | Email 101 | 102 | 103 | 104 | signOut()} 106 | className="dropdown-menu-item dropdown-menu-item-noindent" 107 | > 108 | Sign out 109 | 110 |
    111 |
    112 |
    113 | ); 114 | }; 115 | 116 | export default ProfileMenu; 117 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL= 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY= 3 | SUPABASE_SERVICE_ROLE_KEY= 4 | UPSTASH_URL= 5 | UPSTASH_TOKEN= 6 | OPENAI_API_KEY= 7 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= 8 | STRIPE_SECRET_KEY= 9 | STRIPE_WEBHOOK_SECRET= 10 | NEXT_PUBLIC_STRIPE_APP_ID= 11 | NEXT_PUBLIC_FATHOM_SITE_ID= 12 | NEXT_PUBLIC_PLAIN_APP_KEY= 13 | PLAIN_SECRET_KEY= 14 | NEXT_PUBLIC_APP_HOSTNAME= 15 | NEXT_PUBLIC_SUPPORT_EMAIL= -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const I_DONT_KNOW = 'Sorry, I am not sure how to answer that.'; 2 | export const MIN_CONTENT_LENGTH = 20; 3 | export const MAX_PROMPT_LENGTH = 200; 4 | export const STREAM_SEPARATOR = '___START_RESPONSE_STREAM___'; 5 | -------------------------------------------------------------------------------- /lib/context/app.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from '@supabase/auth-helpers-react'; 2 | import { 3 | createContext, 4 | FC, 5 | PropsWithChildren, 6 | useContext, 7 | useEffect, 8 | } from 'react'; 9 | import Router, { useRouter } from 'next/router'; 10 | import useUser from '../hooks/use-user'; 11 | import useTeam from '../hooks/use-team'; 12 | import useTeams from '../hooks/use-teams'; 13 | import useProjects from '../hooks/use-projects'; 14 | import { initUserData } from '../api'; 15 | 16 | export type State = { 17 | isOnboarding: boolean; 18 | }; 19 | 20 | const initialContextState: State = { 21 | isOnboarding: false, 22 | }; 23 | 24 | const publicRoutes = ['/login', '/signin', '/legal']; 25 | 26 | const AppContextProvider = (props: PropsWithChildren) => { 27 | const router = useRouter(); 28 | const session = useSession(); 29 | const { user, loading: loadingUser } = useUser(); 30 | const { teams, loading: loadingTeams, mutate: mutateTeams } = useTeams(); 31 | const { team } = useTeam(); 32 | const { projects, mutate: mutateProjects } = useProjects(); 33 | 34 | // Create personal team if it doesn't exist 35 | useEffect(() => { 36 | if (user?.has_completed_onboarding) { 37 | return; 38 | } 39 | 40 | if (!user || loadingTeams) { 41 | return; 42 | } 43 | 44 | (async () => { 45 | const team = teams?.find((t) => t.is_personal); 46 | if (!team) { 47 | await initUserData(); 48 | await mutateTeams(); 49 | await mutateProjects(); 50 | } 51 | })(); 52 | }, [ 53 | teams, 54 | team, 55 | loadingTeams, 56 | session?.user, 57 | user, 58 | mutateTeams, 59 | mutateProjects, 60 | ]); 61 | 62 | useEffect(() => { 63 | if (!user || user.has_completed_onboarding) { 64 | return; 65 | } 66 | 67 | if (!teams) { 68 | return; 69 | } 70 | 71 | const personalTeam = teams.find((t) => t.is_personal); 72 | if (!personalTeam) { 73 | return; 74 | } 75 | 76 | // If user is onboarding and user is not on the personal 77 | // team path, redirect to it. 78 | if (router.query.team !== personalTeam.slug) { 79 | router.push({ 80 | pathname: '/[team]', 81 | query: { team: personalTeam.slug }, 82 | }); 83 | return; 84 | } 85 | 86 | let project = projects?.find((t) => t.is_starter); 87 | if (!project) { 88 | // If no starter project is found, find the first one in the list 89 | // (e.g. if the starter project was deleted). 90 | project = projects?.[0]; 91 | if (!project) { 92 | return; 93 | } 94 | } 95 | 96 | // If user is onboarding and user is not on the starter 97 | // project path, redirect to it. 98 | if ( 99 | router.query.project !== project.slug || 100 | router.pathname !== '/[team]/[project]' 101 | ) { 102 | router.push({ 103 | pathname: '/[team]/[project]', 104 | query: { team: personalTeam.slug, project: project.slug }, 105 | }); 106 | } 107 | }, [router, user, teams, projects]); 108 | 109 | useEffect(() => { 110 | if (!user || !user.has_completed_onboarding) { 111 | return; 112 | } 113 | 114 | if (router.pathname !== '/') { 115 | return; 116 | } 117 | 118 | const storedSlug = localStorage.getItem('stored_team_slug'); 119 | const slug = teams?.find((t) => t.slug === storedSlug) || teams?.[0]; 120 | if (slug) { 121 | Router.push(`/${slug.slug}`); 122 | } 123 | }, [ 124 | user, 125 | loadingUser, 126 | user?.has_completed_onboarding, 127 | teams, 128 | router.pathname, 129 | router.asPath, 130 | ]); 131 | 132 | useEffect(() => { 133 | if (router.query.team) { 134 | localStorage.setItem('stored_team_slug', `${router.query.team}`); 135 | } 136 | }, [router.query.team]); 137 | 138 | useEffect(() => { 139 | if (router.query.project) { 140 | localStorage.setItem('stored_project_slug', `${router.query.project}`); 141 | } 142 | }, [router.query.project]); 143 | 144 | return ( 145 | 151 | ); 152 | }; 153 | 154 | export const useAppContext = (): State => { 155 | const context = useContext(AppContext); 156 | if (context === undefined) { 157 | throw new Error(`useAppContext must be used within a AppContextProvider`); 158 | } 159 | return context; 160 | }; 161 | 162 | export const AppContext = createContext(initialContextState); 163 | 164 | AppContext.displayName = 'AppContext'; 165 | 166 | export const ManagedAppContext: FC = ({ children }) => ( 167 | {children} 168 | ); 169 | -------------------------------------------------------------------------------- /lib/github.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from 'octokit'; 2 | import { isPresent } from 'ts-is-present'; 3 | 4 | const octokit = new Octokit(); 5 | 6 | const parseGitHubURL = (url: string) => { 7 | const match = url.match( 8 | /^https:\/\/github.com\/([a-zA-Z0-9\-_\.]+)\/([a-zA-Z0-9\-_\.]+)/, 9 | ); 10 | if (match && match.length > 2) { 11 | return { owner: match[1], repo: match[2] }; 12 | } 13 | return undefined; 14 | }; 15 | 16 | export const isGitHubRepoAccessible = async (url: string) => { 17 | const info = parseGitHubURL(url); 18 | if (!info?.owner && !info?.repo) { 19 | return false; 20 | } 21 | try { 22 | const res = await octokit.request(`GET /repos/${info.owner}/${info.repo}`); 23 | if (res.status === 200) { 24 | return true; 25 | } 26 | } catch (e) { 27 | // 28 | } 29 | return false; 30 | }; 31 | 32 | const getRepo = async (owner: string, repo: string) => { 33 | const res = await octokit.request('GET /repos/{owner}/{repo}', { 34 | owner, 35 | repo, 36 | }); 37 | return res.data; 38 | }; 39 | 40 | const getDefaultBranch = async (owner: string, repo: string) => { 41 | const _repo = await getRepo(owner, repo); 42 | 43 | const branchRes = await octokit.request( 44 | `GET /repos/{owner}/{repo}/branches/{branch}`, 45 | { owner, repo, branch: _repo.default_branch }, 46 | ); 47 | 48 | return branchRes.data; 49 | }; 50 | 51 | const getTree = async (owner: string, repo: string) => { 52 | const defaultBranch = await getDefaultBranch(owner, repo); 53 | 54 | const tree = await octokit.request( 55 | 'GET /repos/{owner}/{repo}/git/trees/{tree_sha}', 56 | { 57 | owner, 58 | repo, 59 | tree_sha: defaultBranch.commit.sha, 60 | recursive: '1', 61 | }, 62 | ); 63 | 64 | return tree.data.tree; 65 | }; 66 | 67 | export const getOwnerRepoString = (url: string) => { 68 | const info = parseGitHubURL(url); 69 | if (!info?.owner && !info?.repo) { 70 | return undefined; 71 | } 72 | return `${info.owner}/${info.repo}`; 73 | }; 74 | 75 | export const getRepositoryMDFilesInfo = async ( 76 | url: string, 77 | ): Promise<{ name: string; path: string; url: string; sha: string }[]> => { 78 | const info = parseGitHubURL(url); 79 | if (!info?.owner && !info?.repo) { 80 | return []; 81 | } 82 | 83 | const tree = await getTree(info.owner, info.repo); 84 | 85 | const mdFileUrls = tree 86 | .map((f) => { 87 | if (f.url && f.path && /\.md(x|oc)?$/.test(f.path)) { 88 | let path = f.path; 89 | if (path.startsWith('.')) { 90 | // Ignore files in dot folders, like .github 91 | return undefined; 92 | } 93 | if (!path.startsWith('/')) { 94 | path = '/' + path; 95 | } 96 | return { 97 | name: f.path.split('/').slice(-1)[0], 98 | path, 99 | url: f.url, 100 | sha: f.sha || '', 101 | }; 102 | } 103 | return undefined; 104 | }) 105 | .filter(isPresent); 106 | return mdFileUrls; 107 | }; 108 | 109 | export const getContent = async (url: string): Promise => { 110 | const res = await fetch(url, { 111 | headers: { accept: 'application/json' }, 112 | }).then((res) => res.json()); 113 | return Buffer.from(res.content, 'base64').toString('utf8'); 114 | }; 115 | -------------------------------------------------------------------------------- /lib/hooks/use-domains.ts: -------------------------------------------------------------------------------- 1 | import { Domain } from '@/types/types'; 2 | import useSWR from 'swr'; 3 | import { fetcher } from '../utils'; 4 | import useProject from './use-project'; 5 | 6 | export default function useDomains() { 7 | const { project } = useProject(); 8 | const { 9 | data: domains, 10 | mutate, 11 | error, 12 | } = useSWR( 13 | project?.id ? `/api/project/${project.id}/domains` : null, 14 | fetcher, 15 | ); 16 | 17 | const loading = !domains && !error; 18 | 19 | return { domains, loading, mutate }; 20 | } 21 | -------------------------------------------------------------------------------- /lib/hooks/use-files.ts: -------------------------------------------------------------------------------- 1 | import { DbFile } from '@/types/types'; 2 | import useSWR from 'swr'; 3 | import { fetcher } from '../utils'; 4 | import useProject from './use-project'; 5 | 6 | export default function useFiles() { 7 | const { project } = useProject(); 8 | const { 9 | data: files, 10 | mutate, 11 | error, 12 | } = useSWR( 13 | project?.id ? `/api/project/${project.id}/files` : null, 14 | fetcher, 15 | ); 16 | 17 | const loading = !files && !error; 18 | 19 | return { files, loading, mutate }; 20 | } 21 | -------------------------------------------------------------------------------- /lib/hooks/use-project.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '@/types/types'; 2 | import { useRouter } from 'next/router'; 3 | import useSWR from 'swr'; 4 | import { fetcher } from '../utils'; 5 | import useProjects from './use-projects'; 6 | 7 | export default function useProject() { 8 | const router = useRouter(); 9 | const { projects } = useProjects(); 10 | const projectId = projects?.find((t) => t.slug === router.query.project)?.id; 11 | const { 12 | data: project, 13 | mutate, 14 | error, 15 | } = useSWR(projectId ? `/api/project/${projectId}` : null, fetcher); 16 | 17 | const loading = !project && !error; 18 | 19 | return { loading, project, mutate }; 20 | } 21 | -------------------------------------------------------------------------------- /lib/hooks/use-projects.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '@/types/types'; 2 | import { useRouter } from 'next/router'; 3 | import useSWR from 'swr'; 4 | import { fetcher } from '../utils'; 5 | import useTeams from './use-teams'; 6 | 7 | export default function useProjects() { 8 | const router = useRouter(); 9 | const { teams } = useTeams(); 10 | const teamId = teams?.find((t) => t.slug === router.query.team)?.id; 11 | const { 12 | data: projects, 13 | mutate, 14 | error, 15 | } = useSWR( 16 | teamId ? `/api/team/${teamId}/projects` : null, 17 | fetcher, 18 | ); 19 | 20 | const loading = !projects && !error; 21 | 22 | return { loading, projects, mutate }; 23 | } 24 | -------------------------------------------------------------------------------- /lib/hooks/use-team.ts: -------------------------------------------------------------------------------- 1 | import { Team } from '@/types/types'; 2 | import { useRouter } from 'next/router'; 3 | import useSWR from 'swr'; 4 | import { fetcher } from '../utils'; 5 | import useTeams from './use-teams'; 6 | 7 | export default function useTeam() { 8 | const router = useRouter(); 9 | const { teams } = useTeams(); 10 | const teamId = teams?.find((t) => t.slug === router.query.team)?.id; 11 | const { 12 | data: team, 13 | mutate, 14 | error, 15 | } = useSWR(teamId ? `/api/team/${teamId}` : null, fetcher); 16 | 17 | const loading = !team && !error; 18 | 19 | return { team, loading, mutate }; 20 | } 21 | -------------------------------------------------------------------------------- /lib/hooks/use-teams.ts: -------------------------------------------------------------------------------- 1 | import { Team } from '@/types/types'; 2 | import useSWR from 'swr'; 3 | import { fetcher } from '../utils'; 4 | import useUser from './use-user'; 5 | 6 | export default function useTeams() { 7 | const { user } = useUser(); 8 | const { 9 | data: teams, 10 | mutate, 11 | error, 12 | } = useSWR(user ? '/api/teams' : null, fetcher); 13 | 14 | const loading = !teams && !error; 15 | 16 | return { loading, teams, mutate }; 17 | } 18 | -------------------------------------------------------------------------------- /lib/hooks/use-tokens.ts: -------------------------------------------------------------------------------- 1 | import { Token } from '@/types/types'; 2 | import useSWR from 'swr'; 3 | import { fetcher } from '../utils'; 4 | import useProject from './use-project'; 5 | 6 | export default function useTokens() { 7 | const { project } = useProject(); 8 | const { 9 | data: tokens, 10 | mutate, 11 | error, 12 | } = useSWR( 13 | project?.id ? `/api/project/${project.id}/tokens` : null, 14 | fetcher, 15 | ); 16 | 17 | const loading = !tokens && !error; 18 | 19 | return { tokens, loading, mutate }; 20 | } 21 | -------------------------------------------------------------------------------- /lib/hooks/use-user.ts: -------------------------------------------------------------------------------- 1 | import { DbUser } from '@/types/types'; 2 | import { useSession, useSupabaseClient } from '@supabase/auth-helpers-react'; 3 | import { usePlain } from '@team-plain/react-chat-ui'; 4 | import Router from 'next/router'; 5 | import { useCallback } from 'react'; 6 | import { toast } from 'react-hot-toast'; 7 | import useSWR, { useSWRConfig } from 'swr'; 8 | import { fetcher } from '../utils'; 9 | 10 | export default function useUser() { 11 | const supabase = useSupabaseClient(); 12 | const session = useSession(); 13 | const { logout: plainLogout } = usePlain(); 14 | const { mutate } = useSWRConfig(); 15 | const { 16 | data: user, 17 | mutate: mutateUser, 18 | error, 19 | } = useSWR(session?.user ? '/api/user' : null, fetcher); 20 | 21 | const loading = session?.user ? !user && !error : false; 22 | const loggedOut = error && error.status === 403; 23 | 24 | const signOut = useCallback(async () => { 25 | plainLogout(); 26 | if (!supabase?.auth) { 27 | Router.push('/'); 28 | return; 29 | } 30 | const { error } = await supabase.auth.signOut(); 31 | if (error) { 32 | toast.error(`Error signing out: ${error.message}`); 33 | return; 34 | } 35 | 36 | await mutate('/api/user'); 37 | setTimeout(() => { 38 | Router.push('/'); 39 | }, 500); 40 | }, [supabase.auth, mutate, plainLogout]); 41 | 42 | return { loading, loggedOut, user, mutate: mutateUser, signOut }; 43 | } 44 | -------------------------------------------------------------------------------- /lib/middleware/app.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs'; 3 | 4 | const UNAUTHED_PATHS = [ 5 | '/', 6 | '/login', 7 | '/signup', 8 | '/legal/terms', 9 | '/legal/privacy', 10 | '/api/subscriptions/webhook', 11 | ]; 12 | 13 | export default async function AppMiddleware(req: NextRequest) { 14 | const path = req.nextUrl.pathname; 15 | 16 | const res = NextResponse.next(); 17 | const supabase = createMiddlewareSupabaseClient({ req, res }); 18 | 19 | const { 20 | data: { session }, 21 | } = await supabase.auth.getSession(); 22 | 23 | if (!session?.user && !UNAUTHED_PATHS.includes(path)) { 24 | return NextResponse.redirect(new URL('/login', req.url)); 25 | } else if (session?.user && (path === '/login' || path === '/signup')) { 26 | return NextResponse.redirect(new URL('/', req.url)); 27 | } 28 | 29 | return NextResponse.rewrite(new URL(path, req.url)); 30 | } 31 | -------------------------------------------------------------------------------- /lib/middleware/common.ts: -------------------------------------------------------------------------------- 1 | import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | 4 | export const noTokenResponse = new NextResponse( 5 | JSON.stringify({ 6 | success: false, 7 | message: 8 | 'An authorization token needs to be provided. Head over to the Markprompt dashboard and get one under the project settings.', 9 | }), 10 | { status: 401, headers: { 'content-type': 'application/json' } }, 11 | ); 12 | 13 | export const noProjectForTokenResponse = new NextResponse( 14 | JSON.stringify({ 15 | success: false, 16 | message: 17 | 'No project was found matching the provided token. Head over to the Markprompt dashboard and get a valid token under the project settings.', 18 | }), 19 | { status: 401, headers: { 'content-type': 'application/json' } }, 20 | ); 21 | 22 | export const getProjectIdFromToken = async ( 23 | req: NextRequest, 24 | res: NextResponse, 25 | token: string, 26 | ) => { 27 | const supabase = createMiddlewareSupabaseClient({ req, res }); 28 | 29 | const { data } = await supabase 30 | .from('tokens') 31 | .select('project_id') 32 | .eq('value', token) 33 | .maybeSingle(); 34 | 35 | return data?.project_id; 36 | }; 37 | -------------------------------------------------------------------------------- /lib/middleware/completions.ts: -------------------------------------------------------------------------------- 1 | import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | import { checkCompletionsRateLimits } from '../rate-limits'; 4 | import { getAuthorizationToken, getHost, removeSchema } from '../utils'; 5 | import { 6 | getProjectIdFromToken, 7 | noProjectForTokenResponse, 8 | noTokenResponse, 9 | } from './common'; 10 | 11 | export default async function CompletionsMiddleware(req: NextRequest) { 12 | if (process.env.NODE_ENV === 'production') { 13 | if (!req.ip) { 14 | return new Response('Forbidden', { status: 403 }); 15 | } 16 | 17 | // Apply rate limiting here already based on IP. After that, apply rate 18 | // limiting on requester origin and token. 19 | 20 | const rateLimitIPResult = await checkCompletionsRateLimits({ 21 | value: req.ip, 22 | type: 'ip', 23 | }); 24 | 25 | if (!rateLimitIPResult.result.success) { 26 | console.error(`[TRAIN] [RATE-LIMIT] IP ${req.ip}`); 27 | return new Response('Too many requests', { status: 429 }); 28 | } 29 | } 30 | 31 | const path = req.nextUrl.pathname; 32 | const requesterOrigin = req.headers.get('origin'); 33 | 34 | let projectId; 35 | if (requesterOrigin) { 36 | const requesterHost = removeSchema(requesterOrigin); 37 | 38 | const rateLimitHostnameResult = await checkCompletionsRateLimits({ 39 | value: requesterHost, 40 | type: 'hostname', 41 | }); 42 | 43 | if (!rateLimitHostnameResult.result.success) { 44 | console.error( 45 | `[TRAIN] [RATE-LIMIT] Origin ${requesterHost}, IP: ${req.ip}`, 46 | ); 47 | return new Response('Too many requests', { status: 429 }); 48 | } 49 | 50 | if (requesterHost === getHost()) { 51 | // Requests from the Markprompt dashboard explicitly specify 52 | // the project id in the path: /completions/[project] 53 | projectId = path.split('/').slice(-1)[0]; 54 | } else { 55 | const res = NextResponse.next(); 56 | const projectKey = req.nextUrl.searchParams.get('projectKey'); 57 | const supabase = createMiddlewareSupabaseClient({ req, res }); 58 | 59 | let { data } = await supabase 60 | .from('projects') 61 | .select('id') 62 | .match({ public_api_key: projectKey }) 63 | .limit(1) 64 | .select() 65 | .maybeSingle(); 66 | 67 | if (!data?.id) { 68 | return new Response('Project not found', { status: 404 }); 69 | } 70 | 71 | projectId = data?.id; 72 | 73 | // Now that we have a project id, we need to check that the 74 | // the project has whitelisted the domain the request comes from. 75 | 76 | let { count } = await supabase 77 | .from('domains') 78 | .select('id', { count: 'exact' }) 79 | .eq('project_id', projectId); 80 | 81 | if (count === 0) { 82 | return new Response( 83 | 'This domain is not allowed to access completions for this project', 84 | { status: 401 }, 85 | ); 86 | } 87 | } 88 | } else { 89 | // Non-browser requests expect an authorization token. 90 | const token = getAuthorizationToken(req.headers.get('Authorization')); 91 | if (!token) { 92 | return noTokenResponse; 93 | } 94 | 95 | // Apply rate-limit here already, before looking up the project id, 96 | // which requires a database lookup. 97 | const rateLimitResult = await checkCompletionsRateLimits({ 98 | value: token, 99 | type: 'token', 100 | }); 101 | 102 | if (!rateLimitResult.result.success) { 103 | console.error(`[TRAIN] [RATE-LIMIT] Token ${token}, IP: ${req.ip}`); 104 | return new Response('Too many requests', { status: 429 }); 105 | } 106 | 107 | const res = NextResponse.next(); 108 | const projectId = await getProjectIdFromToken(req, res, token); 109 | 110 | if (!projectId) { 111 | return noProjectForTokenResponse; 112 | } 113 | } 114 | 115 | return NextResponse.rewrite( 116 | new URL(`/api/openai/completions/${projectId}`, req.url), 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /lib/middleware/train.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { checkCompletionsRateLimits } from '../rate-limits'; 3 | import { getAuthorizationToken, getHost, removeSchema } from '../utils'; 4 | import { 5 | getProjectIdFromToken, 6 | noProjectForTokenResponse, 7 | noTokenResponse, 8 | } from './common'; 9 | 10 | export default async function TrainMiddleware(req: NextRequest) { 11 | // Requests to api.markprompt.com/train come exclusively from external 12 | // sources. Indeed, in the Markprompt dashboard, the internal 13 | // route /api/openai/train-file is used, and it will be cauche 14 | // when comparing requester hosts. So only requests with a valid 15 | // authorization bearer token will be accepted here. 16 | 17 | if (!req.ip) { 18 | return new Response('Forbidden', { status: 403 }); 19 | } 20 | 21 | const token = getAuthorizationToken(req.headers.get('Authorization')); 22 | 23 | if (!token) { 24 | return noTokenResponse; 25 | } 26 | 27 | // Apply rate limiting here already based on IP. After that, apply rate 28 | // limiting on requester token. 29 | const rateLimitIPResult = await checkCompletionsRateLimits({ 30 | value: req.ip, 31 | type: 'ip', 32 | }); 33 | 34 | if (!rateLimitIPResult.result.success) { 35 | console.error(`[TRAIN] [RATE-LIMIT] IP ${req.ip}`); 36 | return new Response('Too many requests', { status: 429 }); 37 | } 38 | 39 | // Apply rate-limit here already, before looking up the project id, 40 | // which requires a database lookup. 41 | const rateLimitResult = await checkCompletionsRateLimits({ 42 | value: token, 43 | type: 'token', 44 | }); 45 | 46 | if (!rateLimitResult.result.success) { 47 | console.error(`[TRAIN] [RATE-LIMIT] Token ${token}, IP: ${req.ip}`); 48 | return new Response('Too many requests', { status: 429 }); 49 | } 50 | 51 | const res = NextResponse.next(); 52 | 53 | const projectId = await getProjectIdFromToken(req, res, token); 54 | 55 | if (!projectId) { 56 | return noProjectForTokenResponse; 57 | } 58 | 59 | return NextResponse.rewrite( 60 | new URL(`/api/openai/train/${projectId}`, req.url), 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /lib/openai.edge.ts: -------------------------------------------------------------------------------- 1 | // IMPORTANT: this code needs to be able to run on the Vercel edge runtime. 2 | // Make sure no Node.js APIs are called/imported transitively. 3 | 4 | import { CreateEmbeddingResponse, CreateModerationResponse } from 'openai'; 5 | 6 | export interface OpenAIStreamPayload { 7 | model: string; 8 | prompt: string; 9 | temperature: number; 10 | top_p: number; 11 | frequency_penalty: number; 12 | presence_penalty: number; 13 | max_tokens: number; 14 | stream: boolean; 15 | n: number; 16 | } 17 | 18 | export const createModeration = async ( 19 | input: string, 20 | ): Promise => { 21 | return fetch('https://api.openai.com/v1/moderations', { 22 | headers: { 23 | 'Content-Type': 'application/json', 24 | Authorization: `Bearer ${process.env.OPENAI_API_KEY!}`, 25 | }, 26 | method: 'POST', 27 | body: JSON.stringify({ input }), 28 | }).then((r) => r.json()); 29 | }; 30 | 31 | export const createEmbedding = async ( 32 | input: string, 33 | ): Promise => { 34 | return await fetch('https://api.openai.com/v1/embeddings', { 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | Authorization: `Bearer ${process.env.OPENAI_API_KEY!}`, 38 | }, 39 | method: 'POST', 40 | body: JSON.stringify({ 41 | model: 'text-embedding-ada-002', 42 | input: input.trim().replaceAll('\n', ' '), 43 | }), 44 | }).then((r) => r.json()); 45 | }; 46 | -------------------------------------------------------------------------------- /lib/rate-limits.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '@/types/types'; 2 | import { Ratelimit } from '@upstash/ratelimit'; 3 | import { getRedisClient } from './redis'; 4 | import { pluralize } from './utils'; 5 | 6 | type RateLimitIdProjectIdType = { value: Project['id']; type: 'projectId' }; 7 | type RateLimitIdTokenType = { value: string; type: 'token' }; 8 | type RateLimitIdHostnameType = { value: string; type: 'hostname' }; 9 | type RateLimitIdIPType = { value: string; type: 'ip' }; 10 | 11 | type RateLimitIdType = 12 | | RateLimitIdProjectIdType 13 | | RateLimitIdTokenType 14 | | RateLimitIdHostnameType 15 | | RateLimitIdIPType; 16 | 17 | const rateLimitTypeToKey = (identifier: RateLimitIdType) => { 18 | return `${identifier.type}:${identifier.value}`; 19 | }; 20 | 21 | export const checkEmbeddingsRateLimits = async ( 22 | identifier: RateLimitIdType, 23 | ) => { 24 | // For now, impose a hard limit of 100 embeddings per minute 25 | // per project. Later, make this configurable. 26 | const ratelimit = new Ratelimit({ 27 | redis: getRedisClient(), 28 | limiter: Ratelimit.fixedWindow(100, '1 m'), 29 | analytics: true, 30 | }); 31 | 32 | const result = await ratelimit.limit(rateLimitTypeToKey(identifier)); 33 | 34 | // Calcualte the remaining time until generations are reset 35 | const diff = Math.abs( 36 | new Date(result.reset).getTime() - new Date().getTime(), 37 | ); 38 | const hours = Math.floor(diff / 1000 / 60 / 60); 39 | const minutes = Math.floor(diff / 1000 / 60) - hours * 60; 40 | 41 | return { result, hours, minutes }; 42 | }; 43 | 44 | export const checkCompletionsRateLimits = async ( 45 | identifier: RateLimitIdType, 46 | ) => { 47 | // For now, impose a hard limit of 10 completions per minute 48 | // per hostname. Later, tie it to the plan associated to a team/project. 49 | const ratelimit = new Ratelimit({ 50 | redis: getRedisClient(), 51 | limiter: Ratelimit.fixedWindow(10, '60 s'), 52 | analytics: true, 53 | }); 54 | 55 | const result = await ratelimit.limit(rateLimitTypeToKey(identifier)); 56 | 57 | // Calcualte the remaining time until generations are reset 58 | const diff = Math.abs( 59 | new Date(result.reset).getTime() - new Date().getTime(), 60 | ); 61 | const hours = Math.floor(diff / 1000 / 60 / 60); 62 | const minutes = Math.floor(diff / 1000 / 60) - hours * 60; 63 | 64 | return { result, hours, minutes }; 65 | }; 66 | 67 | export const getEmbeddingsRateLimitResponse = ( 68 | hours: number, 69 | minutes: number, 70 | ) => { 71 | return `You have reached your training limit for the day. You can resume training in ${pluralize( 72 | hours, 73 | 'hour', 74 | 'hours', 75 | )} and ${pluralize(minutes, 'minute', 'minutes')}. Email ${ 76 | process.env.NEXT_PUBLIC_SUPPORT_EMAIL || 'us' 77 | } if you have any questions.`; 78 | }; 79 | -------------------------------------------------------------------------------- /lib/redis.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '@/types/types'; 2 | import { Redis } from '@upstash/redis'; 3 | 4 | let redis: Redis | undefined = undefined; 5 | 6 | const monthBin = (date: Date) => { 7 | return `${date.getFullYear()}/${date.getMonth() + 1}`; 8 | }; 9 | 10 | export const getProjectChecksumsKey = (projectId: Project['id']) => { 11 | return `${process.env.NODE_ENV}:project:${projectId}:checksums`; 12 | }; 13 | 14 | export const getProjectEmbeddingsMonthTokenCountKey = ( 15 | projectId: Project['id'], 16 | date: Date, 17 | ) => { 18 | return `${process.env.NODE_ENV}:project:${projectId}:token_count:${monthBin( 19 | date, 20 | )}`; 21 | }; 22 | 23 | export const getRedisClient = () => { 24 | if (!redis) { 25 | redis = new Redis({ 26 | url: process.env.UPSTASH_URL || '', 27 | token: process.env.UPSTASH_TOKEN || '', 28 | }); 29 | } 30 | return redis; 31 | }; 32 | 33 | export const safeGetObject = async ( 34 | key: string, 35 | defaultValue: T, 36 | ): Promise => { 37 | const value = await get(key); 38 | if (value) { 39 | try { 40 | return JSON.parse(value); 41 | } catch (e) { 42 | // Do nothing 43 | } 44 | } 45 | return defaultValue; 46 | }; 47 | 48 | export const get = async (key: string): Promise => { 49 | try { 50 | return getRedisClient().get(key); 51 | } catch (e) { 52 | console.error('Redis `get` error', e); 53 | } 54 | return null; 55 | }; 56 | 57 | export const set = async (key: string, value: string) => { 58 | try { 59 | await getRedisClient().set(key, value); 60 | } catch (e) { 61 | console.error('Redis `set` error', e, key, value); 62 | } 63 | }; 64 | 65 | export const setWithExpiration = async ( 66 | key: string, 67 | value: string, 68 | expirationInSeconds: number, 69 | ) => { 70 | try { 71 | await getRedisClient().set(key, value, { ex: expirationInSeconds }); 72 | } catch (e) { 73 | console.error('Redis `set` error', e); 74 | } 75 | }; 76 | 77 | export const hget = async (key: string, field: string): Promise => { 78 | try { 79 | return getRedisClient().hget(key, field); 80 | } catch (e) { 81 | console.error('Redis `hget` error', e); 82 | } 83 | return undefined; 84 | }; 85 | 86 | export const hset = async (key: string, object: any) => { 87 | try { 88 | await getRedisClient().hset(key, object); 89 | } catch (e) { 90 | console.error('Redis `hset` error', e); 91 | } 92 | }; 93 | 94 | export const del = async (key: string) => { 95 | try { 96 | await getRedisClient().del(key); 97 | } catch (e) { 98 | console.error('Redis `del` error', e); 99 | } 100 | }; 101 | 102 | export const batchGet = async (keys: string[]) => { 103 | try { 104 | const pipeline = getRedisClient().pipeline(); 105 | for (const key of keys) { 106 | pipeline.get(key); 107 | } 108 | return pipeline.exec(); 109 | } catch (e) { 110 | console.error('Redis `batchGet` error', e); 111 | } 112 | }; 113 | 114 | export const batchDel = async (keys: string[]) => { 115 | try { 116 | const pipeline = getRedisClient().pipeline(); 117 | for (const key of keys) { 118 | pipeline.del(key); 119 | } 120 | await pipeline.exec(); 121 | } catch (e) { 122 | console.error('Redis `batchDel` error', e); 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /lib/stripe/client.ts: -------------------------------------------------------------------------------- 1 | import { Stripe as StripeProps, loadStripe } from '@stripe/stripe-js'; 2 | 3 | let stripePromise: Promise; 4 | 5 | export const getStripe = () => { 6 | if (!stripePromise) { 7 | stripePromise = loadStripe( 8 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? '', 9 | ); 10 | } 11 | 12 | return stripePromise; 13 | }; 14 | -------------------------------------------------------------------------------- /lib/stripe/server.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', { 4 | apiVersion: '2022-11-15', 5 | appInfo: { 6 | name: 'Markprompt', 7 | version: '0.1.0', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /lib/supabase.ts: -------------------------------------------------------------------------------- 1 | import type { SupabaseClient } from '@supabase/auth-helpers-nextjs'; 2 | import { DbFile, Project } from '@/types/types'; 3 | 4 | export const getFileAtPath = async ( 5 | supabase: SupabaseClient, 6 | projectId: Project['id'], 7 | path: string, 8 | ): Promise => { 9 | try { 10 | const { data } = await supabase 11 | .from('files') 12 | .select('id') 13 | .match({ project_id: projectId, path }) 14 | .limit(1) 15 | .maybeSingle(); 16 | return data?.id as DbFile['id']; 17 | } catch (error) { 18 | console.error('Error:', error); 19 | } 20 | return undefined; 21 | }; 22 | 23 | export const createFile = async ( 24 | supabase: SupabaseClient, 25 | projectId: Project['id'], 26 | path: string, 27 | meta: any, 28 | ): Promise => { 29 | let { error, data } = await supabase 30 | .from('files') 31 | .insert([{ project_id: projectId, path, meta }]) 32 | .select('id') 33 | .limit(1) 34 | .maybeSingle(); 35 | if (error) { 36 | throw error; 37 | } 38 | return data?.id as DbFile['id']; 39 | }; 40 | -------------------------------------------------------------------------------- /lib/utils.node.ts: -------------------------------------------------------------------------------- 1 | // Node-dependent utilities. Cannot run on edge runtimes. 2 | import grayMatter from 'gray-matter'; 3 | import yaml from 'js-yaml'; 4 | import { debounce } from 'lodash-es'; 5 | import { useEffect, useRef, useState } from 'react'; 6 | 7 | export const extractFrontmatter = ( 8 | source: string, 9 | ): { [key: string]: string } => { 10 | try { 11 | const matter = grayMatter(source, {})?.matter; 12 | if (matter) { 13 | return yaml.load(matter, { 14 | schema: yaml.JSON_SCHEMA, 15 | }) as { [key: string]: string }; 16 | } 17 | } catch { 18 | // Do nothing 19 | } 20 | return {}; 21 | }; 22 | 23 | export const useDebouncedState = ( 24 | initialValue: T, 25 | timeout: number, 26 | options?: any, 27 | ): [T, (value: T) => void] => { 28 | const [currentValue, setCurrrentValue] = useState(initialValue); 29 | const [newValue, setNewValue] = useState(initialValue); 30 | const debouncer = useRef<(value: T, setValue: (value: T) => void) => void>(); 31 | 32 | useEffect(() => { 33 | const debounceWrapper = debounce( 34 | (value: T, setValue: (value: T) => void) => { 35 | setValue(value); 36 | }, 37 | timeout, 38 | options, 39 | ); 40 | debouncer.current = debounceWrapper; 41 | 42 | return () => { 43 | debounceWrapper.cancel(); 44 | }; 45 | }, [options, timeout]); 46 | 47 | useEffect(() => { 48 | debouncer.current && debouncer.current(newValue, setCurrrentValue); 49 | }, [newValue]); 50 | 51 | return [currentValue, setNewValue]; 52 | }; 53 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFetchEvent, NextRequest, NextResponse } from 'next/server'; 2 | import CompletionsMiddleware from './lib/middleware/completions'; 3 | import AppMiddleware from './lib/middleware/app'; 4 | import { getHost } from './lib/utils'; 5 | import TrainMiddleware from './lib/middleware/train'; 6 | 7 | export const config = { 8 | matcher: [ 9 | '/((?!_next/|_proxy/|_auth/|_root/|_static|static|_vercel|[\\w-]+\\.\\w+).*)', 10 | ], 11 | }; 12 | 13 | export default async function middleware(req: NextRequest, ev: NextFetchEvent) { 14 | const hostname = req.headers.get('host'); 15 | 16 | if (hostname === getHost()) { 17 | return AppMiddleware(req); 18 | } 19 | 20 | if (hostname === 'api.markprompt.com' || hostname === 'api.localhost:3000') { 21 | const path = req.nextUrl.pathname; 22 | 23 | if (path?.startsWith('/completions')) { 24 | return CompletionsMiddleware(req); 25 | } else if (path?.startsWith('/train')) { 26 | return TrainMiddleware(req); 27 | } 28 | } 29 | 30 | return NextResponse.next(); 31 | } 32 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withMDX = require('@next/mdx')({ 2 | extension: /\.mdx?$/, 3 | }); 4 | 5 | const corsHeaders = [ 6 | { key: 'Access-Control-Allow-Credentials', value: 'true' }, 7 | { key: 'Access-Control-Allow-Origin', value: '*' }, 8 | { key: 'Access-Control-Allow-Methods', value: '*' }, 9 | { key: 'Access-Control-Allow-Headers', value: '*' }, 10 | ]; 11 | 12 | /** @type {import('next').NextConfig} */ 13 | const nextConfig = { 14 | pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'], 15 | reactStrictMode: true, 16 | images: { 17 | remotePatterns: [ 18 | { protocol: 'https', hostname: '**.googleusercontent.com' }, 19 | { protocol: 'https', hostname: '**.githubusercontent.com' }, 20 | ], 21 | }, 22 | async headers() { 23 | return [ 24 | { source: '/api/openai/completions/(.*)', headers: corsHeaders }, 25 | { source: '/completions/(.*)', headers: corsHeaders }, 26 | ]; 27 | }, 28 | }; 29 | 30 | module.exports = withMDX(nextConfig); 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markprompt", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@markdoc/markdoc": "^0.2.2", 13 | "@mdx-js/loader": "^2.3.0", 14 | "@mdx-js/react": "^2.3.0", 15 | "@next/mdx": "^13.2.4", 16 | "@radix-ui/react-checkbox": "^1.0.3", 17 | "@radix-ui/react-dialog": "^1.0.3", 18 | "@radix-ui/react-dropdown-menu": "^2.0.4", 19 | "@radix-ui/react-icons": "^1.2.0", 20 | "@radix-ui/react-navigation-menu": "^1.1.2", 21 | "@radix-ui/react-popover": "^1.0.5", 22 | "@radix-ui/react-slider": "^1.1.1", 23 | "@radix-ui/react-toggle-group": "^1.0.3", 24 | "@sindresorhus/slugify": "^2.2.0", 25 | "@stripe/stripe-js": "^1.49.0", 26 | "@supabase/auth-helpers-nextjs": "^0.5.6", 27 | "@supabase/auth-helpers-react": "^0.3.1", 28 | "@supabase/auth-ui-react": "^0.3.5", 29 | "@supabase/supabase-js": "^2.10.0", 30 | "@tailwindcss/forms": "^0.5.3", 31 | "@tanstack/react-table": "^8.7.9", 32 | "@team-plain/react-chat-ui": "^6.2.3", 33 | "@upstash/ratelimit": "^0.4.0", 34 | "@upstash/redis": "^1.20.1", 35 | "@visx/axis": "^2.12.2", 36 | "@visx/event": "^2.6.0", 37 | "@visx/grid": "^2.12.2", 38 | "@visx/responsive": "^2.10.0", 39 | "@visx/scale": "^2.2.2", 40 | "@visx/tooltip": "^2.10.0", 41 | "axios": "^1.3.4", 42 | "canvas-confetti": "^1.6.0", 43 | "classnames": "^2.3.2", 44 | "clsx": "^1.2.1", 45 | "common-tags": "^1.8.2", 46 | "dayjs": "^1.11.7", 47 | "eslint": "8.35.0", 48 | "eslint-config-next": "13.2.3", 49 | "eventsource-parser": "^0.1.0", 50 | "exponential-backoff": "^3.1.1", 51 | "fathom-client": "^3.5.0", 52 | "formidable": "^2.1.1", 53 | "formik": "^2.2.9", 54 | "gpt3-tokenizer": "^1.1.5", 55 | "gray-matter": "^4.0.3", 56 | "js-md5": "^0.7.3", 57 | "js-yaml": "^4.1.0", 58 | "jsonwebtoken": "^9.0.0", 59 | "lodash-es": "^4.17.21", 60 | "nanoid": "^4.0.1", 61 | "next": "13.2.3", 62 | "next-themes": "^0.2.1", 63 | "octokit": "^2.0.14", 64 | "openai": "^3.2.1", 65 | "pako": "^2.1.0", 66 | "prism-react-renderer": "^1.3.5", 67 | "react": "18.2.0", 68 | "react-dom": "18.2.0", 69 | "react-dropzone": "^14.2.3", 70 | "react-hot-toast": "^2.4.0", 71 | "react-markdown": "^8.0.5", 72 | "react-wrap-balancer": "^0.4.0", 73 | "remark-gfm": "^3.0.1", 74 | "stripe": "^11.15.0", 75 | "swr": "^2.1.0", 76 | "ts-is-present": "^1.2.2", 77 | "turndown": "^7.1.1", 78 | "unique-names-generator": "^4.7.1", 79 | "unist-builder": "^3.0.1", 80 | "unist-util-filter": "^4.0.1", 81 | "unzipper": "^0.10.11", 82 | "use-debounce": "^9.0.3" 83 | }, 84 | "devDependencies": { 85 | "@tailwindcss/typography": "^0.5.9", 86 | "@types/canvas-confetti": "^1.6.0", 87 | "@types/common-tags": "^1.8.1", 88 | "@types/formidable": "^2.0.5", 89 | "@types/js-md5": "^0.7.0", 90 | "@types/js-yaml": "^4.0.5", 91 | "@types/lodash-es": "^4.17.7", 92 | "@types/ms": "0.7.31", 93 | "@types/node": "18.14.6", 94 | "@types/react": "18.0.28", 95 | "@types/react-dom": "18.0.11", 96 | "@types/turndown": "^5.0.1", 97 | "@types/unzipper": "^0.10.5", 98 | "@vercel/git-hooks": "1.0.0", 99 | "autoprefixer": "10.4.13", 100 | "lint-staged": "13.1.0", 101 | "postcss": "8.4.20", 102 | "prettier": "2.8.1", 103 | "prettier-plugin-tailwindcss": "0.2.1", 104 | "tailwindcss": "3.2.4", 105 | "typescript": "4.9.4" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /packages/markprompt-react/.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 | /dist 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /packages/markprompt-react/README.md: -------------------------------------------------------------------------------- 1 | # Markprompt React 2 | 3 | A headless React component for building a prompt interface, based on the [Markprompt](https://markprompt.com) API. 4 | 5 |
    6 |

    7 | 8 | 9 | 10 | 11 | 12 | 13 |

    14 | 15 | ## Installation 16 | 17 | In [Motif](https://motif.land), paste the following import statement in an MDX, JSX or TSX file: 18 | 19 | ```jsx 20 | import { Markprompt } from 'https://esm.sh/markprompt'; 21 | ``` 22 | 23 | If you have a Node-based setup, install the `markprompt` package via npm or yarn: 24 | 25 | ```sh 26 | # npm 27 | npm install markprompt 28 | 29 | # Yarn 30 | yarn add markprompt 31 | ``` 32 | 33 | ## Usage 34 | 35 | Example: 36 | 37 | ```jsx 38 | import { Markprompt } from 'markprompt'; 39 | 40 | function MyPrompt() { 41 | return ; 42 | } 43 | ``` 44 | 45 | where `project-key` can be obtained in your project settings, and `model` is the identifier of the OpenAI model to use for completions. Supported models are: 46 | 47 | - Chat completions: `gpt-4` `gpt-4-0314` `gpt-4-32k` `gpt-4-32k-0314` `gpt-3.5-turbo` `gpt-3.5-turbo-0301` 48 | - Completions: `text-davinci-003`, `text-davinci-002`, `text-curie-001`, `text-babbage-001`, `text-ada-001`, `davinci`, `curie`, `babbage`, `ada` 49 | 50 | If no model is specified, `gpt-3.5-turbo` will be used. 51 | 52 | ## Styling 53 | 54 | The Markprompt component is styled using [Tailwind CSS](https://tailwindcss.com/), and therefore requires a working Tailwind configuration. We are planning to make it headless, for more flexible options. 55 | 56 | ## Configuration 57 | 58 | You can pass the following props to the component: 59 | 60 | | Prop | Default value | Description | 61 | | ------------------ | ---------------------------------------- | --------------------------------------------------------------- | 62 | | `iDontKnowMessage` | Sorry, I am not sure how to answer that. | Fallback message in can no answer is found. | 63 | | `placeholder` | 'Ask me anything...' | Message to show in the input box when no text has been entered. | 64 | 65 | Example: 66 | 67 | ```jsx 68 | 74 | ``` 75 | 76 | ## Whitelisting your domain 77 | 78 | Usage of the [Markprompt API](https://markprompt.com) is subject to quotas, depending on the plan you have subscribed to. Markprompt has systems in place to detect abuse and excessive usage, but we nevertheless recommend being cautious when offering a prompt interface on a public website. In any case, the prompt will **only work on domains you have whitelisted** through the [Markprompt dashboard](https://markprompt.com). 79 | 80 | ## Passing an authorization token 81 | 82 | If you cannot use a whitelisted domain, for instance when developing on localhost, you can alternatively pass an authorization token: 83 | 84 | ```jsx 85 | 86 | ``` 87 | 88 | You can obtain this token in the project settings. This token is tied to a specific project, so adding the `project` prop will not have any effect. 89 | 90 | **Important:** Make sure to keep this token private, and never publish code that exposes it. If your token has been compromised, you can generate a new one in the settings. 91 | 92 | ## Community 93 | 94 | - [Twitter @markprompt](https://twitter.com/markprompt) 95 | - [Twitter @motifland](https://twitter.com/motifland) 96 | - [Discord](https://discord.gg/MBMh4apz6X) 97 | 98 | ## Authors 99 | 100 | This library is created by the team behind [Motif](https://motif.land) 101 | ([@motifland](https://twitter.com/motifland)). 102 | 103 | ## License 104 | 105 | MIT 106 | -------------------------------------------------------------------------------- /packages/markprompt-react/build/index.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (process.env.NODE_ENV === 'production') { 4 | module.exports = require('./markprompt.prod.cjs'); 5 | } else { 6 | module.exports = require('./markprompt.dev.cjs'); 7 | } 8 | -------------------------------------------------------------------------------- /packages/markprompt-react/jest.config.cjs: -------------------------------------------------------------------------------- 1 | let create = require('../../jest/create-jest-config.cjs') 2 | module.exports = create(__dirname, { 3 | displayName: 'React', 4 | setupFilesAfterEnv: ['./jest.setup.js'], 5 | }) 6 | -------------------------------------------------------------------------------- /packages/markprompt-react/jest.setup.js: -------------------------------------------------------------------------------- 1 | globalThis.IS_REACT_ACT_ENVIRONMENT = true 2 | -------------------------------------------------------------------------------- /packages/markprompt-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markprompt", 3 | "version": "0.1.2", 4 | "description": "A React component for adding GPT-4 powered search using the Markprompt API.", 5 | "main": "dist/index.cjs", 6 | "typings": "dist/index.d.ts", 7 | "module": "dist/markprompt.esm.js", 8 | "license": "MIT", 9 | "files": [ 10 | "README.md", 11 | "dist" 12 | ], 13 | "exports": { 14 | "import": "./dist/markprompt.esm.js", 15 | "require": "./dist/index.cjs", 16 | "types": "./dist/index.d.ts" 17 | }, 18 | "type": "module", 19 | "sideEffects": false, 20 | "engines": { 21 | "node": ">=10" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/motifland/markprompt.git", 26 | "directory": "packages/markprompt-react" 27 | }, 28 | "publishConfig": { 29 | "access": "public" 30 | }, 31 | "scripts": { 32 | "prepublishOnly": "npm run build", 33 | "build": "./scripts/build.sh --external:react --external:react-dom", 34 | "clean": "rimraf ./dist" 35 | }, 36 | "peerDependencies": { 37 | "react": "^16 || ^17 || ^18", 38 | "react-dom": "^16 || ^17 || ^18" 39 | }, 40 | "devDependencies": { 41 | "@testing-library/react": "^13.0.0", 42 | "@types/react": "^17.0.43", 43 | "@types/react-dom": "^17.0.14", 44 | "classnames": "^2.3.2", 45 | "esbuild": "^0.17.8", 46 | "fast-glob": "^3.2.12", 47 | "react": "^18.0.0", 48 | "react-dom": "^18.0.0", 49 | "rimraf": "^3.0.2", 50 | "snapshot-diff": "^0.8.1", 51 | "tslib": "^2.3.1", 52 | "typescript": "^4.9.5", 53 | "@swc/core": "^1.2.131", 54 | "@swc/jest": "^0.2.17", 55 | "@testing-library/jest-dom": "^5.16.4", 56 | "@types/node": "^14.14.22", 57 | "husky": "^4.3.8", 58 | "jest": "26", 59 | "lint-staged": "^12.2.1", 60 | "npm-run-all": "^4.1.5", 61 | "prettier": "^2.6.2", 62 | "prettier-plugin-tailwindcss": "^0.1.4" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/markprompt-react/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | SCRIPT_DIR=$(cd ${0%/*} && pwd -P) 5 | 6 | # Known variables 7 | SRC='./src' 8 | DST='./dist' 9 | name="markprompt" 10 | input="./${SRC}/index.ts" 11 | 12 | # Find executables 13 | esbuild=$(yarn bin esbuild) 14 | tsc=$(yarn bin tsc) 15 | resolver="${SCRIPT_DIR}/resolve-files.js" 16 | rewriteImports="${SCRIPT_DIR}/rewrite-imports.js" 17 | 18 | # Setup shared options for esbuild 19 | sharedOptions=() 20 | sharedOptions+=("--platform=browser") 21 | sharedOptions+=("--target=es2019") 22 | 23 | # Generate actual builds 24 | # ESM 25 | resolverOptions=() 26 | resolverOptions+=($SRC) 27 | resolverOptions+=('/**/*.{ts,tsx}') 28 | resolverOptions+=('--ignore=.test.,__mocks__') 29 | INPUT_FILES=$($resolver ${resolverOptions[@]}) 30 | 31 | NODE_ENV=production $esbuild $INPUT_FILES --format=esm --outdir=$DST --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="false" ${sharedOptions[@]} & 32 | NODE_ENV=production $esbuild $input --format=esm --outfile=$DST/$name.esm.js --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="false" ${sharedOptions[@]} & 33 | 34 | # Common JS 35 | NODE_ENV=production $esbuild $input --format=cjs --outfile=$DST/$name.prod.cjs --minify --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="false" ${sharedOptions[@]} $@ & 36 | NODE_ENV=development $esbuild $input --format=cjs --outfile=$DST/$name.dev.cjs --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" --define:__DEV__="true" ${sharedOptions[@]} $@ & 37 | 38 | # Generate types 39 | tsc --emitDeclarationOnly --outDir $DST & 40 | 41 | # Copy build files over 42 | cp -rf ./build/ $DST 43 | 44 | # Wait for all the scripts to finish 45 | wait 46 | 47 | # Rewrite ESM imports 😤 48 | $rewriteImports "$DST" '/**/*.js' 49 | $rewriteImports "$DST" '/**/*.d.ts' 50 | 51 | # Remove test related files 52 | rm -rf `$resolver "$DST" '/**/*.{test,__mocks__,}.*'` 53 | rm -rf `$resolver "$DST" '/**/test-utils/*'` 54 | -------------------------------------------------------------------------------- /packages/markprompt-react/scripts/resolve-files.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import fastGlob from 'fast-glob'; 3 | 4 | let parts = process.argv.slice(2); 5 | let [args, flags] = parts.reduce( 6 | ([args, flags], part) => { 7 | if (part.startsWith('--')) { 8 | flags[part.slice(2, part.indexOf('='))] = part.slice( 9 | part.indexOf('=') + 1, 10 | ); 11 | } else { 12 | args.push(part); 13 | } 14 | return [args, flags]; 15 | }, 16 | [[], {}], 17 | ); 18 | 19 | flags.ignore = flags.ignore ?? ''; 20 | flags.ignore = flags.ignore.split(',').filter(Boolean); 21 | 22 | console.info( 23 | fastGlob 24 | .sync(args.join('')) 25 | .filter((file) => { 26 | for (let ignore of flags.ignore) { 27 | if (file.includes(ignore)) { 28 | return false; 29 | } 30 | } 31 | return true; 32 | }) 33 | .join('\n'), 34 | ); 35 | -------------------------------------------------------------------------------- /packages/markprompt-react/scripts/rewrite-imports.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import fastGlob from 'fast-glob'; 6 | 7 | console.time('Rewrote imports in'); 8 | fastGlob.sync([process.argv.slice(2).join('')]).forEach((file) => { 9 | file = path.resolve(process.cwd(), file); 10 | let content = fs.readFileSync(file, 'utf8'); 11 | let result = content.replace( 12 | /(import|export)([^"']*?)(["'])\.(.*?)\3/g, 13 | (full, a, b, _, d) => { 14 | // For idempotency reasons, if `.js` already exists, then we can skip this. This allows us to 15 | // run this script over and over again without adding .js files every time. 16 | if (d.endsWith('.js')) { 17 | return full; 18 | } 19 | 20 | return `${a}${b}'.${d}.js'`; 21 | }, 22 | ); 23 | if (result !== content) { 24 | fs.writeFileSync(file, result, 'utf8'); 25 | } 26 | }); 27 | console.timeEnd('Rewrote imports in'); 28 | -------------------------------------------------------------------------------- /packages/markprompt-react/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Markprompt'; 2 | -------------------------------------------------------------------------------- /packages/markprompt-react/src/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode, ReactElement, JSXElementConstructor } from 'react' 2 | 3 | export type ReactTag = keyof JSX.IntrinsicElements | JSXElementConstructor 4 | 5 | // A unique placeholder we can use as a default. This is nice because we can use this instead of 6 | // defaulting to null / never / ... and possibly collide with actual data. 7 | // Ideally we use a unique symbol here. 8 | let __ = '1D45E01E-AF44-47C4-988A-19A94EBAF55C' as const 9 | export type __ = typeof __ 10 | 11 | export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never 12 | 13 | export type PropsOf = TTag extends React.ElementType 14 | ? Omit, 'ref'> 15 | : never 16 | 17 | type PropsWeControl = 'as' | 'children' | 'refName' | 'className' 18 | 19 | // Resolve the props of the component, but ensure to omit certain props that we control 20 | type CleanProps = Omit< 21 | PropsOf, 22 | TOmitableProps | PropsWeControl 23 | > 24 | 25 | // Add certain props that we control 26 | type OurProps = { 27 | as?: TTag 28 | children?: ReactNode | ((bag: TSlot) => ReactElement) 29 | refName?: string 30 | } 31 | 32 | type HasProperty = T extends never 33 | ? never 34 | : K extends keyof T 35 | ? true 36 | : never 37 | 38 | // Conditionally override the `className`, to also allow for a function 39 | // if and only if the PropsOf already defines `className`. 40 | // This will allow us to have a TS error on as={Fragment} 41 | type ClassNameOverride = 42 | // Order is important here, because `never extends true` is `true`... 43 | true extends HasProperty, 'className'> 44 | ? { className?: PropsOf['className'] | ((bag: TSlot) => string) } 45 | : {} 46 | 47 | // Provide clean TypeScript props, which exposes some of our custom API's. 48 | export type Props< 49 | TTag extends ReactTag, 50 | TSlot = {}, 51 | TOmitableProps extends PropertyKey = never, 52 | Overrides = {} 53 | > = CleanProps & 54 | OurProps & 55 | ClassNameOverride & 56 | Overrides 57 | 58 | type Without = { [P in Exclude]?: never } 59 | export type XOR = T | U extends __ 60 | ? never 61 | : T extends __ 62 | ? U 63 | : U extends __ 64 | ? T 65 | : T | U extends object 66 | ? (Without & U) | (Without & T) 67 | : T | U 68 | 69 | export type ByComparator = 70 | | (T extends null ? string : keyof T & string) 71 | | ((a: T, b: T) => boolean) 72 | export type EnsureArray = T extends any[] ? T : Expand[] 73 | -------------------------------------------------------------------------------- /packages/markprompt-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext", "dom.iterable"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "downlevelIteration": true, 16 | "moduleResolution": "node", 17 | "baseUrl": "./", 18 | "paths": { 19 | "markprompt": ["src"], 20 | "*": ["src/*", "node_modules/*"] 21 | }, 22 | "jsx": "react", 23 | "esModuleInterop": true, 24 | "target": "ESNext", 25 | "allowJs": true, 26 | "skipLibCheck": true, 27 | "forceConsistentCasingInFileNames": true, 28 | "resolveJsonModule": true, 29 | "isolatedModules": true 30 | }, 31 | "exclude": ["node_modules", "**/*.test.tsx?"] 32 | } 33 | -------------------------------------------------------------------------------- /packages/markprompt-react/types/jest.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | declare global { 4 | namespace jest { 5 | interface Matchers { 6 | toBeWithinRenderFrame(actual: number): R 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pages/[team]/[project]/[analytics].tsx: -------------------------------------------------------------------------------- 1 | import { ProjectSettingsLayout } from '@/components/layouts/ProjectSettingsLayout'; 2 | import { Tag } from '@/components/ui/Tag'; 3 | 4 | // This is a hack. Currently, something weird is going on with pages 5 | // named "analytics", probably ad-blockers blocking loading resources. 6 | // This trick fixes it. 7 | const Analytics = () => { 8 | return ( 9 | 13 | Analytics{' '} 14 | 15 | Soon 16 | 17 |
    18 | } 19 | > 20 | Analytics 21 | 22 | ); 23 | }; 24 | 25 | export default Analytics; 26 | -------------------------------------------------------------------------------- /pages/[team]/[project]/index.tsx: -------------------------------------------------------------------------------- 1 | import { AnalyticsExample } from '@/components/examples/analytics'; 2 | import { ProjectSettingsLayout } from '@/components/layouts/ProjectSettingsLayout'; 3 | import Onboarding from '@/components/onboarding/Onboarding'; 4 | import { Tag } from '@/components/ui/Tag'; 5 | import useUser from '@/lib/hooks/use-user'; 6 | 7 | const Project = () => { 8 | const { user, loading: loadingUser } = useUser(); 9 | 10 | if (!loadingUser && !user?.has_completed_onboarding) { 11 | return ; 12 | } 13 | 14 | if (loadingUser) { 15 | return <>; 16 | } 17 | 18 | return ( 19 | 23 | Dashboard{' '} 24 | 25 | Soon 26 | 27 |
    28 | } 29 | > 30 |
    31 | 32 |
    33 | 34 | ); 35 | }; 36 | 37 | export default Project; 38 | -------------------------------------------------------------------------------- /pages/[team]/[project]/playground.tsx: -------------------------------------------------------------------------------- 1 | import { ProjectSettingsLayout } from '@/components/layouts/ProjectSettingsLayout'; 2 | import { Playground } from '@/components/files/Playground'; 3 | 4 | const PlaygroundPage = () => { 5 | return ( 6 | 7 |
    8 | 9 |
    10 |
    11 | ); 12 | }; 13 | 14 | export default PlaygroundPage; 15 | -------------------------------------------------------------------------------- /pages/[team]/[project]/usage.tsx: -------------------------------------------------------------------------------- 1 | import { ProjectSettingsLayout } from '@/components/layouts/ProjectSettingsLayout'; 2 | 3 | const Usage = () => { 4 | return asd; 5 | }; 6 | 7 | export default Usage; 8 | -------------------------------------------------------------------------------- /pages/[team]/index.tsx: -------------------------------------------------------------------------------- 1 | import { TeamSettingsLayout } from '@/components/layouts/TeamSettingsLayout'; 2 | import useProjects from '@/lib/hooks/use-projects'; 3 | import useTeam from '@/lib/hooks/use-team'; 4 | import Link from 'next/link'; 5 | import Button from '@/components/ui/Button'; 6 | 7 | const Team = () => { 8 | const { team } = useTeam(); 9 | const { projects } = useProjects(); 10 | 11 | return ( 12 | ( 15 | <> 16 | {team?.slug && ( 17 | 24 | )} 25 | 26 | )} 27 | > 28 |
    29 | {team && 30 | projects?.map((project) => ( 31 | 35 |
    36 | {project.name} 37 |
    38 | 39 | ))} 40 |
    41 |
    42 | ); 43 | }; 44 | 45 | export default Team; 46 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css'; 2 | 3 | import type { AppProps } from 'next/app'; 4 | import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs'; 5 | import { 6 | SessionContextProvider, 7 | useSession, 8 | } from '@supabase/auth-helpers-react'; 9 | import { ThemeProvider } from 'next-themes'; 10 | import { ReactNode, useEffect, useState } from 'react'; 11 | import { NextComponentType, NextPageContext } from 'next'; 12 | import { Toaster } from '@/components/ui/Toaster'; 13 | import { ManagedAppContext } from '@/lib/context/app'; 14 | import { ManagedTrainingContext } from '@/lib/context/training'; 15 | import * as Fathom from 'fathom-client'; 16 | import { useRouter } from 'next/router'; 17 | import { PlainProvider } from '@team-plain/react-chat-ui'; 18 | import { ChatWindow, plainTheme } from '@/components/user/ChatWindow'; 19 | import { getHost } from '@/lib/utils'; 20 | 21 | interface CustomAppProps

    extends AppProps

    { 22 | Component: NextComponentType & { 23 | getLayout?: (page: ReactNode) => JSX.Element; 24 | title?: string; 25 | }; 26 | } 27 | 28 | const getCustomerJwt = async () => { 29 | return fetch('/api/user/jwt') 30 | .then((res) => res.json()) 31 | .then((res) => res.customerJwt); 32 | }; 33 | 34 | export default function App({ Component, pageProps }: CustomAppProps) { 35 | const router = useRouter(); 36 | const [supabase] = useState(() => createBrowserSupabaseClient()); 37 | 38 | useEffect(() => { 39 | const origin = getHost(); 40 | if (!process.env.NEXT_PUBLIC_FATHOM_SITE_ID || !origin) { 41 | return; 42 | } 43 | 44 | Fathom.load(process.env.NEXT_PUBLIC_FATHOM_SITE_ID, { 45 | includedDomains: [origin], 46 | }); 47 | 48 | function onRouteChangeComplete() { 49 | Fathom.trackPageview(); 50 | } 51 | router.events.on('routeChangeComplete', onRouteChangeComplete); 52 | 53 | return () => { 54 | router.events.off('routeChangeComplete', onRouteChangeComplete); 55 | }; 56 | // eslint-disable-next-line react-hooks/exhaustive-deps 57 | }, []); 58 | 59 | return ( 60 | <> 61 | 62 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | ); 78 | } 79 | 80 | export const ManagedPlainProvider = ({ children }: { children: ReactNode }) => { 81 | const session = useSession(); 82 | 83 | return ( 84 | 93 | {children} 94 | 95 | 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 13 | 17 | 18 | 19 | 20 | 21 |

    22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /pages/api/openai/train-file.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Project, ProjectChecksums, FileData } from '@/types/types'; 3 | import { generateFileEmbeddings } from '@/lib/generate-embeddings'; 4 | import { getProjectChecksumsKey, safeGetObject } from '@/lib/redis'; 5 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 6 | import { Database } from '@/types/supabase'; 7 | import { createHash } from 'crypto'; 8 | import { 9 | checkEmbeddingsRateLimits, 10 | getEmbeddingsRateLimitResponse, 11 | } from '@/lib/rate-limits'; 12 | 13 | type Data = { 14 | status?: string; 15 | error?: string; 16 | errors?: any[]; 17 | }; 18 | 19 | export default async function handler( 20 | req: NextApiRequest, 21 | res: NextApiResponse, 22 | ) { 23 | if (req.method !== 'POST') { 24 | res.setHeader('Allow', ['POST']); 25 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 26 | } 27 | 28 | const supabase = createServerSupabaseClient({ req, res }); 29 | const { 30 | data: { session }, 31 | } = await supabase.auth.getSession(); 32 | 33 | if (!session?.user) { 34 | return res.status(401).json({ error: 'Unauthorized' }); 35 | } 36 | 37 | const file = req.body.file as FileData; 38 | const projectId = req.body.projectId as Project['id']; 39 | 40 | if (!req.body.forceRetrain && projectId) { 41 | const checksums = await safeGetObject( 42 | getProjectChecksumsKey(projectId), 43 | {}, 44 | ); 45 | const previousChecksum = checksums[file.path]; 46 | const currentChecksum = createHash('sha256') 47 | .update(file.content) 48 | .digest('base64'); 49 | if (previousChecksum === currentChecksum) { 50 | return res.status(200).json({ status: 'Already processed' }); 51 | } 52 | } 53 | 54 | // Apply rate limits 55 | const rateLimitResult = await checkEmbeddingsRateLimits({ 56 | type: 'projectId', 57 | value: projectId, 58 | }); 59 | 60 | res.setHeader('X-RateLimit-Limit', rateLimitResult.result.limit); 61 | res.setHeader('X-RateLimit-Remaining', rateLimitResult.result.remaining); 62 | 63 | if (!rateLimitResult.result.success) { 64 | console.error('[TRAIN] [RATE-LIMIT]'); 65 | return res.status(429).json({ 66 | status: getEmbeddingsRateLimitResponse( 67 | rateLimitResult.hours, 68 | rateLimitResult.minutes, 69 | ), 70 | }); 71 | } 72 | 73 | const errors = await generateFileEmbeddings(supabase, projectId, file); 74 | 75 | return res.status(200).json({ status: 'ok', errors }); 76 | } 77 | -------------------------------------------------------------------------------- /pages/api/project/[id]/checksums.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { getProjectChecksumsKey, safeGetObject, set } from '@/lib/redis'; 3 | import { Project, ProjectChecksums } from '@/types/types'; 4 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 5 | import { Database } from '@/types/supabase'; 6 | 7 | type Data = { 8 | checksums?: ProjectChecksums; 9 | status?: string; 10 | error?: string; 11 | }; 12 | 13 | export const serverSetChecksums = async ( 14 | projectId: Project['id'], 15 | checksums: ProjectChecksums, 16 | ) => { 17 | await set( 18 | getProjectChecksumsKey(projectId), 19 | // TODO: For some reason, Upstash seems to treat a single 20 | // JSON.stringified object as an object, and not as a raw string. 21 | // Fetching the string subsequently and running JSON.parse will 22 | // then fail. By applying JSON.stringify twice, it is fixed. 23 | // Seems wrong, should investigate. 24 | JSON.stringify(JSON.stringify(checksums)), 25 | ); 26 | }; 27 | 28 | export const serverGetChecksums = async ( 29 | projectId: Project['id'], 30 | ): Promise => { 31 | return safeGetObject(getProjectChecksumsKey(projectId), {}); 32 | }; 33 | 34 | export default async function handler( 35 | req: NextApiRequest, 36 | res: NextApiResponse, 37 | ) { 38 | if (!req.method || !['GET', 'POST'].includes(req.method)) { 39 | res.setHeader('Allow', ['GET', 'POST']); 40 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 41 | } 42 | 43 | const supabase = createServerSupabaseClient({ req, res }); 44 | const { 45 | data: { session }, 46 | } = await supabase.auth.getSession(); 47 | if (!session?.user) { 48 | return res.status(401).json({ error: 'Unauthorized' }); 49 | } 50 | 51 | const projectId = req.query.id as Project['id']; 52 | 53 | if (req.method === 'POST') { 54 | if (!req.body?.checksums) { 55 | console.error('[Checksums] Missing checksums'); 56 | return res.status(400).json({ error: 'Missing checksums.' }); 57 | } 58 | await serverSetChecksums(projectId, req.body.checksums); 59 | return res.status(200).json({ status: 'ok' }); 60 | } else if (req.method === 'GET') { 61 | try { 62 | const checksums = await serverGetChecksums(projectId); 63 | console.error('[Checksums] All good'); 64 | return res.status(200).json({ checksums }); 65 | } catch (e) { 66 | return res.status(400).json({ error: `${e}` }); 67 | } 68 | } 69 | 70 | return res.status(200).json({ status: 'ok' }); 71 | } 72 | -------------------------------------------------------------------------------- /pages/api/project/[id]/domains.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Domain, Project } from '@/types/types'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | 6 | type Data = 7 | | { 8 | status?: string; 9 | error?: string; 10 | } 11 | | Domain[] 12 | | Domain; 13 | 14 | const allowedMethods = ['GET', 'POST', 'DELETE']; 15 | 16 | export default async function handler( 17 | req: NextApiRequest, 18 | res: NextApiResponse, 19 | ) { 20 | if (!req.method || !allowedMethods.includes(req.method)) { 21 | res.setHeader('Allow', allowedMethods); 22 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 23 | } 24 | 25 | const supabase = createServerSupabaseClient({ req, res }); 26 | const { 27 | data: { session }, 28 | } = await supabase.auth.getSession(); 29 | 30 | if (!session?.user) { 31 | return res.status(401).json({ error: 'Unauthorized' }); 32 | } 33 | 34 | const projectId = req.query.id as Project['id']; 35 | 36 | if (req.method === 'GET') { 37 | const { data: domains, error } = await supabase 38 | .from('domains') 39 | .select('*') 40 | .eq('project_id', projectId); 41 | 42 | if (error) { 43 | return res.status(400).json({ error: error.message }); 44 | } 45 | 46 | if (!domains) { 47 | return res.status(404).json({ error: 'No domains found.' }); 48 | } 49 | 50 | return res.status(200).json(domains); 51 | } else if (req.method === 'POST') { 52 | if (!req.body.name) { 53 | return res.status(400).json({ error: 'No domain provided.' }); 54 | } 55 | 56 | let { error, data } = await supabase 57 | .from('domains') 58 | .insert([{ project_id: projectId, name: req.body.name as string }]) 59 | .select('*') 60 | .limit(1) 61 | .maybeSingle(); 62 | 63 | if (error) { 64 | throw error; 65 | } 66 | 67 | if (!data) { 68 | return res.status(400).json({ error: 'Error adding domain.' }); 69 | } 70 | return res.status(200).json(data); 71 | } else if (req.method === 'DELETE') { 72 | const { error } = await supabase 73 | .from('domains') 74 | .delete() 75 | .eq('id', req.body.id); 76 | if (error) { 77 | return res.status(400).json({ error: error.message }); 78 | } 79 | res.status(200).end(); 80 | } 81 | 82 | return res.status(400).end(); 83 | } 84 | -------------------------------------------------------------------------------- /pages/api/project/[id]/files.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { DbFile, Project, ProjectChecksums } from '@/types/types'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | import { serverGetChecksums, serverSetChecksums } from './checksums'; 6 | 7 | type Data = 8 | | { 9 | status?: string; 10 | error?: string; 11 | } 12 | | DbFile[]; 13 | 14 | const allowedMethods = ['GET', 'DELETE']; 15 | 16 | export default async function handler( 17 | req: NextApiRequest, 18 | res: NextApiResponse, 19 | ) { 20 | if (!req.method || !allowedMethods.includes(req.method)) { 21 | res.setHeader('Allow', allowedMethods); 22 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 23 | } 24 | 25 | const supabase = createServerSupabaseClient({ req, res }); 26 | const { 27 | data: { session }, 28 | } = await supabase.auth.getSession(); 29 | 30 | if (!session?.user) { 31 | return res.status(401).json({ error: 'Unauthorized' }); 32 | } 33 | 34 | const projectId = req.query.id as Project['id']; 35 | 36 | if (req.method === 'GET') { 37 | const { data: files, error } = await supabase 38 | .from('files') 39 | .select('*') 40 | .eq('project_id', projectId); 41 | 42 | if (error) { 43 | return res.status(400).json({ error: error.message }); 44 | } 45 | 46 | if (!files) { 47 | return res.status(404).json({ error: 'No files found' }); 48 | } 49 | 50 | return res.status(200).json(files); 51 | } else if (req.method === 'DELETE') { 52 | let deletedPaths: string[] = []; 53 | if (req.body) { 54 | const ids = req.body; 55 | const dbRes = await supabase 56 | .from('files') 57 | .delete() 58 | .in('id', ids) 59 | .select('path'); 60 | if (dbRes.error) { 61 | return res.status(400).json({ error: dbRes.error.message }); 62 | } 63 | deletedPaths = dbRes.data.map((d) => d.path); 64 | } else { 65 | const dbRes = await supabase 66 | .from('files') 67 | .delete() 68 | .eq('project_id', projectId) 69 | .select('path'); 70 | if (dbRes.error) { 71 | return res.status(400).json({ error: dbRes.error.message }); 72 | } 73 | deletedPaths = dbRes.data.map((d) => d.path); 74 | } 75 | 76 | // Delete associated checksums 77 | const oldChecksums = await serverGetChecksums(projectId); 78 | const newChecksums: ProjectChecksums = {}; 79 | for (const key of Object.keys(oldChecksums)) { 80 | if (!deletedPaths.includes(key)) { 81 | newChecksums[key] = oldChecksums[key]; 82 | } 83 | } 84 | await serverSetChecksums(projectId, newChecksums); 85 | 86 | return res.status(200).json({ status: 'ok' }); 87 | } 88 | 89 | return res.status(400).end(); 90 | } 91 | -------------------------------------------------------------------------------- /pages/api/project/[id]/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Project, Team } from '@/types/types'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | 6 | type Data = 7 | | { 8 | status?: string; 9 | error?: string; 10 | } 11 | | Project; 12 | 13 | const allowedMethods = ['GET', 'PATCH', 'DELETE']; 14 | 15 | export default async function handler( 16 | req: NextApiRequest, 17 | res: NextApiResponse, 18 | ) { 19 | if (!req.method || !allowedMethods.includes(req.method)) { 20 | res.setHeader('Allow', allowedMethods); 21 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 22 | } 23 | 24 | const supabase = createServerSupabaseClient({ req, res }); 25 | const { 26 | data: { session }, 27 | } = await supabase.auth.getSession(); 28 | 29 | if (!session?.user) { 30 | return res.status(401).json({ error: 'Unauthorized' }); 31 | } 32 | 33 | const projectId = req.query.id as Project['id']; 34 | 35 | if (req.method === 'GET') { 36 | const { data: project, error } = await supabase 37 | .from('projects') 38 | .select('*') 39 | .eq('id', projectId) 40 | .limit(1) 41 | .maybeSingle(); 42 | 43 | if (error) { 44 | return res.status(400).json({ error: error.message }); 45 | } 46 | 47 | if (!project) { 48 | return res.status(404).json({ error: 'Project not found' }); 49 | } 50 | 51 | return res.status(200).json(project); 52 | } else if (req.method === 'PATCH') { 53 | const { data: project, error } = await supabase 54 | .from('projects') 55 | .update(req.body) 56 | .eq('id', projectId) 57 | .select('*') 58 | .maybeSingle(); 59 | 60 | if (error) { 61 | return res.status(400).json({ error: error.message }); 62 | } 63 | 64 | if (!project) { 65 | return res.status(404).json({ error: 'Project not found' }); 66 | } 67 | 68 | return res.status(200).json(project); 69 | } else if (req.method === 'DELETE') { 70 | const { error } = await supabase 71 | .from('projects') 72 | .delete() 73 | .eq('id', projectId); 74 | 75 | if (error) { 76 | return res.status(400).json({ error: error.message }); 77 | } 78 | 79 | return res.status(200).json({ status: 'ok' }); 80 | } 81 | 82 | return res.status(400).end(); 83 | } 84 | -------------------------------------------------------------------------------- /pages/api/project/[id]/tokens.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Project, Token } from '@/types/types'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | import { generateKey } from '@/lib/utils'; 6 | 7 | type Data = 8 | | { 9 | status?: string; 10 | error?: string; 11 | } 12 | | Token[] 13 | | Token; 14 | 15 | const allowedMethods = ['GET', 'POST', 'DELETE']; 16 | 17 | export default async function handler( 18 | req: NextApiRequest, 19 | res: NextApiResponse, 20 | ) { 21 | if (!req.method || !allowedMethods.includes(req.method)) { 22 | res.setHeader('Allow', allowedMethods); 23 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 24 | } 25 | 26 | const supabase = createServerSupabaseClient({ req, res }); 27 | const { 28 | data: { session }, 29 | } = await supabase.auth.getSession(); 30 | 31 | if (!session?.user) { 32 | return res.status(401).json({ error: 'Unauthorized' }); 33 | } 34 | 35 | const projectId = req.query.id as Project['id']; 36 | 37 | if (req.method === 'GET') { 38 | const { data: tokens, error } = await supabase 39 | .from('tokens') 40 | .select('*') 41 | .eq('project_id', projectId); 42 | 43 | if (error) { 44 | return res.status(400).json({ error: error.message }); 45 | } 46 | 47 | if (!tokens) { 48 | return res.status(404).json({ error: 'No tokens found.' }); 49 | } 50 | 51 | return res.status(200).json(tokens); 52 | } else if (req.method === 'POST') { 53 | if (!req.body.projectId) { 54 | return res.status(400).json({ error: 'No domain provided.' }); 55 | } 56 | 57 | const value = generateKey(); 58 | let { error, data } = await supabase 59 | .from('tokens') 60 | .insert([ 61 | { 62 | value, 63 | project_id: projectId, 64 | created_by: session.user.id, 65 | }, 66 | ]) 67 | .select('*') 68 | .limit(1) 69 | .maybeSingle(); 70 | 71 | if (error) { 72 | throw error; 73 | } 74 | 75 | if (!data) { 76 | return res.status(400).json({ error: 'Error generating token.' }); 77 | } 78 | return res.status(200).json(data); 79 | } else if (req.method === 'DELETE') { 80 | const { error } = await supabase 81 | .from('tokens') 82 | .delete() 83 | .eq('id', req.body.id); 84 | if (error) { 85 | return res.status(400).json({ error: error.message }); 86 | } 87 | res.status(200).end(); 88 | } 89 | 90 | return res.status(400).end(); 91 | } 92 | -------------------------------------------------------------------------------- /pages/api/slug/generate-project-slug.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Team } from '@/types/types'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | import { SupabaseClient } from '@supabase/auth-helpers-react'; 6 | import { isProjectSlugAvailable } from './is-project-slug-available'; 7 | import { generateRandomSlug } from '@/lib/utils'; 8 | 9 | type Data = 10 | | { 11 | status?: string; 12 | error?: string; 13 | } 14 | | string; 15 | 16 | const allowedMethods = ['POST']; 17 | 18 | export const getAvailableProjectSlug = async ( 19 | supabase: SupabaseClient, 20 | teamId: Team['id'], 21 | baseSlug: string | undefined, 22 | ) => { 23 | // If no slug is provided, generate a random one 24 | let candidateSlug: string; 25 | if (baseSlug) { 26 | candidateSlug = baseSlug; 27 | } else { 28 | candidateSlug = generateRandomSlug(); 29 | } 30 | 31 | let attempt = 0; 32 | while (true) { 33 | const isAvailable = await isProjectSlugAvailable( 34 | supabase, 35 | teamId, 36 | candidateSlug, 37 | ); 38 | if (isAvailable) { 39 | return candidateSlug; 40 | } 41 | attempt++; 42 | candidateSlug = `${baseSlug}-${attempt}`; 43 | } 44 | }; 45 | 46 | export default async function handler( 47 | req: NextApiRequest, 48 | res: NextApiResponse, 49 | ) { 50 | if (!req.method || !allowedMethods.includes(req.method)) { 51 | res.setHeader('Allow', allowedMethods); 52 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 53 | } 54 | 55 | const supabase = createServerSupabaseClient({ req, res }); 56 | const { 57 | data: { session }, 58 | } = await supabase.auth.getSession(); 59 | 60 | if (!session?.user) { 61 | return res.status(401).json({ error: 'Unauthorized' }); 62 | } 63 | 64 | const candidate = req.body.candidate; 65 | const teamId = req.body.teamId; 66 | const slug = await getAvailableProjectSlug(supabase, teamId, candidate); 67 | 68 | return res.status(200).json(slug); 69 | } 70 | -------------------------------------------------------------------------------- /pages/api/slug/generate-team-slug.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 3 | import { Database } from '@/types/supabase'; 4 | import { SupabaseClient } from '@supabase/auth-helpers-react'; 5 | 6 | type Data = 7 | | { 8 | status?: string; 9 | error?: string; 10 | } 11 | | string; 12 | 13 | const allowedMethods = ['POST']; 14 | 15 | const RESERVED_SLUGS = ['settings', 'legal', 'docs']; 16 | 17 | export const isTeamSlugAvailable = async ( 18 | supabase: SupabaseClient, 19 | slug: string, 20 | ) => { 21 | if (RESERVED_SLUGS.includes(slug)) { 22 | return false; 23 | } 24 | let { count } = await supabase 25 | .from('teams') 26 | .select('slug', { count: 'exact' }) 27 | .eq('slug', slug); 28 | return count === 0; 29 | }; 30 | 31 | export const getAvailableTeamSlug = async ( 32 | supabase: SupabaseClient, 33 | baseSlug: string, 34 | ) => { 35 | let candidateSlug = baseSlug; 36 | let attempt = 0; 37 | while (true) { 38 | const isAvailable = await isTeamSlugAvailable(supabase, candidateSlug); 39 | if (isAvailable) { 40 | return candidateSlug; 41 | } 42 | attempt++; 43 | candidateSlug = `${baseSlug}-${attempt}`; 44 | } 45 | }; 46 | 47 | export default async function handler( 48 | req: NextApiRequest, 49 | res: NextApiResponse, 50 | ) { 51 | if (!req.method || !allowedMethods.includes(req.method)) { 52 | res.setHeader('Allow', allowedMethods); 53 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 54 | } 55 | 56 | const supabase = createServerSupabaseClient({ req, res }); 57 | const { 58 | data: { session }, 59 | } = await supabase.auth.getSession(); 60 | 61 | if (!session?.user) { 62 | return res.status(401).json({ error: 'Unauthorized' }); 63 | } 64 | 65 | const candidate = req.body.candidate; 66 | const slug = await getAvailableTeamSlug(supabase, candidate); 67 | 68 | return res.status(200).json(slug); 69 | } 70 | -------------------------------------------------------------------------------- /pages/api/slug/is-project-slug-available.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Team } from '@/types/types'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | import { SupabaseClient } from '@supabase/auth-helpers-react'; 6 | 7 | type Data = 8 | | { 9 | status?: string; 10 | error?: string; 11 | } 12 | | boolean; 13 | 14 | const allowedMethods = ['POST']; 15 | 16 | export const isProjectSlugAvailable = async ( 17 | supabase: SupabaseClient, 18 | teamId: Team['id'], 19 | slug: string, 20 | ) => { 21 | let { count } = await supabase 22 | .from('projects') 23 | .select('slug', { count: 'exact' }) 24 | .match({ team_id: teamId, slug: slug }); 25 | return count === 0; 26 | }; 27 | 28 | export default async function handler( 29 | req: NextApiRequest, 30 | res: NextApiResponse, 31 | ) { 32 | if (!req.method || !allowedMethods.includes(req.method)) { 33 | res.setHeader('Allow', allowedMethods); 34 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 35 | } 36 | 37 | const supabase = createServerSupabaseClient({ req, res }); 38 | const { 39 | data: { session }, 40 | } = await supabase.auth.getSession(); 41 | 42 | if (!session?.user) { 43 | return res.status(401).json({ error: 'Unauthorized' }); 44 | } 45 | 46 | const isAvailable = await isProjectSlugAvailable( 47 | supabase, 48 | req.body.teamId, 49 | req.body.slug, 50 | ); 51 | 52 | return res.status(200).json(isAvailable); 53 | } 54 | -------------------------------------------------------------------------------- /pages/api/slug/is-team-slug-available.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 3 | import { Database } from '@/types/supabase'; 4 | import { isTeamSlugAvailable } from './generate-team-slug'; 5 | 6 | type Data = 7 | | { 8 | status?: string; 9 | error?: string; 10 | } 11 | | boolean; 12 | 13 | const allowedMethods = ['POST']; 14 | 15 | export default async function handler( 16 | req: NextApiRequest, 17 | res: NextApiResponse, 18 | ) { 19 | if (!req.method || !allowedMethods.includes(req.method)) { 20 | res.setHeader('Allow', allowedMethods); 21 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 22 | } 23 | 24 | const supabase = createServerSupabaseClient({ req, res }); 25 | const { 26 | data: { session }, 27 | } = await supabase.auth.getSession(); 28 | 29 | if (!session?.user) { 30 | return res.status(401).json({ error: 'Unauthorized' }); 31 | } 32 | 33 | const isAvailable = await isTeamSlugAvailable(supabase, req.body.slug); 34 | 35 | return res.status(200).json(isAvailable); 36 | } 37 | -------------------------------------------------------------------------------- /pages/api/subscriptions/cancel.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 3 | import { Database } from '@/types/supabase'; 4 | import { stripe } from '@/lib/stripe/server'; 5 | import { createClient } from '@supabase/supabase-js'; 6 | 7 | type Data = { 8 | status?: string; 9 | error?: string; 10 | }; 11 | 12 | const allowedMethods = ['POST']; 13 | 14 | export default async function handler( 15 | req: NextApiRequest, 16 | res: NextApiResponse, 17 | ) { 18 | if (!req.method || !allowedMethods.includes(req.method)) { 19 | res.setHeader('Allow', allowedMethods); 20 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 21 | } 22 | 23 | const supabase = createServerSupabaseClient({ req, res }); 24 | const { 25 | data: { session }, 26 | } = await supabase.auth.getSession(); 27 | 28 | if (!session?.user) { 29 | return res.status(401).json({ error: 'Unauthorized' }); 30 | } 31 | 32 | const { data, error } = await supabase 33 | .from('teams') 34 | .select('stripe_customer_id') 35 | .eq('id', req.body.teamId) 36 | .maybeSingle(); 37 | 38 | if (error || !data?.stripe_customer_id) { 39 | return res.status(400).json({ error: 'Customer not found.' }); 40 | } 41 | 42 | const subscription = await stripe.subscriptions.list({ 43 | customer: data.stripe_customer_id, 44 | limit: 1, 45 | }); 46 | 47 | const subscriptionId = subscription.data[0].id; 48 | if (!subscriptionId) { 49 | return res.status(400).json({ error: 'No subscription found.' }); 50 | } 51 | 52 | const deleted = await stripe.subscriptions.del(subscriptionId); 53 | 54 | if (deleted?.id) { 55 | // We can safely assume the subscription was cancelled, no 56 | // need to wait for the webhook to trigger. 57 | await supabase 58 | .from('teams') 59 | .update({ 60 | stripe_customer_id: null, 61 | stripe_price_id: null, 62 | billing_cycle_start: null, 63 | }) 64 | .eq('stripe_customer_id', data.stripe_customer_id); 65 | 66 | return res.status(200).end(); 67 | } 68 | 69 | return res.status(400).json({ error: 'Unable to cancel subscription.' }); 70 | } 71 | -------------------------------------------------------------------------------- /pages/api/subscriptions/create-checkout-session.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 3 | import { Database } from '@/types/supabase'; 4 | import { stripe } from '@/lib/stripe/server'; 5 | import { getOrigin } from '@/lib/utils'; 6 | 7 | type Data = 8 | | { 9 | status?: string; 10 | error?: string; 11 | } 12 | | { sessionId: string }; 13 | 14 | const allowedMethods = ['POST']; 15 | 16 | export default async function handler( 17 | req: NextApiRequest, 18 | res: NextApiResponse, 19 | ) { 20 | if (!req.method || !allowedMethods.includes(req.method)) { 21 | res.setHeader('Allow', allowedMethods); 22 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 23 | } 24 | 25 | const supabase = createServerSupabaseClient({ req, res }); 26 | const { 27 | data: { session }, 28 | } = await supabase.auth.getSession(); 29 | 30 | if (!session?.user) { 31 | return res.status(401).json({ error: 'Unauthorized' }); 32 | } 33 | 34 | const redirect = `${getOrigin()}/${req.body.redirect}`; 35 | 36 | const stripeSession = await stripe.checkout.sessions.create({ 37 | customer_email: session.user.email, 38 | payment_method_types: ['card'], 39 | billing_address_collection: 'required', 40 | success_url: redirect, 41 | cancel_url: redirect, 42 | line_items: [{ price: req.body.priceId, quantity: 1 }], 43 | mode: 'subscription', 44 | client_reference_id: req.body.teamId, 45 | metadata: { appId: process.env.NEXT_PUBLIC_STRIPE_APP_ID || '' }, 46 | }); 47 | 48 | return res.status(200).json({ sessionId: stripeSession.id }); 49 | } 50 | -------------------------------------------------------------------------------- /pages/api/subscriptions/webhook.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import Stripe from 'stripe'; 3 | import { Readable } from 'node:stream'; 4 | import { stripe } from '@/lib/stripe/server'; 5 | import { createClient } from '@supabase/supabase-js'; 6 | import { Database } from '@/types/supabase'; 7 | import { truncateMiddle } from '@/lib/utils'; 8 | 9 | export const config = { 10 | api: { 11 | bodyParser: false, 12 | }, 13 | }; 14 | 15 | const relevantEvents = new Set([ 16 | 'checkout.session.completed', 17 | 'customer.subscription.updated', 18 | 'customer.subscription.deleted', 19 | ]); 20 | 21 | const allowedMethods = ['POST']; 22 | 23 | type Data = string | { error: string } | { received: boolean }; 24 | 25 | const buffer = async (readable: Readable) => { 26 | const chunks = []; 27 | for await (const chunk of readable) { 28 | chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); 29 | } 30 | return Buffer.concat(chunks); 31 | }; 32 | 33 | // Admin access to Supabase, bypassing RLS. 34 | const supabaseAdmin = createClient( 35 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 36 | process.env.SUPABASE_SERVICE_ROLE_KEY || '', 37 | ); 38 | 39 | export default async function handler( 40 | req: NextApiRequest, 41 | res: NextApiResponse, 42 | ) { 43 | if (!req.method || !allowedMethods.includes(req.method)) { 44 | res.setHeader('Allow', allowedMethods); 45 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 46 | } 47 | 48 | const buf = await buffer(req); 49 | const sig = req.headers['stripe-signature']; 50 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; 51 | let event: Stripe.Event; 52 | 53 | try { 54 | if (!sig || !webhookSecret) { 55 | return; 56 | } 57 | 58 | event = stripe.webhooks.constructEvent(buf, sig, webhookSecret); 59 | } catch (e: any) { 60 | console.error('Subscriptions webhook error:', e.message); 61 | return res.status(400).send(`Error: ${e.message}`); 62 | } 63 | 64 | if (relevantEvents.has(event.type)) { 65 | try { 66 | // When adding a new event type here, make sure to add them to the 67 | // Stripe dashboard as well. 68 | switch (event.type) { 69 | case 'checkout.session.completed': { 70 | // When a user subscribes to a plan, attach the associated Stripe 71 | // customer ID to the team for easy subsequent retrieval. 72 | const checkoutSession = event.data.object as Stripe.Checkout.Session; 73 | if ( 74 | checkoutSession.metadata?.appId !== 75 | process.env.NEXT_PUBLIC_STRIPE_APP_ID 76 | ) { 77 | } 78 | if (!checkoutSession.customer) { 79 | throw new Error('Invalid customer.'); 80 | } 81 | // Stripe knows the team id as it's been associated to 82 | // client_reference_id during checkout. 83 | const teamId = checkoutSession.client_reference_id; 84 | 85 | const { error } = await supabaseAdmin 86 | .from('teams') 87 | .update({ 88 | stripe_customer_id: checkoutSession.customer.toString(), 89 | }) 90 | .eq('id', teamId); 91 | if (error) { 92 | console.error('Error session completed', error.message); 93 | throw new Error( 94 | `Unable to update customer in database: ${error.message}`, 95 | ); 96 | } 97 | break; 98 | } 99 | case 'customer.subscription.updated': { 100 | const subscription = event.data.object as Stripe.Subscription; 101 | const newPriceId = subscription.items.data[0].price.id; 102 | const stripeCustomerId = subscription.customer.toString(); 103 | const { error } = await supabaseAdmin 104 | .from('teams') 105 | .update({ 106 | stripe_price_id: newPriceId, 107 | billing_cycle_start: new Date().toISOString(), 108 | }) 109 | .eq('stripe_customer_id', stripeCustomerId); 110 | if (error) { 111 | throw new Error(`Error updating price: ${error.message}`); 112 | } 113 | break; 114 | } 115 | case 'customer.subscription.deleted': { 116 | const subscription = event.data.object as Stripe.Subscription; 117 | const stripeCustomerId = subscription.customer.toString(); 118 | const { error } = await supabaseAdmin 119 | .from('teams') 120 | .update({ 121 | stripe_customer_id: null, 122 | stripe_price_id: null, 123 | billing_cycle_start: null, 124 | }) 125 | .eq('stripe_customer_id', stripeCustomerId); 126 | if (error) { 127 | throw new Error(`Error deleting subscription: ${error.message}`); 128 | } 129 | break; 130 | } 131 | default: 132 | throw new Error('Unhandled event.'); 133 | } 134 | } catch (error) { 135 | console.error('Subscriptions webhook error:', error); 136 | return res 137 | .status(400) 138 | .send('Subscriptions webhook error. Check the logs.'); 139 | } 140 | } 141 | 142 | res.json({ received: true }); 143 | } 144 | -------------------------------------------------------------------------------- /pages/api/team/[id]/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Team } from '@/types/types'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | 6 | type Data = 7 | | { 8 | status?: string; 9 | error?: string; 10 | } 11 | | Team; 12 | 13 | const allowedMethods = ['GET', 'PATCH']; 14 | 15 | export default async function handler( 16 | req: NextApiRequest, 17 | res: NextApiResponse, 18 | ) { 19 | if (!req.method || !allowedMethods.includes(req.method)) { 20 | res.setHeader('Allow', allowedMethods); 21 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 22 | } 23 | 24 | const supabase = createServerSupabaseClient({ req, res }); 25 | const { 26 | data: { session }, 27 | } = await supabase.auth.getSession(); 28 | 29 | if (!session?.user) { 30 | return res.status(401).json({ error: 'Unauthorized' }); 31 | } 32 | 33 | if (req.method === 'GET') { 34 | const { data: team, error } = await supabase 35 | .from('teams') 36 | .select('*') 37 | .eq('id', req.query.id) 38 | .limit(1) 39 | .maybeSingle(); 40 | 41 | if (error) { 42 | console.error('api/team/[]', error); 43 | return res.status(400).json({ error: error.message }); 44 | } 45 | 46 | if (!team) { 47 | return res.status(404).json({ error: 'Team not found' }); 48 | } 49 | 50 | return res.status(200).json(team); 51 | } else if (req.method === 'PATCH') { 52 | const { data: team, error } = await supabase 53 | .from('teams') 54 | .update(req.body) 55 | .eq('id', req.query.id) 56 | .select('*') 57 | .maybeSingle(); 58 | 59 | if (error) { 60 | console.error('api/team/[]', error); 61 | return res.status(400).json({ error: error.message }); 62 | } 63 | 64 | if (!team) { 65 | return res.status(404).json({ error: 'Team not found' }); 66 | } 67 | 68 | return res.status(200).json(team); 69 | } 70 | 71 | return res.status(400).end(); 72 | } 73 | -------------------------------------------------------------------------------- /pages/api/team/[id]/projects.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Project, Team } from '@/types/types'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | import { getAvailableProjectSlug } from '../../slug/generate-project-slug'; 6 | import { generateKey } from '@/lib/utils'; 7 | 8 | type Data = 9 | | { 10 | status?: string; 11 | error?: string; 12 | } 13 | | Project[] 14 | | Project; 15 | 16 | const allowedMethods = ['GET', 'POST']; 17 | 18 | export default async function handler( 19 | req: NextApiRequest, 20 | res: NextApiResponse, 21 | ) { 22 | if (!req.method || !allowedMethods.includes(req.method)) { 23 | res.setHeader('Allow', allowedMethods); 24 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 25 | } 26 | 27 | const supabase = createServerSupabaseClient({ req, res }); 28 | const { 29 | data: { session }, 30 | } = await supabase.auth.getSession(); 31 | 32 | if (!session?.user) { 33 | return res.status(401).json({ error: 'Unauthorized' }); 34 | } 35 | 36 | const teamId = req.query.id as Team['id']; 37 | 38 | if (req.method === 'GET') { 39 | const { data, error } = await supabase 40 | .from('projects') 41 | .select('*') 42 | .match({ team_id: teamId }); 43 | 44 | if (error) { 45 | console.error('api/team/[]/projects:', error); 46 | return res.status(400).json({ error: error.message }); 47 | } 48 | 49 | return res.status(200).json(data || []); 50 | } else if (req.method === 'POST') { 51 | const { name, candidateSlug, githubRepo } = req.body; 52 | const slug = await getAvailableProjectSlug(supabase, teamId, candidateSlug); 53 | const public_api_key = generateKey(); 54 | let { data, error } = await supabase 55 | .from('projects') 56 | .insert([ 57 | { 58 | name, 59 | team_id: teamId, 60 | slug, 61 | github_repo: githubRepo, 62 | created_by: session.user.id, 63 | public_api_key, 64 | }, 65 | ]) 66 | .select('*') 67 | .limit(1) 68 | .maybeSingle(); 69 | 70 | if (error) { 71 | console.error('api/team/[]/projects', error); 72 | return res.status(400).json({ error: error.message }); 73 | } 74 | 75 | if (!data) { 76 | console.error('api/team/[]/projects: no data'); 77 | return res.status(400).json({ error: 'Unable to create project' }); 78 | } 79 | 80 | return res.status(200).json(data); 81 | } 82 | 83 | return res.status(200).json({ status: 'ok' }); 84 | } 85 | -------------------------------------------------------------------------------- /pages/api/teams/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Team } from '@/types/types'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | import { getAvailableTeamSlug } from '../slug/generate-team-slug'; 6 | 7 | type Data = 8 | | { 9 | status?: string; 10 | error?: string; 11 | } 12 | | Team[] 13 | | Team; 14 | 15 | const allowedMethods = ['GET', 'POST']; 16 | 17 | export default async function handler( 18 | req: NextApiRequest, 19 | res: NextApiResponse, 20 | ) { 21 | if (!req.method || !allowedMethods.includes(req.method)) { 22 | res.setHeader('Allow', allowedMethods); 23 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 24 | } 25 | 26 | const supabase = createServerSupabaseClient({ req, res }); 27 | const { 28 | data: { session }, 29 | } = await supabase.auth.getSession(); 30 | 31 | if (!session?.user) { 32 | return res.status(401).json({ error: 'Unauthorized' }); 33 | } 34 | 35 | if (req.method === 'GET') { 36 | const { data, error } = await supabase 37 | .from('memberships') 38 | .select('user_id, teams (*)') 39 | .match({ user_id: session.user.id }); 40 | 41 | if (error) { 42 | console.error('Error', error.message); 43 | return res.status(400).json({ error: error.message }); 44 | } 45 | 46 | const teams = (data?.map((d) => d.teams) || []) as Team[]; 47 | return res.status(200).json(teams); 48 | } else if (req.method === 'POST') { 49 | const { candidateSlug, isPersonal, ...rest } = req.body; 50 | const slug = await getAvailableTeamSlug(supabase, candidateSlug); 51 | let { data, error } = await supabase 52 | .from('teams') 53 | .insert([ 54 | { ...rest, is_personal: isPersonal, slug, created_by: session.user.id }, 55 | ]) 56 | .select('*') 57 | .limit(1) 58 | .maybeSingle(); 59 | 60 | if (error) { 61 | return res.status(400).json({ error: error.message }); 62 | } 63 | 64 | if (!data) { 65 | return res.status(400).json({ error: 'Unable to create team' }); 66 | } 67 | 68 | // Automatically add the creator of the team as an admin member. 69 | const { error: membershipError } = await supabase 70 | .from('memberships') 71 | .insert([{ user_id: session.user.id, team_id: data.id, type: 'admin' }]); 72 | 73 | if (membershipError) { 74 | return res.status(400).json({ error: membershipError.message }); 75 | } 76 | 77 | return res.status(200).json(data); 78 | } 79 | 80 | return res.status(200).json({ status: 'ok' }); 81 | } 82 | -------------------------------------------------------------------------------- /pages/api/user/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 3 | import { Database } from '@/types/supabase'; 4 | import { DbUser } from '@/types/types'; 5 | 6 | type Data = 7 | | { 8 | status?: string; 9 | error?: string; 10 | } 11 | | DbUser; 12 | 13 | const allowedMethods = ['GET', 'POST', 'PATCH']; 14 | 15 | export default async function handler( 16 | req: NextApiRequest, 17 | res: NextApiResponse, 18 | ) { 19 | if (!req.method || !allowedMethods.includes(req.method)) { 20 | res.setHeader('Allow', allowedMethods); 21 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 22 | } 23 | 24 | const supabase = createServerSupabaseClient({ req, res }); 25 | const { 26 | data: { session }, 27 | } = await supabase.auth.getSession(); 28 | 29 | if (!session?.user) { 30 | return res.status(403).json({ error: 'Forbidden' }); 31 | } 32 | 33 | if (req.method === 'GET') { 34 | const { data, error } = await supabase 35 | .from('users') 36 | .select('*') 37 | .eq('id', session.user.id) 38 | .limit(1) 39 | .maybeSingle(); 40 | 41 | if (error) { 42 | console.error('Error GET:', error.message); 43 | return res.status(400).json({ error: error.message }); 44 | } 45 | 46 | if (!data) { 47 | console.error('Error: user not found'); 48 | return res.status(404).json({ error: 'User not found' }); 49 | } 50 | 51 | return res.status(200).json(data); 52 | } else if (req.method === 'PATCH') { 53 | let { error } = await supabase 54 | .from('users') 55 | .update({ 56 | ...req.body, 57 | updated_at: new Date().toISOString(), 58 | }) 59 | .eq('id', session.user.id); 60 | 61 | if (error) { 62 | console.error('Error PATCH:', error.message); 63 | return res.status(400).json({ error: error.message }); 64 | } 65 | 66 | return res.status(200).json({ status: 'ok' }); 67 | } 68 | 69 | return res.status(200).json({ status: 'ok' }); 70 | } 71 | -------------------------------------------------------------------------------- /pages/api/user/init.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 3 | import { Database } from '@/types/supabase'; 4 | import slugify from '@sindresorhus/slugify'; 5 | import { generateKey, generateRandomSlug, slugFromEmail } from '@/lib/utils'; 6 | import { getAvailableTeamSlug } from '../slug/generate-team-slug'; 7 | import { Project, Team } from '@/types/types'; 8 | 9 | type Data = 10 | | { 11 | status?: string; 12 | error?: string; 13 | } 14 | | { team: Team; project: Project }; 15 | 16 | const allowedMethods = ['POST']; 17 | 18 | export default async function handler( 19 | req: NextApiRequest, 20 | res: NextApiResponse, 21 | ) { 22 | if (!req.method || !allowedMethods.includes(req.method)) { 23 | res.setHeader('Allow', allowedMethods); 24 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 25 | } 26 | 27 | const supabase = createServerSupabaseClient({ req, res }); 28 | const { 29 | data: { session }, 30 | } = await supabase.auth.getSession(); 31 | 32 | if (!session?.user) { 33 | return res.status(403).json({ error: 'Forbidden' }); 34 | } 35 | 36 | // Check if personal team already exists 37 | let { data: team } = await supabase 38 | .from('teams') 39 | .select('id') 40 | .match({ created_by: session.user.id, is_personal: true }) 41 | .limit(1) 42 | .select() 43 | .maybeSingle(); 44 | 45 | if (!team) { 46 | let candidateSlug = ''; 47 | if (session.user.user_metadata?.user_name) { 48 | candidateSlug = slugify(session.user.user_metadata?.user_name); 49 | } else if (session.user.user_metadata?.name) { 50 | candidateSlug = slugify(session.user.user_metadata?.name); 51 | } else if (session.user.email) { 52 | candidateSlug = slugFromEmail(session.user.email); 53 | } else { 54 | candidateSlug = generateRandomSlug(); 55 | } 56 | 57 | const slug = await getAvailableTeamSlug(supabase, candidateSlug); 58 | let { data, error } = await supabase 59 | .from('teams') 60 | .insert([ 61 | { 62 | name: 'Personal', 63 | is_personal: true, 64 | slug, 65 | created_by: session.user.id, 66 | }, 67 | ]) 68 | .select('*') 69 | .limit(1) 70 | .maybeSingle(); 71 | if (error) { 72 | return res.status(400).json({ error: error.message }); 73 | } 74 | 75 | team = data; 76 | } 77 | 78 | if (!team) { 79 | return res.status(400).json({ error: 'Unable to create team' }); 80 | } 81 | 82 | // Check if membership already exists 83 | const { count: membershipCount } = await supabase 84 | .from('memberships') 85 | .select('id', { count: 'exact' }) 86 | .match({ user_id: session.user.id, team_id: team.id, type: 'admin' }); 87 | 88 | if (membershipCount === 0) { 89 | // Automatically add the creator of the team as an admin member. 90 | const { error: membershipError } = await supabase 91 | .from('memberships') 92 | .insert([{ user_id: session.user.id, team_id: team.id, type: 'admin' }]); 93 | 94 | if (membershipError) { 95 | return res.status(400).json({ error: membershipError.message }); 96 | } 97 | } 98 | 99 | // Check if starter project already exists 100 | let { data: project } = await supabase 101 | .from('projects') 102 | .select('id') 103 | .match({ team_id: team.id, is_starter: true }) 104 | .limit(1) 105 | .select() 106 | .maybeSingle(); 107 | 108 | if (!project) { 109 | // Create a starter project 110 | const public_api_key = generateKey(); 111 | const { data, error: projectError } = await supabase 112 | .from('projects') 113 | .insert([ 114 | { 115 | name: 'Starter', 116 | slug: 'starter', 117 | is_starter: true, 118 | created_by: session.user.id, 119 | team_id: team.id, 120 | public_api_key, 121 | }, 122 | ]) 123 | .select('*') 124 | .limit(1) 125 | .maybeSingle(); 126 | 127 | if (projectError) { 128 | return res.status(400).json({ error: projectError.message }); 129 | } 130 | 131 | project = data; 132 | } 133 | 134 | if (!project) { 135 | return res.status(400).json({ error: 'Unable to create starter project' }); 136 | } 137 | 138 | // Check if token already exists 139 | let { count: tokenCount } = await supabase 140 | .from('tokens') 141 | .select('id', { count: 'exact' }) 142 | .match({ project_id: project.id }); 143 | 144 | if (tokenCount === 0) { 145 | const value = generateKey(); 146 | await supabase.from('tokens').insert([ 147 | { 148 | value, 149 | project_id: project.id, 150 | created_by: session.user.id, 151 | }, 152 | ]); 153 | } 154 | 155 | return res.status(200).json({ team, project }); 156 | } 157 | -------------------------------------------------------------------------------- /pages/api/user/jwt.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import jwt from 'jsonwebtoken'; 3 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Database } from '@/types/supabase'; 5 | 6 | type Data = { error?: string } | { customerJwt: string }; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | const supabase = createServerSupabaseClient({ req, res }); 13 | const { 14 | data: { session }, 15 | } = await supabase.auth.getSession(); 16 | 17 | if (!session?.user) { 18 | return res.status(401).json({ error: 'Unauthorized' }); 19 | } 20 | 21 | const customerJwt = jwt.sign( 22 | { 23 | fullName: session.user.user_metadata.full_name, 24 | shortName: session.user.user_metadata.name, 25 | email: { 26 | email: session.user.email, 27 | isVerified: false, 28 | }, 29 | externalId: session.user.email, 30 | }, 31 | process.env.PLAIN_SECRET_KEY!, 32 | { 33 | algorithm: 'RS256', 34 | expiresIn: '1h', 35 | }, 36 | ); 37 | 38 | return res.status(200).json({ customerJwt }); 39 | } 40 | -------------------------------------------------------------------------------- /pages/home.tsx: -------------------------------------------------------------------------------- 1 | import { InferGetStaticPropsType } from 'next'; 2 | import { FC } from 'react'; 3 | import LandingPage from '@/components/pages/Landing'; 4 | import { getStaticProps as _getStaticProps } from './index'; 5 | 6 | export const getStaticProps = _getStaticProps; 7 | 8 | const Home: FC> = ({ 9 | stars, 10 | }) => { 11 | return ; 12 | }; 13 | 14 | export default Home; 15 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from '@supabase/auth-helpers-react'; 2 | import { GetStaticProps, InferGetStaticPropsType } from 'next'; 3 | import { FC, memo } from 'react'; 4 | import LandingPage from '@/components/pages/Landing'; 5 | import AppPage from '@/components/pages/App'; 6 | 7 | export interface RawDomainStats { 8 | timestamp: number; 9 | } 10 | 11 | const repo = 'https://api.github.com/repos/motifland/markprompt'; 12 | 13 | export const getStaticProps: GetStaticProps = async (context) => { 14 | const res = await fetch(repo); 15 | const json = await res.json(); 16 | 17 | return { 18 | props: { stars: json.stargazers_count || 1 }, 19 | revalidate: 600, 20 | }; 21 | }; 22 | 23 | const Index: FC> = ({ 24 | stars, 25 | }) => { 26 | const session = useSession(); 27 | 28 | if (!session) { 29 | return ; 30 | } else { 31 | return ; 32 | } 33 | }; 34 | 35 | export default memo(Index); 36 | -------------------------------------------------------------------------------- /pages/login.tsx: -------------------------------------------------------------------------------- 1 | import AuthPage from '@/components/user/AuthPage'; 2 | import Head from 'next/head'; 3 | 4 | const Login = () => { 5 | return ( 6 | <> 7 | 8 | Sign in | Markprompt 9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default Login; 16 | -------------------------------------------------------------------------------- /pages/settings/[team]/projects/new.tsx: -------------------------------------------------------------------------------- 1 | import { TeamSettingsLayout } from '@/components/layouts/TeamSettingsLayout'; 2 | import Button from '@/components/ui/Button'; 3 | import { ErrorLabel } from '@/components/ui/Forms'; 4 | import { NoAutoInput } from '@/components/ui/Input'; 5 | import { createProject } from '@/lib/api'; 6 | import { isGitHubRepoAccessible } from '@/lib/github'; 7 | import useProjects from '@/lib/hooks/use-projects'; 8 | import useTeam from '@/lib/hooks/use-team'; 9 | import { showConfetti } from '@/lib/utils'; 10 | import { 11 | ErrorMessage, 12 | Field, 13 | Form, 14 | Formik, 15 | FormikErrors, 16 | FormikValues, 17 | } from 'formik'; 18 | import { useRouter } from 'next/router'; 19 | import { toast } from 'react-hot-toast'; 20 | 21 | const NewProject = () => { 22 | const router = useRouter(); 23 | const { team } = useTeam(); 24 | const { projects, mutate: mutateProjects } = useProjects(); 25 | 26 | return ( 27 | 28 |
    29 |
    30 | { 34 | let errors: FormikErrors = {}; 35 | if (!values.name) { 36 | errors.name = 'Required'; 37 | return errors; 38 | } 39 | 40 | if (values.github) { 41 | if (values.github) { 42 | const isAccessible = await isGitHubRepoAccessible( 43 | values.github, 44 | ); 45 | if (!isAccessible) { 46 | errors.github = 'Repository is not accessible'; 47 | } 48 | } 49 | return errors; 50 | } 51 | return errors; 52 | }} 53 | onSubmit={async (values, { setSubmitting }) => { 54 | if (!team) { 55 | return; 56 | } 57 | const newProject = await createProject( 58 | team.id, 59 | values.name, 60 | values.slug, 61 | values.github, 62 | ); 63 | await mutateProjects([...(projects || []), newProject]); 64 | setSubmitting(false); 65 | toast.success('Project created.'); 66 | setTimeout(() => { 67 | showConfetti(); 68 | router.replace({ 69 | pathname: '/[team]/[project]/data', 70 | query: { team: team.slug, project: newProject.slug }, 71 | }); 72 | }, 500); 73 | }} 74 | > 75 | {({ isSubmitting, isValid }) => ( 76 |
    77 |
    78 |

    79 | Name 80 |

    81 | 87 | 88 |

    89 | GitHub repo (optional) 90 |

    91 | 97 | 98 |
    99 |
    100 | 108 |
    109 |
    110 | )} 111 |
    112 |
    113 |
    114 |
    115 | ); 116 | }; 117 | 118 | export default NewProject; 119 | -------------------------------------------------------------------------------- /pages/settings/[team]/team.tsx: -------------------------------------------------------------------------------- 1 | import { TeamSettingsLayout } from '@/components/layouts/TeamSettingsLayout'; 2 | 3 | const Team = () => { 4 | return asd; 5 | }; 6 | 7 | export default Team; 8 | -------------------------------------------------------------------------------- /pages/settings/[team]/usage.tsx: -------------------------------------------------------------------------------- 1 | import { TeamSettingsLayout } from '@/components/layouts/TeamSettingsLayout'; 2 | import BarChart from '@/components/charts/bar-chart'; 3 | import { sampleVisitsData } from '@/lib/utils'; 4 | import { Tag } from '@/components/ui/Tag'; 5 | 6 | const Usage = () => { 7 | return ( 8 | 12 | Usage{' '} 13 | 14 | Soon 15 | 16 |
    17 | } 18 | > 19 | 26 | 27 | ); 28 | }; 29 | 30 | export default Usage; 31 | -------------------------------------------------------------------------------- /pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import AuthPage from '@/components/user/AuthPage'; 2 | import Head from 'next/head'; 3 | 4 | const Signup = () => { 5 | return ( 6 | <> 7 | 8 | Sign up | Markprompt 9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default Signup; 16 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | semi: true, 4 | trailingComma: 'all', 5 | singleQuote: true, 6 | // pnpm doesn't support plugin autoloading 7 | // https://github.com/tailwindlabs/prettier-plugin-tailwindcss#installation 8 | plugins: [require('prettier-plugin-tailwindcss')], 9 | }; 10 | -------------------------------------------------------------------------------- /public/static/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EdgarHnd/markprompt/4bcb3dd891274d91383b1cf01c7b5ce91007c3a4/public/static/cover.png -------------------------------------------------------------------------------- /public/static/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EdgarHnd/markprompt/4bcb3dd891274d91383b1cf01c7b5ce91007c3a4/public/static/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/static/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EdgarHnd/markprompt/4bcb3dd891274d91383b1cf01c7b5ce91007c3a4/public/static/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/static/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EdgarHnd/markprompt/4bcb3dd891274d91383b1cf01c7b5ce91007c3a4/public/static/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/static/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EdgarHnd/markprompt/4bcb3dd891274d91383b1cf01c7b5ce91007c3a4/public/static/favicons/favicon.ico -------------------------------------------------------------------------------- /public/static/favicons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EdgarHnd/markprompt/4bcb3dd891274d91383b1cf01c7b5ce91007c3a4/public/static/favicons/favicon.png -------------------------------------------------------------------------------- /public/static/favicons/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/static/favicons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#4a4a4a", 17 | "background_color": "#4a4a4a", 18 | "display": "standalone" 19 | } -------------------------------------------------------------------------------- /styles/prism.css: -------------------------------------------------------------------------------- 1 | pre { 2 | @apply rounded-md bg-transparent text-sm text-white antialiased !important; 3 | line-height: 1.7 !important; 4 | } 5 | 6 | .code-small pre { 7 | @apply p-3 text-xs antialiased !important; 8 | line-height: 1.7 !important; 9 | } 10 | 11 | .code-small-md pre { 12 | @apply p-3 text-xs antialiased md:text-sm !important; 13 | line-height: 1.7 !important; 14 | } 15 | 16 | .token.plain { 17 | @apply text-white !important; 18 | } 19 | 20 | .token.tag, 21 | .token.class-name, 22 | .token.selector, 23 | .token.selector .class, 24 | .token.function { 25 | @apply text-fuchsia-400 !important; 26 | } 27 | 28 | .token.attr-name, 29 | .token.keyword, 30 | .token.rule, 31 | .token.operator, 32 | .token.pseudo-class, 33 | .token.important { 34 | @apply text-cyan-400 !important; 35 | } 36 | 37 | .token.attr-value, 38 | .token.class, 39 | .token.string, 40 | .token.number, 41 | .token.unit, 42 | .token.color { 43 | @apply text-lime-300 !important; 44 | } 45 | 46 | .token.punctuation, 47 | .token.module, 48 | .token.property { 49 | @apply text-sky-200 !important; 50 | } 51 | 52 | .token.atapply .token:not(.rule):not(.important) { 53 | color: inherit; 54 | } 55 | 56 | .language-shell .token:not(.comment) { 57 | color: inherit; 58 | } 59 | 60 | .language-css .token.function { 61 | color: inherit; 62 | } 63 | 64 | .token.comment { 65 | @apply text-neutral-400 !important; 66 | } 67 | 68 | .token.deleted:not(.prefix) { 69 | @apply relative -mx-4 block px-4 !important; 70 | } 71 | 72 | .token.deleted:not(.prefix)::after { 73 | content: ''; 74 | @apply pointer-events-none absolute inset-0 block bg-rose-400 bg-opacity-25 !important; 75 | } 76 | 77 | .token.deleted.prefix { 78 | @apply select-none text-neutral-400 !important; 79 | } 80 | 81 | .token.inserted:not(.prefix) { 82 | @apply -mx-4 block bg-emerald-700 bg-opacity-50 px-4 !important; 83 | } 84 | 85 | .token.inserted.prefix { 86 | @apply select-none text-emerald-200 text-opacity-75 !important; 87 | } 88 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors'); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: 'class', 6 | content: [ 7 | './app/**/*.{js,ts,jsx,tsx}', 8 | './pages/**/*.{js,ts,jsx,tsx}', 9 | './components/**/*.{js,ts,jsx,tsx}', 10 | './src/**/*.{js,ts,jsx,tsx}', 11 | ], 12 | theme: { 13 | extend: { 14 | colors: { 15 | primary: colors.fuchsia, 16 | neutral: { 17 | 1000: '#0E0E0E', 18 | 1100: '#050505', 19 | }, 20 | }, 21 | }, 22 | }, 23 | plugins: [require('@tailwindcss/typography'), require('@tailwindcss/forms')], 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "allowSyntheticDefaultImports": true, 18 | "paths": { 19 | "@/*": ["./*"] 20 | }, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ] 26 | }, 27 | "include": [ 28 | "next-env.d.ts", 29 | "**/*.ts", 30 | "**/*.tsx", 31 | ".next/types/**/*.ts", 32 | "schema/tables.sql" 33 | ], 34 | "exclude": ["node_modules"] 35 | } 36 | -------------------------------------------------------------------------------- /types/types.ts: -------------------------------------------------------------------------------- 1 | import { Database } from './supabase'; 2 | 3 | export type TimeInterval = '1h' | '24h' | '7d' | '30d' | '3m' | '1y'; 4 | export type TimePeriod = 'hour' | 'day' | 'weekofyear' | 'month' | 'year'; 5 | export type HistogramStat = { start: number; end: number; value: number }; 6 | 7 | export type OpenAIModel = 8 | | { type: 'chat_completions'; value: OpenAIChatCompletionsModel } 9 | | { type: 'completions'; value: OpenAICompletionsModel }; 10 | 11 | export type OpenAIChatCompletionsModel = 12 | | 'gpt-4' 13 | | 'gpt-4-0314' 14 | | 'gpt-4-32k' 15 | | 'gpt-4-32k-0314' 16 | | 'gpt-3.5-turbo' 17 | | 'gpt-3.5-turbo-0301'; 18 | 19 | type OpenAICompletionsModel = 20 | | 'text-davinci-003' 21 | | 'text-davinci-002' 22 | | 'text-curie-001' 23 | | 'text-babbage-001' 24 | | 'text-ada-001' 25 | | 'davinci' 26 | | 'curie' 27 | | 'babbage' 28 | | 'ada'; 29 | 30 | export type DbUser = Database['public']['Tables']['users']['Row']; 31 | export type Team = Database['public']['Tables']['teams']['Row']; 32 | export type Project = Database['public']['Tables']['projects']['Row']; 33 | export type Token = Database['public']['Tables']['tokens']['Row']; 34 | export type Domain = Database['public']['Tables']['domains']['Row']; 35 | export type Membership = Database['public']['Tables']['memberships']['Row']; 36 | export type MembershipType = 37 | Database['public']['Tables']['memberships']['Row']['type']; 38 | export type DbFile = Database['public']['Tables']['files']['Row']; 39 | export type FileSections = Database['public']['Tables']['file_sections']['Row']; 40 | 41 | export type FileData = { path: string; name: string; content: string }; 42 | export type ProjectChecksums = Record; 43 | --------------------------------------------------------------------------------