├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── LICENSE.md ├── README.md ├── components ├── home │ ├── card.tsx │ ├── component-grid.tsx │ ├── demo-modal.tsx │ └── web-vitals.tsx ├── layout │ ├── index.tsx │ ├── meta.tsx │ ├── sign-in-modal.tsx │ └── user-dropdown.tsx └── shared │ ├── counting-numbers.tsx │ ├── icons │ ├── expanding-arrow.tsx │ ├── github.tsx │ ├── google.tsx │ ├── index.tsx │ ├── link.tsx │ ├── loading-circle.tsx │ ├── loading-dots.module.css │ ├── loading-dots.tsx │ ├── loading-spinner.module.css │ ├── loading-spinner.tsx │ └── twitter.tsx │ ├── leaflet.tsx │ ├── modal.tsx │ ├── popover.tsx │ └── tooltip.tsx ├── lib ├── constants.ts ├── hooks │ ├── use-intersection-observer.ts │ ├── use-local-storage.ts │ ├── use-scroll.ts │ └── use-window-size.ts ├── prisma.ts └── utils.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── api │ ├── analyze.ts │ ├── auth │ │ └── [...nextauth].ts │ └── og.tsx └── index.tsx ├── postcss.config.js ├── prettier.config.js ├── prisma └── schema.prisma ├── public ├── authjs.webp ├── favicon.ico ├── logo.png ├── next.svg ├── prisma.svg ├── thirteen.svg ├── vercel-logotype.svg └── vercel.svg ├── screenshot.png ├── styles ├── SF-Pro-Display-Medium.otf └── globals.css ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | .idea/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | node_modules 3 | .next 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Steven Tey 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 | # [Read Pilot](https://readpilot.vercel.app/) 2 | 3 | Read Pilot analyzes online articles and generate Q&A cards for you. 4 | 5 | ![](./screenshot.png) 6 | 7 | ## Star History 8 | 9 | [![Star History Chart](https://api.star-history.com/svg?repos=forrestchang/readpilot&type=Date)](https://star-history.com/#forrestchang/readpilot&Date) -------------------------------------------------------------------------------- /components/home/card.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import ReactMarkdown from "react-markdown"; 3 | import Balancer from "react-wrap-balancer"; 4 | 5 | export default function Card({ 6 | title, 7 | description, 8 | }: { 9 | title: string; 10 | description: string; 11 | }) { 12 | return ( 13 |
14 |
15 |

16 | {title} 17 |

18 |
19 | 20 | ( 23 | 29 | ), 30 | code: ({ node, ...props }) => ( 31 | 37 | ), 38 | }} 39 | > 40 | {description} 41 | 42 | 43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/home/component-grid.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useDemoModal } from "@/components/home/demo-modal"; 3 | import Popover from "@/components/shared/popover"; 4 | import Tooltip from "@/components/shared/tooltip"; 5 | import { ChevronDown } from "lucide-react"; 6 | 7 | export default function ComponentGrid() { 8 | const { DemoModal, setShowDemoModal } = useDemoModal(); 9 | const [openPopover, setOpenPopover] = useState(false); 10 | return ( 11 |
12 | 13 | 19 | 22 | 25 | 28 | 31 |
32 | } 33 | openPopover={openPopover} 34 | setOpenPopover={setOpenPopover} 35 | > 36 | 47 | 48 | 49 |
50 |

Tooltip

51 |
52 |
53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /components/home/demo-modal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "@/components/shared/modal"; 2 | import { 3 | useState, 4 | Dispatch, 5 | SetStateAction, 6 | useCallback, 7 | useMemo, 8 | } from "react"; 9 | import Image from "next/image"; 10 | 11 | const DemoModal = ({ 12 | showDemoModal, 13 | setShowDemoModal, 14 | }: { 15 | showDemoModal: boolean; 16 | setShowDemoModal: Dispatch>; 17 | }) => { 18 | return ( 19 | 20 |
21 |
22 | 23 | Precedent Logo 30 | 31 |

Precedent

32 |

33 | Precedent is an opinionated collection of components, hooks, and 34 | utilities for your Next.js project. 35 |

36 |
37 |
38 | 39 | ); 40 | }; 41 | 42 | export function useDemoModal() { 43 | const [showDemoModal, setShowDemoModal] = useState(false); 44 | 45 | const DemoModalCallback = useCallback(() => { 46 | return ( 47 | 51 | ); 52 | }, [showDemoModal, setShowDemoModal]); 53 | 54 | return useMemo( 55 | () => ({ setShowDemoModal, DemoModal: DemoModalCallback }), 56 | [setShowDemoModal, DemoModalCallback], 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /components/home/web-vitals.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import CountingNumbers from "@/components/shared/counting-numbers"; 3 | 4 | export default function WebVitals() { 5 | return ( 6 |
7 | 13 | 29 | 30 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { FADE_IN_ANIMATION_SETTINGS } from "@/lib/constants"; 2 | import { AnimatePresence, motion } from "framer-motion"; 3 | import { useSession } from "next-auth/react"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | import { ReactNode } from "react"; 7 | import useScroll from "@/lib/hooks/use-scroll"; 8 | import Meta from "./meta"; 9 | import { useSignInModal } from "./sign-in-modal"; 10 | import UserDropdown from "./user-dropdown"; 11 | 12 | export default function Layout({ 13 | meta, 14 | children, 15 | }: { 16 | meta?: { 17 | title?: string; 18 | description?: string; 19 | image?: string; 20 | }; 21 | children: ReactNode; 22 | }) { 23 | const { data: session, status } = useSession(); 24 | const { SignInModal, setShowSignInModal } = useSignInModal(); 25 | const scrolled = useScroll(50); 26 | 27 | return ( 28 | <> 29 | 30 |
31 |
38 |
39 | 40 | Precedent logo 47 |

Read Pilot

48 | 49 |
50 | 51 | {!session && status !== "loading" ? ( 52 | 58 | Subscribe 59 | 60 | ) : ( 61 | 62 | )} 63 | 64 |
65 |
66 |
67 |
68 | {children} 69 |
70 |
71 |

72 | Powered by{" "} 73 | 79 | Next.js 80 | 81 |  and {""} 82 | 88 | OpenAI 89 | 90 |

91 |
92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /components/layout/meta.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | // TODO(jiayuan): Change this 4 | const DOMAIN = "https://readpilot.vercel.app"; 5 | 6 | export default function Meta({ 7 | title = "Read Pilot - Unlock the power of your online reading", 8 | description = "Read Pilot analyzes online articles and generate Q&A cards for you.", 9 | image = `${DOMAIN}/api/og`, 10 | }: { 11 | title?: string; 12 | description?: string; 13 | image?: string; 14 | }) { 15 | return ( 16 | 17 | {title} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /components/layout/sign-in-modal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "@/components/shared/modal"; 2 | import { signIn } from "next-auth/react"; 3 | import { 4 | useState, 5 | Dispatch, 6 | SetStateAction, 7 | useCallback, 8 | useMemo, 9 | } from "react"; 10 | import { LoadingDots, Google } from "@/components/shared/icons"; 11 | import Image from "next/image"; 12 | 13 | const SignInModal = ({ 14 | showSignInModal, 15 | setShowSignInModal, 16 | }: { 17 | showSignInModal: boolean; 18 | setShowSignInModal: Dispatch>; 19 | }) => { 20 | const [signInClicked, setSignInClicked] = useState(false); 21 | 22 | return ( 23 | 24 |
25 |
26 | 27 | Logo 34 | 35 |

Sign In

36 |

37 | This is strictly for demo purposes - only your email and profile 38 | picture will be stored. 39 |

40 |
41 | 42 |
43 | 64 |
65 |
66 |
67 | ); 68 | }; 69 | 70 | export function useSignInModal() { 71 | const [showSignInModal, setShowSignInModal] = useState(false); 72 | 73 | const SignInModalCallback = useCallback(() => { 74 | return ( 75 | 79 | ); 80 | }, [showSignInModal, setShowSignInModal]); 81 | 82 | return useMemo( 83 | () => ({ setShowSignInModal, SignInModal: SignInModalCallback }), 84 | [setShowSignInModal, SignInModalCallback], 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /components/layout/user-dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { signOut, useSession } from "next-auth/react"; 3 | import { LayoutDashboard, LogOut } from "lucide-react"; 4 | import Popover from "@/components/shared/popover"; 5 | import Image from "next/image"; 6 | import { motion } from "framer-motion"; 7 | import { FADE_IN_ANIMATION_SETTINGS } from "@/lib/constants"; 8 | 9 | export default function UserDropdown() { 10 | const { data: session } = useSession(); 11 | const { email, image } = session?.user || {}; 12 | const [openPopover, setOpenPopover] = useState(false); 13 | 14 | if (!email) return null; 15 | 16 | return ( 17 | 21 | 24 | {/* 28 | 29 |

Dashboard

30 | */} 31 | 38 | 45 |
46 | } 47 | align="end" 48 | openPopover={openPopover} 49 | setOpenPopover={setOpenPopover} 50 | > 51 | 62 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /components/shared/counting-numbers.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export default function CountingNumbers({ 4 | value, 5 | className, 6 | start = 0, 7 | duration = 800, 8 | }: { 9 | value: number; 10 | className: string; 11 | start?: number; 12 | duration?: number; 13 | }) { 14 | const [count, setCount] = useState(start); 15 | 16 | useEffect(() => { 17 | let startTime: number | undefined; 18 | const animateCount = (timestamp: number) => { 19 | if (!startTime) startTime = timestamp; 20 | const timePassed = timestamp - startTime; 21 | const progress = timePassed / duration; 22 | const currentCount = easeOutQuad(progress, 0, value, 1); 23 | if (currentCount >= value) { 24 | setCount(value); 25 | return; 26 | } 27 | setCount(currentCount); 28 | requestAnimationFrame(animateCount); 29 | }; 30 | requestAnimationFrame(animateCount); 31 | }, [value, duration]); 32 | 33 | return {Intl.NumberFormat().format(count)}; 34 | } 35 | const easeOutQuad = (t: number, b: number, c: number, d: number) => { 36 | t /= d; 37 | return Math.round(-c * t * (t - 2) + b); 38 | }; 39 | -------------------------------------------------------------------------------- /components/shared/icons/expanding-arrow.tsx: -------------------------------------------------------------------------------- 1 | export default function ExpandingArrow({ className }: { className?: string }) { 2 | return ( 3 |
4 | 14 | 18 | 19 | 29 | 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/shared/icons/github.tsx: -------------------------------------------------------------------------------- 1 | export default function Github({ className }: { className?: string }) { 2 | return ( 3 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/shared/icons/google.tsx: -------------------------------------------------------------------------------- 1 | export default function Google({ className }: { className: string }) { 2 | return ( 3 | 4 | 12 | 13 | 14 | 15 | 23 | 24 | 25 | 26 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {" "} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/shared/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as LoadingDots } from "./loading-dots"; 2 | export { default as LoadingCircle } from "./loading-circle"; 3 | export { default as LoadingSpinner } from "./loading-spinner"; 4 | export { default as ExpandingArrow } from "./expanding-arrow"; 5 | export { default as Github } from "./github"; 6 | export { default as Twitter } from "./twitter"; 7 | export { default as Google } from "./google"; 8 | -------------------------------------------------------------------------------- /components/shared/icons/link.tsx: -------------------------------------------------------------------------------- 1 | export default function LinkIcon({ className }: { className: string }) { 2 | return ( 3 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /components/shared/icons/loading-circle.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingCircle() { 2 | return ( 3 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/shared/icons/loading-dots.module.css: -------------------------------------------------------------------------------- 1 | .loading { 2 | display: inline-flex; 3 | align-items: center; 4 | } 5 | 6 | .loading .spacer { 7 | margin-right: 2px; 8 | } 9 | 10 | .loading span { 11 | animation-name: blink; 12 | animation-duration: 1.4s; 13 | animation-iteration-count: infinite; 14 | animation-fill-mode: both; 15 | width: 5px; 16 | height: 5px; 17 | border-radius: 50%; 18 | display: inline-block; 19 | margin: 0 1px; 20 | } 21 | 22 | .loading span:nth-of-type(2) { 23 | animation-delay: 0.2s; 24 | } 25 | 26 | .loading span:nth-of-type(3) { 27 | animation-delay: 0.4s; 28 | } 29 | 30 | @keyframes blink { 31 | 0% { 32 | opacity: 0.2; 33 | } 34 | 20% { 35 | opacity: 1; 36 | } 37 | 100% { 38 | opacity: 0.2; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /components/shared/icons/loading-dots.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./loading-dots.module.css"; 2 | 3 | const LoadingDots = ({ color = "#000" }: { color?: string }) => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default LoadingDots; 14 | -------------------------------------------------------------------------------- /components/shared/icons/loading-spinner.module.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | color: gray; 3 | display: inline-block; 4 | position: relative; 5 | width: 80px; 6 | height: 80px; 7 | transform: scale(0.3) translateX(-95px); 8 | } 9 | .spinner div { 10 | transform-origin: 40px 40px; 11 | animation: spinner 1.2s linear infinite; 12 | } 13 | .spinner div:after { 14 | content: " "; 15 | display: block; 16 | position: absolute; 17 | top: 3px; 18 | left: 37px; 19 | width: 6px; 20 | height: 20px; 21 | border-radius: 20%; 22 | background: black; 23 | } 24 | .spinner div:nth-child(1) { 25 | transform: rotate(0deg); 26 | animation-delay: -1.1s; 27 | } 28 | .spinner div:nth-child(2) { 29 | transform: rotate(30deg); 30 | animation-delay: -1s; 31 | } 32 | .spinner div:nth-child(3) { 33 | transform: rotate(60deg); 34 | animation-delay: -0.9s; 35 | } 36 | .spinner div:nth-child(4) { 37 | transform: rotate(90deg); 38 | animation-delay: -0.8s; 39 | } 40 | .spinner div:nth-child(5) { 41 | transform: rotate(120deg); 42 | animation-delay: -0.7s; 43 | } 44 | .spinner div:nth-child(6) { 45 | transform: rotate(150deg); 46 | animation-delay: -0.6s; 47 | } 48 | .spinner div:nth-child(7) { 49 | transform: rotate(180deg); 50 | animation-delay: -0.5s; 51 | } 52 | .spinner div:nth-child(8) { 53 | transform: rotate(210deg); 54 | animation-delay: -0.4s; 55 | } 56 | .spinner div:nth-child(9) { 57 | transform: rotate(240deg); 58 | animation-delay: -0.3s; 59 | } 60 | .spinner div:nth-child(10) { 61 | transform: rotate(270deg); 62 | animation-delay: -0.2s; 63 | } 64 | .spinner div:nth-child(11) { 65 | transform: rotate(300deg); 66 | animation-delay: -0.1s; 67 | } 68 | .spinner div:nth-child(12) { 69 | transform: rotate(330deg); 70 | animation-delay: 0s; 71 | } 72 | @keyframes spinner { 73 | 0% { 74 | opacity: 1; 75 | } 76 | 100% { 77 | opacity: 0; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /components/shared/icons/loading-spinner.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./loading-spinner.module.css"; 2 | 3 | export default function LoadingSpinner() { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/shared/icons/twitter.tsx: -------------------------------------------------------------------------------- 1 | export default function Twitter({ className }: { className?: string }) { 2 | return ( 3 | 8 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/shared/leaflet.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, ReactNode, Dispatch, SetStateAction } from "react"; 2 | import { AnimatePresence, motion, useAnimation } from "framer-motion"; 3 | 4 | export default function Leaflet({ 5 | setShow, 6 | children, 7 | }: { 8 | setShow: Dispatch>; 9 | children: ReactNode; 10 | }) { 11 | const leafletRef = useRef(null); 12 | const controls = useAnimation(); 13 | const transitionProps = { type: "spring", stiffness: 500, damping: 30 }; 14 | useEffect(() => { 15 | controls.start({ 16 | y: 20, 17 | transition: transitionProps, 18 | }); 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | }, []); 21 | 22 | async function handleDragEnd(_: any, info: any) { 23 | const offset = info.offset.y; 24 | const velocity = info.velocity.y; 25 | const height = leafletRef.current?.getBoundingClientRect().height || 0; 26 | if (offset > height / 2 || velocity > 800) { 27 | await controls.start({ y: "100%", transition: transitionProps }); 28 | setShow(false); 29 | } else { 30 | controls.start({ y: 0, transition: transitionProps }); 31 | } 32 | } 33 | 34 | return ( 35 | 36 | 50 |
53 |
54 |
55 |
56 | {children} 57 | 58 | setShow(false)} 65 | /> 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /components/shared/modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dispatch, 3 | SetStateAction, 4 | useCallback, 5 | useEffect, 6 | useRef, 7 | } from "react"; 8 | import FocusTrap from "focus-trap-react"; 9 | import { AnimatePresence, motion } from "framer-motion"; 10 | import Leaflet from "./leaflet"; 11 | import useWindowSize from "@/lib/hooks/use-window-size"; 12 | 13 | export default function Modal({ 14 | children, 15 | showModal, 16 | setShowModal, 17 | }: { 18 | children: React.ReactNode; 19 | showModal: boolean; 20 | setShowModal: Dispatch>; 21 | }) { 22 | const desktopModalRef = useRef(null); 23 | 24 | const onKeyDown = useCallback( 25 | (e: KeyboardEvent) => { 26 | if (e.key === "Escape") { 27 | setShowModal(false); 28 | } 29 | }, 30 | [setShowModal], 31 | ); 32 | 33 | useEffect(() => { 34 | document.addEventListener("keydown", onKeyDown); 35 | return () => document.removeEventListener("keydown", onKeyDown); 36 | }, [onKeyDown]); 37 | 38 | const { isMobile, isDesktop } = useWindowSize(); 39 | 40 | return ( 41 | 42 | {showModal && ( 43 | <> 44 | {isMobile && {children}} 45 | {isDesktop && ( 46 | <> 47 | 48 | { 56 | if (desktopModalRef.current === e.target) { 57 | setShowModal(false); 58 | } 59 | }} 60 | > 61 | {children} 62 | 63 | 64 | setShowModal(false)} 71 | /> 72 | 73 | )} 74 | 75 | )} 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /components/shared/popover.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, ReactNode, useRef } from "react"; 2 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 3 | import useWindowSize from "@/lib/hooks/use-window-size"; 4 | import Leaflet from "./leaflet"; 5 | 6 | export default function Popover({ 7 | children, 8 | content, 9 | align = "center", 10 | openPopover, 11 | setOpenPopover, 12 | }: { 13 | children: ReactNode; 14 | content: ReactNode | string; 15 | align?: "center" | "start" | "end"; 16 | openPopover: boolean; 17 | setOpenPopover: Dispatch>; 18 | }) { 19 | const { isMobile, isDesktop } = useWindowSize(); 20 | return ( 21 | <> 22 | {isMobile && children} 23 | {openPopover && isMobile && ( 24 | {content} 25 | )} 26 | {isDesktop && ( 27 | 28 | 29 | {children} 30 | 31 | 36 | {content} 37 | 38 | 39 | )} 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /components/shared/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react"; 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 3 | import { AnimatePresence } from "framer-motion"; 4 | import useWindowSize from "@/lib/hooks/use-window-size"; 5 | import Leaflet from "./leaflet"; 6 | 7 | export default function Tooltip({ 8 | children, 9 | content, 10 | fullWidth, 11 | }: { 12 | children: ReactNode; 13 | content: ReactNode | string; 14 | fullWidth?: boolean; 15 | }) { 16 | const [openTooltip, setOpenTooltip] = useState(false); 17 | 18 | const { isMobile, isDesktop } = useWindowSize(); 19 | 20 | return ( 21 | <> 22 | {isMobile && ( 23 | 30 | )} 31 | {openTooltip && isMobile && ( 32 | 33 | {typeof content === "string" ? ( 34 | 35 | {content} 36 | 37 | ) : ( 38 | content 39 | )} 40 | 41 | )} 42 | {isDesktop && ( 43 | 44 | 45 | 46 | {children} 47 | 48 | 53 | 54 | {typeof content === "string" ? ( 55 |
56 | 57 | {content} 58 | 59 |
60 | ) : ( 61 | content 62 | )} 63 | 64 |
65 |
66 |
67 | )} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const FADE_IN_ANIMATION_SETTINGS = { 2 | initial: { opacity: 0 }, 3 | animate: { opacity: 1 }, 4 | transition: { duration: 0.2 }, 5 | }; 6 | 7 | export const FADE_DOWN_ANIMATION_VARIANTS = { 8 | hidden: { opacity: 0, y: -10 }, 9 | show: { opacity: 1, y: 0, transition: { type: "spring" } }, 10 | }; 11 | 12 | export const FADE_UP_ANIMATION_VARIANTS = { 13 | hidden: { opacity: 0, y: 10 }, 14 | show: { opacity: 1, y: 0, transition: { type: "spring" } }, 15 | }; 16 | 17 | export const DEPLOY_URL = 18 | "https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsteven-tey%2Fprecedent&project-name=precedent&repository-name=precedent&demo-title=Precedent&demo-description=An%20opinionated%20collection%20of%20components%2C%20hooks%2C%20and%20utilities%20for%20your%20Next%20project.&demo-url=https%3A%2F%2Fprecedent.dev&demo-image=https%3A%2F%2Fprecedent.dev%2Fapi%2Fog&env=DATABASE_URL,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,NEXTAUTH_SECRET&envDescription=How%20to%20get%20these%20env%20variables%3A&envLink=https%3A%2F%2Fgithub.com%2Fsteven-tey%2Fprecedent%2Fblob%2Fmain%2F.env.example"; 19 | -------------------------------------------------------------------------------- /lib/hooks/use-intersection-observer.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useState } from "react"; 2 | 3 | interface Args extends IntersectionObserverInit { 4 | freezeOnceVisible?: boolean; 5 | } 6 | 7 | function useIntersectionObserver( 8 | elementRef: RefObject, 9 | { 10 | threshold = 0, 11 | root = null, 12 | rootMargin = "0%", 13 | freezeOnceVisible = false, 14 | }: Args, 15 | ): IntersectionObserverEntry | undefined { 16 | const [entry, setEntry] = useState(); 17 | 18 | const frozen = entry?.isIntersecting && freezeOnceVisible; 19 | 20 | const updateEntry = ([entry]: IntersectionObserverEntry[]): void => { 21 | setEntry(entry); 22 | }; 23 | 24 | useEffect(() => { 25 | const node = elementRef?.current; // DOM Ref 26 | const hasIOSupport = !!window.IntersectionObserver; 27 | 28 | if (!hasIOSupport || frozen || !node) return; 29 | 30 | const observerParams = { threshold, root, rootMargin }; 31 | const observer = new IntersectionObserver(updateEntry, observerParams); 32 | 33 | observer.observe(node); 34 | 35 | return () => observer.disconnect(); 36 | 37 | // eslint-disable-next-line react-hooks/exhaustive-deps 38 | }, [threshold, root, rootMargin, frozen]); 39 | 40 | return entry; 41 | } 42 | 43 | export default useIntersectionObserver; 44 | -------------------------------------------------------------------------------- /lib/hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useLocalStorage = ( 4 | key: string, 5 | initialValue: T, 6 | ): [T, (value: T) => void] => { 7 | const [storedValue, setStoredValue] = useState(initialValue); 8 | 9 | useEffect(() => { 10 | // Retrieve from localStorage 11 | const item = window.localStorage.getItem(key); 12 | if (item) { 13 | setStoredValue(JSON.parse(item)); 14 | } 15 | }, [key]); 16 | 17 | const setValue = (value: T) => { 18 | // Save state 19 | setStoredValue(value); 20 | // Save to localStorage 21 | window.localStorage.setItem(key, JSON.stringify(value)); 22 | }; 23 | return [storedValue, setValue]; 24 | }; 25 | 26 | export default useLocalStorage; 27 | -------------------------------------------------------------------------------- /lib/hooks/use-scroll.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | export default function useScroll(threshold: number) { 4 | const [scrolled, setScrolled] = useState(false); 5 | 6 | const onScroll = useCallback(() => { 7 | setScrolled(window.pageYOffset > threshold); 8 | }, [threshold]); 9 | 10 | useEffect(() => { 11 | window.addEventListener("scroll", onScroll); 12 | return () => window.removeEventListener("scroll", onScroll); 13 | }, [onScroll]); 14 | 15 | return scrolled; 16 | } 17 | -------------------------------------------------------------------------------- /lib/hooks/use-window-size.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export default function useWindowSize() { 4 | const [windowSize, setWindowSize] = useState<{ 5 | width: number | undefined; 6 | height: number | undefined; 7 | }>({ 8 | width: undefined, 9 | height: undefined, 10 | }); 11 | 12 | useEffect(() => { 13 | // Handler to call on window resize 14 | function handleResize() { 15 | // Set window width/height to state 16 | setWindowSize({ 17 | width: window.innerWidth, 18 | height: window.innerHeight, 19 | }); 20 | } 21 | 22 | // Add event listener 23 | window.addEventListener("resize", handleResize); 24 | 25 | // Call handler right away so state gets updated with initial window size 26 | handleResize(); 27 | 28 | // Remove event listener on cleanup 29 | return () => window.removeEventListener("resize", handleResize); 30 | }, []); // Empty array ensures that effect is only run on mount 31 | 32 | return { 33 | windowSize, 34 | isMobile: typeof windowSize?.width === "number" && windowSize?.width < 768, 35 | isDesktop: 36 | typeof windowSize?.width === "number" && windowSize?.width >= 768, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | const prisma = global.prisma || new PrismaClient(); 8 | 9 | if (process.env.NODE_ENV === "development") global.prisma = prisma; 10 | 11 | export default prisma; 12 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import ms from "ms"; 2 | 3 | export const timeAgo = (timestamp: Date, timeOnly?: boolean): string => { 4 | if (!timestamp) return "never"; 5 | return `${ms(Date.now() - new Date(timestamp).getTime())}${ 6 | timeOnly ? "" : " ago" 7 | }`; 8 | }; 9 | 10 | export async function fetcher( 11 | input: RequestInfo, 12 | init?: RequestInit, 13 | ): Promise { 14 | const res = await fetch(input, init); 15 | 16 | if (!res.ok) { 17 | const json = await res.json(); 18 | if (json.error) { 19 | const error = new Error(json.error) as Error & { 20 | status: number; 21 | }; 22 | error.status = res.status; 23 | throw error; 24 | } else { 25 | throw new Error("An unexpected error occurred"); 26 | } 27 | } 28 | 29 | return res.json(); 30 | } 31 | 32 | export function nFormatter(num: number, digits?: number) { 33 | if (!num) return "0"; 34 | const lookup = [ 35 | { value: 1, symbol: "" }, 36 | { value: 1e3, symbol: "K" }, 37 | { value: 1e6, symbol: "M" }, 38 | { value: 1e9, symbol: "G" }, 39 | { value: 1e12, symbol: "T" }, 40 | { value: 1e15, symbol: "P" }, 41 | { value: 1e18, symbol: "E" }, 42 | ]; 43 | const rx = /\.0+$|(\.[0-9]*[1-9])0+$/; 44 | var item = lookup 45 | .slice() 46 | .reverse() 47 | .find(function (item) { 48 | return num >= item.value; 49 | }); 50 | return item 51 | ? (num / item.value).toFixed(digits || 1).replace(rx, "$1") + item.symbol 52 | : "0"; 53 | } 54 | 55 | export function capitalize(str: string) { 56 | if (!str || typeof str !== "string") return str; 57 | return str.charAt(0).toUpperCase() + str.slice(1); 58 | } 59 | 60 | export const truncate = (str: string, length: number) => { 61 | if (!str || str.length <= length) return str; 62 | return `${str.slice(0, length)}...`; 63 | }; 64 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | images: { 6 | domains: ["lh3.googleusercontent.com"], 7 | }, 8 | async redirects() { 9 | return [ 10 | { 11 | source: "/github", 12 | destination: "https://github.com/steven-tey/precedent", 13 | permanent: false, 14 | }, 15 | ]; 16 | }, 17 | }; 18 | 19 | module.exports = nextConfig; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "precedent", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "format:write": "prettier --write \"**/*.{css,js,json,jsx,ts,tsx}\"", 9 | "format": "prettier \"**/*.{css,js,json,jsx,ts,tsx}\"", 10 | "start": "next start", 11 | "prebuild": "prisma generate", 12 | "predev": "prisma generate", 13 | "lint": "next lint" 14 | }, 15 | "dependencies": { 16 | "@next-auth/prisma-adapter": "^1.0.5", 17 | "@next/font": "13.1.1", 18 | "@prisma/client": "^4.8.1", 19 | "@radix-ui/react-popover": "^1.0.2", 20 | "@radix-ui/react-tooltip": "^1.0.2", 21 | "@types/node": "18.11.18", 22 | "@types/react": "18.0.26", 23 | "@types/react-dom": "18.0.10", 24 | "@vercel/analytics": "^0.1.6", 25 | "@vercel/og": "^0.0.26", 26 | "classnames": "^2.3.2", 27 | "eslint": "8.31.0", 28 | "eslint-config-next": "13.1.1", 29 | "focus-trap-react": "^10.0.2", 30 | "framer-motion": "^8.4.2", 31 | "lucide-react": "0.105.0-alpha.4", 32 | "ms": "^2.1.3", 33 | "next": "13.1.1", 34 | "next-auth": "^4.18.8", 35 | "react": "18.2.0", 36 | "react-dom": "18.2.0", 37 | "react-markdown": "^8.0.4", 38 | "react-wrap-balancer": "^0.3.0", 39 | "typescript": "4.9.4", 40 | "use-debounce": "^9.0.3" 41 | }, 42 | "devDependencies": { 43 | "@tailwindcss/forms": "^0.5.3", 44 | "@tailwindcss/line-clamp": "^0.4.2", 45 | "@tailwindcss/typography": "^0.5.9", 46 | "@types/ms": "^0.7.31", 47 | "autoprefixer": "^10.4.13", 48 | "concurrently": "^7.6.0", 49 | "postcss": "^8.4.21", 50 | "prettier": "^2.8.2", 51 | "prettier-plugin-tailwindcss": "^0.2.1", 52 | "prisma": "^4.8.1", 53 | "tailwindcss": "^3.2.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import { Analytics } from "@vercel/analytics/react"; 4 | import type { Session } from "next-auth"; 5 | import { SessionProvider } from "next-auth/react"; 6 | import { Provider as RWBProvider } from "react-wrap-balancer"; 7 | import cx from "classnames"; 8 | import localFont from "@next/font/local"; 9 | import { Inter } from "@next/font/google"; 10 | 11 | const sfPro = localFont({ 12 | src: "../styles/SF-Pro-Display-Medium.otf", 13 | variable: "--font-sf", 14 | }); 15 | 16 | const inter = Inter({ 17 | variable: "--font-inter", 18 | subsets: ["latin"], 19 | }); 20 | 21 | export default function MyApp({ 22 | Component, 23 | pageProps: { session, ...pageProps }, 24 | }: AppProps<{ session: Session }>) { 25 | return ( 26 | 27 | 28 |
29 | 30 |
31 |
32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/analyze.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | 3 | export const config = { 4 | runtime: "edge", 5 | }; 6 | 7 | const handler = async (req: NextRequest) => { 8 | const { url } = (await req.json()) as { url: string }; 9 | 10 | if (!url) { 11 | return new Response(JSON.stringify({ msg: "URL is empty", data: [] }), { 12 | status: 400, 13 | }); 14 | } 15 | 16 | const backendDomain = process.env.BACKEND_DOMAIN as string; 17 | const apiUrl = `${backendDomain}/api/v1/readpilot/analyze_url`; 18 | 19 | const res = await fetch(apiUrl, { 20 | method: "POST", 21 | headers: { 22 | "Content-Type": "application/json", 23 | "X-Api-Token": `${process.env.PIPE3_API_KEY}`, 24 | }, 25 | body: JSON.stringify({ 26 | url: url, 27 | }), 28 | }); 29 | 30 | if (res.status != 200) { 31 | console.log(`[analyze.ts] Pipe3 API error: ${res}`); 32 | return new Response(JSON.stringify({ msg: "Pipe3 API error", data: [] }), { 33 | status: 500, 34 | }); 35 | } 36 | 37 | const respJson = await res.json(); 38 | const data = respJson.data; 39 | 40 | return new Response(JSON.stringify({ data: data })); 41 | }; 42 | 43 | export default handler; 44 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { NextAuthOptions } from "next-auth"; 2 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 3 | import prisma from "@/lib/prisma"; 4 | import GoogleProvider from "next-auth/providers/google"; 5 | 6 | export const authOptions: NextAuthOptions = { 7 | adapter: PrismaAdapter(prisma), 8 | providers: [ 9 | GoogleProvider({ 10 | clientId: process.env.GOOGLE_CLIENT_ID as string, 11 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 12 | }), 13 | ], 14 | }; 15 | 16 | export default NextAuth(authOptions); 17 | -------------------------------------------------------------------------------- /pages/api/og.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { ImageResponse } from "@vercel/og"; 3 | import { NextRequest } from "next/server"; 4 | 5 | export const config = { 6 | runtime: "experimental-edge", 7 | }; 8 | 9 | const sfPro = fetch( 10 | new URL("../../styles/SF-Pro-Display-Medium.otf", import.meta.url), 11 | ).then((res) => res.arrayBuffer()); 12 | 13 | export default async function handler(req: NextRequest) { 14 | const [sfProData] = await Promise.all([sfPro]); 15 | 16 | const { searchParams } = req.nextUrl; 17 | const title = searchParams.get("title") || "Precedent"; 18 | 19 | return new ImageResponse( 20 | ( 21 |
34 | Precedent Logo 39 |

51 | {title} 52 |

53 |
54 | ), 55 | { 56 | width: 1200, 57 | height: 630, 58 | fonts: [ 59 | { 60 | name: "SF Pro", 61 | data: sfProData, 62 | }, 63 | ], 64 | }, 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Card from "@/components/home/card"; 2 | import Layout from "@/components/layout"; 3 | import Balancer from "react-wrap-balancer"; 4 | import { motion } from "framer-motion"; 5 | import { FADE_DOWN_ANIMATION_VARIANTS } from "@/lib/constants"; 6 | import { Github, LoadingDots, Twitter } from "@/components/shared/icons"; 7 | import { useState } from "react"; 8 | import LinkIcon from "@/components/shared/icons/link"; 9 | import CountingNumbers from "@/components/shared/counting-numbers"; 10 | 11 | export default function Home() { 12 | const [url, setUrl] = useState(""); 13 | const [showGeneratedCards, setShowGeneratedCards] = useState(false); 14 | const [loading, setLoading] = useState(false); 15 | const [results, setResults] = useState([]); 16 | 17 | const generateCards = async (e: any) => { 18 | e.preventDefault(); 19 | 20 | // TODO(jiayuan): refactor this later 21 | if (url === "") { 22 | console.log("Please enter a valid URL"); 23 | return; 24 | } 25 | 26 | setLoading(true); 27 | setResults([]); 28 | 29 | const response = await fetch("/api/analyze", { 30 | method: "POST", 31 | headers: { 32 | "Content-Type": "application/json", 33 | }, 34 | body: JSON.stringify({ url }), 35 | }) 36 | .then((res) => { 37 | if (res.status === 200) { 38 | return res.json(); 39 | } 40 | throw new Error("Something went wrong"); 41 | }) 42 | .then((responseJson) => { 43 | console.log(responseJson); 44 | setResults(responseJson.data); 45 | setShowGeneratedCards(true); 46 | }) 47 | .catch((error) => { 48 | console.error(error); 49 | }); 50 | 51 | setLoading(false); 52 | }; 53 | 54 | return ( 55 | 56 |
57 | 72 | 76 | 82 | 83 |

84 | Introducing Read Pilot 85 |

86 |
87 | 93 | 94 |

Star on GitHub

95 |
96 |
97 | 98 | 102 | 103 | Read Online Articles With 104 |
105 | 106 | Intelligence 107 | 108 |
109 |
110 | 114 | 115 | Read Pilot analyzes online articles and generate Q&A cards for 116 | you. 117 | 118 | 119 | 120 | 124 | 125 | Trusted by{" "} 126 | {" "} 131 | users,{" "} 132 | {" "} 137 | links have been analyzed. 138 | 139 | 140 | 141 | 142 |
143 | 144 | { 149 | setUrl((e.target as HTMLInputElement).value); 150 | }} 151 | required 152 | className="block w-full rounded-2xl border border-gray-200 bg-white p-2 pl-12 text-lg text-gray-600 shadow-md focus:border-black focus:outline-none focus:ring-0" 153 | /> 154 |
155 |
156 | 157 | 158 | {!loading && ( 159 | 165 | )} 166 | {loading && ( 167 | 174 | )} 175 | 176 |
177 | 178 | {showGeneratedCards && ( 179 |
180 | {results.map(({ q, a }) => ( 181 | 182 | ))} 183 |
184 | )} 185 |
186 |
187 | ); 188 | } 189 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // prettier.config.js 2 | module.exports = { 3 | bracketSpacing: true, 4 | semi: true, 5 | trailingComma: "all", 6 | printWidth: 80, 7 | tabWidth: 2, 8 | plugins: [require("prettier-plugin-tailwindcss")], 9 | }; 10 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | model Account { 11 | id String @id @default(cuid()) 12 | userId String 13 | type String 14 | provider String 15 | providerAccountId String 16 | refresh_token String? @db.Text 17 | access_token String? @db.Text 18 | expires_at Int? 19 | token_type String? 20 | scope String? 21 | id_token String? @db.Text 22 | session_state String? 23 | 24 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 25 | 26 | @@unique([provider, providerAccountId]) 27 | } 28 | 29 | model Session { 30 | id String @id @default(cuid()) 31 | sessionToken String @unique 32 | userId String 33 | expires DateTime 34 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 35 | } 36 | 37 | model User { 38 | id String @id @default(cuid()) 39 | name String? 40 | email String? @unique 41 | emailVerified DateTime? 42 | image String? 43 | accounts Account[] 44 | sessions Session[] 45 | } 46 | 47 | model VerificationToken { 48 | identifier String 49 | token String @unique 50 | expires DateTime 51 | 52 | @@unique([identifier, token]) 53 | } -------------------------------------------------------------------------------- /public/authjs.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/index-labs/readpilot/58b639f46f7dd05c1ecd0d494fb4930805a83e41/public/authjs.webp -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/index-labs/readpilot/58b639f46f7dd05c1ecd0d494fb4930805a83e41/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/index-labs/readpilot/58b639f46f7dd05c1ecd0d494fb4930805a83e41/public/logo.png -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/prisma.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel-logotype.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/index-labs/readpilot/58b639f46f7dd05c1ecd0d494fb4930805a83e41/screenshot.png -------------------------------------------------------------------------------- /styles/SF-Pro-Display-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/index-labs/readpilot/58b639f46f7dd05c1ecd0d494fb4930805a83e41/styles/SF-Pro-Display-Medium.otf -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const plugin = require("tailwindcss/plugin"); 3 | 4 | module.exports = { 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx}", 7 | "./components/**/*.{js,ts,jsx,tsx}", 8 | ], 9 | future: { 10 | hoverOnlyWhenSupported: true, 11 | }, 12 | theme: { 13 | extend: { 14 | width: { 15 | 128: "32rem", 16 | }, 17 | fontFamily: { 18 | display: ["var(--font-sf)", "system-ui", "sans-serif"], 19 | default: ["var(--font-inter)", "system-ui", "sans-serif"], 20 | }, 21 | animation: { 22 | // Tooltip 23 | "slide-up-fade": "slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1)", 24 | "slide-down-fade": "slide-down-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1)", 25 | }, 26 | keyframes: { 27 | // Tooltip 28 | "slide-up-fade": { 29 | "0%": { opacity: 0, transform: "translateY(6px)" }, 30 | "100%": { opacity: 1, transform: "translateY(0)" }, 31 | }, 32 | "slide-down-fade": { 33 | "0%": { opacity: 0, transform: "translateY(-6px)" }, 34 | "100%": { opacity: 1, transform: "translateY(0)" }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | plugins: [ 40 | require("@tailwindcss/forms"), 41 | require("@tailwindcss/typography"), 42 | require("@tailwindcss/line-clamp"), 43 | plugin(({ addVariant }) => { 44 | addVariant("radix-side-top", '&[data-side="top"]'); 45 | addVariant("radix-side-bottom", '&[data-side="bottom"]'); 46 | }), 47 | ], 48 | }; 49 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "@/components/*": ["components/*"], 10 | "@/pages/*": ["pages/*"], 11 | "@/lib/*": ["lib/*"], 12 | "@/styles/*": ["styles/*"] 13 | }, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noEmit": true, 17 | "esModuleInterop": true, 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "jsx": "preserve", 23 | "incremental": true 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------