├── .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 | 
6 |
7 | ## Star History
8 |
9 | [](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 |
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 |
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 |
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 |
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 |
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 |
19 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/components/shared/icons/github.tsx:
--------------------------------------------------------------------------------
1 | export default function Github({ className }: { className?: string }) {
2 | return (
3 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/components/shared/icons/google.tsx:
--------------------------------------------------------------------------------
1 | export default function Google({ className }: { className: string }) {
2 | return (
3 |
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 |
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 |
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 |
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 |

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 |
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 |
--------------------------------------------------------------------------------