├── .eslintrc.js ├── .gitignore ├── README.md ├── apps └── web │ ├── .env │ ├── .eslintrc.js │ ├── README.md │ ├── lib │ ├── browser.ts │ ├── store.ts │ ├── trpc.ts │ ├── useHotkeys.ts │ ├── useHover.ts │ ├── useIsMounted.ts │ ├── useOnClickOutside.ts │ ├── useSSRMediaQuery.ts │ ├── useSSRTheme.ts │ └── useScreen.ts │ ├── modules │ ├── authentication │ │ └── LoginModal.tsx │ ├── editor │ │ ├── FormikAutoSave.tsx │ │ ├── RichTextEditor.tsx │ │ └── plugins │ │ │ ├── CommandsList.tsx │ │ │ ├── DraggableItems.tsx │ │ │ ├── MathNode.tsx │ │ │ ├── Placeholder.tsx │ │ │ ├── SlashCommands.tsx │ │ │ └── TrailingNode.tsx │ ├── kbar │ │ ├── CommandPallette.tsx │ │ └── RenderResults.tsx │ ├── landing-page │ │ ├── FeatureCard.tsx │ │ ├── HeroSection.tsx │ │ ├── LandingPage.tsx │ │ ├── ListCheck.tsx │ │ ├── Navbar.tsx │ │ ├── Statistic.tsx │ │ └── Waitlist.tsx │ └── layout │ │ ├── DashboardLayout.tsx │ │ ├── FileTree.tsx │ │ ├── FloatingActions.tsx │ │ ├── QuickFindPopover.tsx │ │ ├── SampleSplitter.tsx │ │ ├── SettingsModal.tsx │ │ ├── Sidebar.tsx │ │ ├── SidebarControls.tsx │ │ ├── SidebarItem │ │ ├── icons │ │ │ ├── FocusedArchiveIcon.tsx │ │ │ ├── FocusedExploreIcon.tsx │ │ │ ├── FocusedHomeIcon.tsx │ │ │ ├── FocusedTrophyIcon.tsx │ │ │ └── asdf.tsx │ │ └── index.tsx │ │ └── UserDropdown.tsx │ ├── next-auth.d.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── dashboard.tsx │ ├── editor │ │ └── [id].tsx │ ├── index.tsx │ └── view │ │ └── [id].tsx │ ├── postcss.config.js │ ├── prisma │ ├── migrations │ │ ├── 20220802073129_init │ │ │ └── migration.sql │ │ ├── 20220802073542_reaction_count │ │ │ └── migration.sql │ │ ├── 20220802080439_remove_reaction_id │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma │ ├── public │ ├── favicon.ico │ ├── fonts │ │ └── eudoxus-sans-var.woff2 │ └── static │ │ ├── animals │ │ ├── bird.png │ │ ├── rabbit.png │ │ ├── rat.png │ │ └── walrus.png │ │ ├── dashboard.png │ │ └── logo.svg │ ├── server │ ├── context.ts │ ├── createRouter.ts │ ├── env.js │ ├── prisma.ts │ └── routers │ │ ├── _app.ts │ │ ├── drafts.ts │ │ ├── folders.ts │ │ └── reactions.ts │ ├── strip-attributes.d.ts │ ├── styles │ ├── globals.css │ └── lowlight.css │ ├── tailwind.config.js │ └── tsconfig.json ├── package.json ├── packages ├── eslint-config-custom │ ├── index.js │ └── package.json ├── tailwind-config │ ├── package.json │ ├── postcss.config.js │ └── tailwind.config.js ├── tsconfig │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json └── ui │ ├── index.tsx │ ├── package.json │ ├── postcss.config.js │ ├── src │ ├── Avatar.tsx │ ├── Button.tsx │ ├── IconButton.tsx │ ├── IconInput.tsx │ ├── Input.tsx │ ├── Menu.tsx │ ├── Modal.tsx │ ├── Popover.tsx │ ├── ScrollArea.tsx │ └── ThemeIcon.tsx │ ├── tailwind.config.js │ └── tsconfig.json ├── patches └── @tiptap+react+2.0.0-beta.114.patch ├── turbo.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // This tells ESLint to load the config from the package `eslint-config-custom` 4 | extends: ["custom"], 5 | settings: { 6 | next: { 7 | rootDir: ["apps/*/"], 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.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 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Presage 2 | 3 | A Medium alternative built for referral podcasts and blogs [joinpresage.com](https://joinpresage.com) 4 | 5 | ## Development 6 | 7 | All packages (web, api, etc.) are under `packages/`. When starting the api, run `yarn watch` and `yarn dev`. 8 | 9 | When contributing code, please checkout our [Figma file](https://www.figma.com/file/zPd9BYz6uGH4SxuANFGKvM/Presage). 10 | 11 | ## Contact & Other Details 12 | 13 | If you want to get in touch for investing, contributing, or joining our platform, please DM us on twitter [@joinpresage](https://twitter.com/joinpresage) or email use [help@joinpresage.com](mailto:help@joinpresage.com) 14 | -------------------------------------------------------------------------------- /apps/web/.env: -------------------------------------------------------------------------------- 1 | # DATABASE_URL="mysql://root@127.0.0.1:3309/presage" 2 | DATABASE_URL="postgres://postgres:postgres@localhost:5432/presage" 3 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | First, run the development server: 4 | 5 | ```bash 6 | yarn dev 7 | ``` 8 | 9 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 10 | 11 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 12 | 13 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 14 | 15 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 16 | 17 | ## Learn More 18 | 19 | To learn more about Next.js, take a look at the following resources: 20 | 21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 23 | 24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 25 | 26 | ## Deploy on Vercel 27 | 28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. 29 | 30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 31 | -------------------------------------------------------------------------------- /apps/web/lib/browser.ts: -------------------------------------------------------------------------------- 1 | const nav = typeof navigator != "undefined" ? navigator : null; 2 | const doc = typeof document != "undefined" ? document : null; 3 | const agent = (nav && nav.userAgent) || ""; 4 | 5 | const ie_edge = /Edge\/(\d+)/.exec(agent); 6 | const ie_upto10 = /MSIE \d/.exec(agent); 7 | const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(agent); 8 | 9 | export const ie = !!(ie_upto10 || ie_11up || ie_edge); 10 | export const ie_version = ie_upto10 11 | ? (document as any).documentMode 12 | : ie_11up 13 | ? +ie_11up[1] 14 | : ie_edge 15 | ? +ie_edge[1] 16 | : 0; 17 | export const gecko = !ie && /gecko\/(\d+)/i.test(agent); 18 | export const gecko_version = 19 | gecko && +(/Firefox\/(\d+)/.exec(agent) || [0, 0])[1]; 20 | 21 | const _chrome = !ie && /Chrome\/(\d+)/.exec(agent); 22 | export const chrome = !!_chrome; 23 | export const chrome_version = _chrome ? +_chrome[1] : 0; 24 | export const safari = !ie && !!nav && /Apple Computer/.test(nav.vendor); 25 | // Is true for both iOS and iPadOS for convenience 26 | export const ios = 27 | safari && (/Mobile\/\w+/.test(agent) || (!!nav && nav.maxTouchPoints > 2)); 28 | export const mac = ios || (nav ? /Mac/.test(nav.platform) : false); 29 | export const android = /Android \d/.test(agent); 30 | export const webkit = 31 | !!doc && "webkitFontSmoothing" in doc.documentElement.style; 32 | export const webkit_version = webkit 33 | ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1] 34 | : 0; 35 | -------------------------------------------------------------------------------- /apps/web/lib/store.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const collapseAtom = atom(false); 4 | export const currentFileAtom = atom({ 5 | draftId: "", 6 | stringPath: [] as string[], 7 | absolutePath: [] as string[], 8 | }); 9 | 10 | export const fileTreeAtom = atom(new Set()); 11 | -------------------------------------------------------------------------------- /apps/web/lib/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createReactQueryHooks } from "@trpc/react"; 2 | import type { 3 | inferProcedureOutput, 4 | inferProcedureInput, 5 | inferSubscriptionOutput, 6 | } from "@trpc/server"; 7 | import { AppRouter } from "../server/routers/_app"; 8 | 9 | export const trpc = createReactQueryHooks(); 10 | 11 | /** 12 | * Enum containing all api query paths 13 | */ 14 | export type TQuery = keyof AppRouter["_def"]["queries"]; 15 | 16 | /** 17 | * Enum containing all api mutation paths 18 | */ 19 | export type TMutation = keyof AppRouter["_def"]["mutations"]; 20 | 21 | /** 22 | * Enum containing all api subscription paths 23 | */ 24 | export type TSubscription = keyof AppRouter["_def"]["subscriptions"]; 25 | 26 | /** 27 | * This is a helper method to infer the output of a query resolver 28 | * @example type HelloOutput = InferQueryOutput<'hello'> 29 | */ 30 | export type InferQueryOutput = inferProcedureOutput< 31 | AppRouter["_def"]["queries"][TRouteKey] 32 | >; 33 | 34 | /** 35 | * This is a helper method to infer the input of a query resolver 36 | * @example type HelloInput = InferQueryInput<'hello'> 37 | */ 38 | export type InferQueryInput = inferProcedureInput< 39 | AppRouter["_def"]["queries"][TRouteKey] 40 | >; 41 | 42 | /** 43 | * This is a helper method to infer the output of a mutation resolver 44 | * @example type HelloOutput = InferMutationOutput<'hello'> 45 | */ 46 | export type InferMutationOutput = 47 | inferProcedureOutput; 48 | 49 | /** 50 | * This is a helper method to infer the input of a mutation resolver 51 | * @example type HelloInput = InferMutationInput<'hello'> 52 | */ 53 | export type InferMutationInput = 54 | inferProcedureInput; 55 | 56 | /** 57 | * This is a helper method to infer the output of a subscription resolver 58 | * @example type HelloOutput = InferSubscriptionOutput<'hello'> 59 | */ 60 | export type InferSubscriptionOutput = 61 | inferProcedureOutput; 62 | 63 | /** 64 | * This is a helper method to infer the asynchronous output of a subscription resolver 65 | * @example type HelloAsyncOutput = InferAsyncSubscriptionOutput<'hello'> 66 | */ 67 | export type InferAsyncSubscriptionOutput = 68 | inferSubscriptionOutput; 69 | 70 | /** 71 | * This is a helper method to infer the input of a subscription resolver 72 | * @example type HelloInput = InferSubscriptionInput<'hello'> 73 | */ 74 | export type InferSubscriptionInput = 75 | inferProcedureInput; 76 | -------------------------------------------------------------------------------- /apps/web/lib/useHotkeys.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import isHotkey from "is-hotkey"; 3 | 4 | export default function useHotkeys( 5 | hotkeys: { hotkey: string; callback: () => void }[] 6 | ) { 7 | useEffect(() => { 8 | const handleKeyboardShortcuts = (event: KeyboardEvent) => { 9 | for (const { hotkey, callback } of hotkeys) { 10 | if (isHotkey(hotkey, event)) { 11 | event.preventDefault(); 12 | callback(); 13 | } 14 | } 15 | }; 16 | document.addEventListener("keydown", handleKeyboardShortcuts); 17 | return () => 18 | document.removeEventListener("keydown", handleKeyboardShortcuts); 19 | }, [hotkeys]); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/lib/useHover.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useState, useRef, useEffect } from "react"; 2 | 3 | export function useHover(): [MutableRefObject, boolean] { 4 | const [value, setValue] = useState(false); 5 | const ref: any = useRef(null); 6 | const handleMouseOver = (): void => setValue(true); 7 | const handleMouseOut = (): void => setValue(false); 8 | useEffect( 9 | () => { 10 | const node: any = ref.current; 11 | if (node) { 12 | node.addEventListener("mouseover", handleMouseOver); 13 | node.addEventListener("mouseout", handleMouseOut); 14 | return () => { 15 | node.removeEventListener("mouseover", handleMouseOver); 16 | node.removeEventListener("mouseout", handleMouseOut); 17 | }; 18 | } 19 | }, 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | [ref.current] // Recall only if ref changes 22 | ); 23 | return [ref, value]; 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/lib/useIsMounted.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | 3 | export function useIsMounted() { 4 | const isMounted = useRef(false); 5 | 6 | useEffect(() => { 7 | isMounted.current = true; 8 | 9 | return () => { 10 | isMounted.current = false; 11 | }; 12 | }, []); 13 | 14 | return useCallback(() => isMounted.current, []); 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/lib/useOnClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export default function useOnClickOutside( 4 | element: Element | null, 5 | handler?: () => void 6 | ) { 7 | useEffect(() => { 8 | if (!element || !handler) { 9 | return; 10 | } 11 | 12 | const listener = (event: Event) => { 13 | if (!element || element.contains(event.target as Node)) { 14 | return; 15 | } 16 | 17 | handler(); 18 | }; 19 | 20 | document.addEventListener("mousedown", listener); 21 | document.addEventListener("touchstart", listener); 22 | 23 | return () => { 24 | document.removeEventListener("mousedown", listener); 25 | document.removeEventListener("touchstart", listener); 26 | }; 27 | }, [element, handler]); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/lib/useSSRMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useSSRMediaQuery = (mediaQuery: string) => { 4 | const [isVerified, setIsVerified] = useState(false); 5 | 6 | useEffect(() => { 7 | if (typeof window !== "undefined") { 8 | const mediaQueryList = window.matchMedia(mediaQuery); 9 | const documentChangeHandler = () => 10 | setIsVerified(!!mediaQueryList.matches); 11 | 12 | try { 13 | mediaQueryList.addEventListener("change", documentChangeHandler); 14 | } catch (e) { 15 | // Safari isn't supporting mediaQueryList.addEventListener 16 | console.error(e); 17 | mediaQueryList.addListener(documentChangeHandler); 18 | } 19 | 20 | documentChangeHandler(); 21 | return () => { 22 | try { 23 | mediaQueryList.removeEventListener("change", documentChangeHandler); 24 | } catch (e) { 25 | // Safari isn't supporting mediaQueryList.removeEventListener 26 | console.error(e); 27 | mediaQueryList.removeListener(documentChangeHandler); 28 | } 29 | }; 30 | } 31 | }, [mediaQuery]); 32 | 33 | return isVerified; 34 | }; 35 | -------------------------------------------------------------------------------- /apps/web/lib/useSSRTheme.ts: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes"; 2 | import { useIsMounted } from "./useIsMounted"; 3 | 4 | export const useSSRTheme = () => { 5 | const theme = useTheme(); 6 | const isMounted = useIsMounted(); 7 | 8 | return isMounted() ? theme : null; 9 | }; 10 | -------------------------------------------------------------------------------- /apps/web/lib/useScreen.ts: -------------------------------------------------------------------------------- 1 | import { useSSRMediaQuery } from "./useSSRMediaQuery"; 2 | 3 | export const useScreen = () => { 4 | const isSmallerThanMobile = useSSRMediaQuery("(max-width: 640px)"); 5 | const isSmallerThanTablet = useSSRMediaQuery("(max-width: 768px)"); 6 | const isSmallerThanDesktop = useSSRMediaQuery("(max-width: 1024px)"); 7 | 8 | return { isSmallerThanMobile, isSmallerThanDesktop, isSmallerThanTablet }; 9 | }; 10 | -------------------------------------------------------------------------------- /apps/web/modules/authentication/LoginModal.tsx: -------------------------------------------------------------------------------- 1 | import { signIn } from "next-auth/react"; 2 | import React from "react"; 3 | import { AiOutlineGithub, AiOutlineGoogle } from "react-icons/ai"; 4 | import { Button, Modal } from "ui"; 5 | 6 | interface LoginModalProps {} 7 | 8 | export const LoginModal: React.FC = ({}) => { 9 | return ( 10 | Login} 12 | title="Login Providers" 13 | > 14 |
15 | 23 | 31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /apps/web/modules/editor/FormikAutoSave.tsx: -------------------------------------------------------------------------------- 1 | import { useFormikContext } from "formik"; 2 | import React, { useEffect, useState } from "react"; 3 | import { useDebouncedCallback } from "use-debounce"; 4 | 5 | export const AutoSave: React.FC = () => { 6 | const formik = useFormikContext(); 7 | const [isLoading, setIsLoading] = useState(false); 8 | const debouncedSubmit = useDebouncedCallback( 9 | () => 10 | formik.submitForm().then(() => { 11 | setIsLoading(false); 12 | }), 13 | 1000 14 | ); 15 | 16 | useEffect(() => { 17 | if (formik.isValid && formik.dirty) { 18 | setIsLoading(true); 19 | debouncedSubmit(); 20 | } 21 | }, [debouncedSubmit, formik.dirty, formik.isValid, formik.values]); 22 | 23 | // return ( 24 | // 29 | // {isLoading ? "loading..." : "saved"} 30 | // 31 | // ); 32 | return null; 33 | }; 34 | -------------------------------------------------------------------------------- /apps/web/modules/editor/RichTextEditor.tsx: -------------------------------------------------------------------------------- 1 | import Dropcursor from "@tiptap/extension-dropcursor"; 2 | import Highlight from "@tiptap/extension-highlight"; 3 | import { 4 | BubbleMenu, 5 | Content, 6 | EditorContent, 7 | Extension, 8 | useEditor, 9 | } from "@tiptap/react"; 10 | import StarterKit from "@tiptap/starter-kit"; 11 | import { Plugin } from "prosemirror-state"; 12 | import React, { useEffect } from "react"; 13 | 14 | import { 15 | IconBold, 16 | IconCode, 17 | IconHighlight, 18 | IconItalic, 19 | IconStrikethrough, 20 | IconUnderline, 21 | } from "@tabler/icons"; 22 | import Underline from "@tiptap/extension-underline"; 23 | import { useField } from "formik"; 24 | 25 | import { InferQueryOutput } from "../../lib/trpc"; 26 | import { DraggableItems } from "./plugins/DraggableItems"; 27 | import { Placeholder } from "./plugins/Placeholder"; 28 | import { SlashCommands } from "./plugins/SlashCommands"; 29 | import { TrailingNode } from "./plugins/TrailingNode"; 30 | import { lowlight } from "lowlight/lib/common.js"; 31 | import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; 32 | 33 | export const rteClass = 34 | "prose !bg-transparent dark:prose-invert max-w-[calc(100%+2rem)] focus:outline-none -ml-8 pb-4 pt-2 " + 35 | "prose-pre:!bg-gray-900 prose-pre:border dark:prose-pre:border-gray-800 dark:prose-code:bg-gray-900 dark:prose-code:border-gray-700 dark:prose-code:text-gray-400 prose-code:bg-gray-100 dark:bg-gray-800 prose-code:font-medium prose-code:font-mono prose-code:rounded-lg prose-code:px-1.5 prose-code:py-0.5 prose-code:border prose-code:text-gray-500 " + 36 | "prose-blockquote:border-l-2 prose-blockquote:pl-4 prose-blockquote:text-gray-400 prose-blockquote:not-italic " + 37 | "prose-headings:leading-tight prose-headings:tracking-tight prose-h1:text-2xl prose-h1:font-bold prose-h1:font-bold"; 38 | 39 | const topLevelElements = [ 40 | "paragraph", 41 | "heading", 42 | "blockquote", 43 | "orderedList", 44 | "bulletList", 45 | "codeBlock", 46 | ]; 47 | 48 | interface RichTextEditorProps { 49 | draft?: InferQueryOutput<"drafts.byId">; 50 | } 51 | 52 | export const viewOnlyExtensions = [ 53 | Underline, 54 | Highlight.configure({ multicolor: true }), 55 | StarterKit.configure({ codeBlock: false }), 56 | CodeBlockLowlight.extend({ 57 | addOptions() { 58 | return { ...this.parent?.(), lowlight }; 59 | }, 60 | }), 61 | ]; 62 | 63 | export const RichTextEditor: React.FC = ({ draft }) => { 64 | const [{}, _, { setValue }] = useField("content"); 65 | const editor = useEditor({ 66 | extensions: [ 67 | // Math, 68 | // for the time being until https://github.com/benrbray/prosemirror-math/issues/43 is fixed 69 | Underline, 70 | Highlight.configure({ multicolor: true }), 71 | StarterKit.configure({ 72 | dropcursor: false, 73 | paragraph: false, 74 | heading: false, 75 | blockquote: false, 76 | bulletList: false, 77 | orderedList: false, 78 | horizontalRule: false, 79 | codeBlock: false, 80 | }), 81 | Placeholder.configure({ placeholder: "Type '/' for commands" }), 82 | // Focus.configure({ className: "has-focus", mode: "shallowest" }), 83 | Dropcursor.configure({ width: 3, color: "#68cef8" }), 84 | SlashCommands, 85 | Extension.create({ 86 | priority: 10000, 87 | addGlobalAttributes() { 88 | return [ 89 | { 90 | types: topLevelElements, 91 | attributes: { 92 | topLevel: { 93 | default: false, 94 | rendered: false, 95 | keepOnSplit: false, 96 | }, 97 | nestedParentType: { 98 | default: null, 99 | rendered: false, 100 | keepOnSplit: true, 101 | }, 102 | }, 103 | }, 104 | ]; 105 | }, 106 | addProseMirrorPlugins() { 107 | return [ 108 | new Plugin({ 109 | appendTransaction: (_transactions, oldState, newState) => { 110 | if (newState.doc === oldState.doc) { 111 | return; 112 | } 113 | const tr = newState.tr; 114 | newState.doc.descendants((node, pos, parent) => { 115 | if (topLevelElements.includes(node.type.name)) { 116 | tr.setNodeMarkup(pos, null, { 117 | topLevel: parent === newState.doc, 118 | nestedParentType: parent?.type.name, 119 | }); 120 | } 121 | }); 122 | return tr; 123 | }, 124 | }), 125 | ]; 126 | }, 127 | }), 128 | TrailingNode, 129 | ...DraggableItems, 130 | ], 131 | onCreate: ({ editor: e }) => { 132 | e.state.doc.descendants((node, pos, parent) => { 133 | if (topLevelElements.includes(node.type.name)) { 134 | e.view.dispatch( 135 | e.state.tr.setNodeMarkup(pos, null, { 136 | topLevel: parent === e.state.doc, 137 | nestedParentType: parent?.type.name, 138 | }) 139 | ); 140 | } 141 | }); 142 | }, 143 | onUpdate: ({ editor: e }) => setValue(e.getJSON()), 144 | content: draft?.content as Content, 145 | editorProps: { 146 | attributes: { 147 | spellcheck: "false", 148 | class: rteClass, 149 | }, 150 | }, 151 | }); 152 | 153 | useEffect(() => { 154 | if (editor && editor.getJSON() !== draft?.content) { 155 | !editor.isDestroyed && 156 | editor.commands.setContent((draft?.content || null) as Content); 157 | } 158 | }, [draft?.content, editor]); 159 | 160 | useEffect(() => { 161 | return () => { 162 | editor?.destroy(); 163 | }; 164 | }, [editor]); 165 | 166 | return ( 167 | <> 168 | {editor && ( 169 | 174 | 182 | 190 | 198 | 206 | 214 | {/*
*/} 215 | 223 | {/* 118 | 119 | )) 120 | )} 121 | 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /apps/web/modules/editor/plugins/DraggableItems.tsx: -------------------------------------------------------------------------------- 1 | import Blockquote from "@tiptap/extension-blockquote"; 2 | import BulletList from "@tiptap/extension-bullet-list"; 3 | import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; 4 | import Heading from "@tiptap/extension-heading"; 5 | import OrderedList from "@tiptap/extension-ordered-list"; 6 | import Paragraph from "@tiptap/extension-paragraph"; 7 | import { 8 | NodeViewContent, 9 | NodeViewProps, 10 | NodeViewWrapper, 11 | ReactNodeViewRenderer, 12 | } from "@tiptap/react"; 13 | import { lowlight } from "lowlight/lib/common.js"; 14 | import { NodeSelection, Plugin, TextSelection } from "prosemirror-state"; 15 | import { dropPoint } from "prosemirror-transform"; 16 | 17 | function eventCoords(event: MouseEvent) { 18 | return { left: event.clientX, top: event.clientY }; 19 | } 20 | 21 | const ComponentWrapper: React.FC = ({ editor, node }) => { 22 | // const [isHovering, hoverProps] = useHover(); 23 | return ( 24 | 29 | {node.attrs.topLevel && ( 30 |
46 | )} 47 | 64 |
65 | ); 66 | }; 67 | 68 | let globalStyles: Element; 69 | 70 | export const DraggableItems = [ 71 | CodeBlockLowlight.extend({ 72 | addOptions() { 73 | return { 74 | ...this.parent?.(), 75 | lowlight, 76 | }; 77 | }, 78 | draggable: true, 79 | selectable: false, 80 | addNodeView() { 81 | return ReactNodeViewRenderer(ComponentWrapper); 82 | }, 83 | }), 84 | Paragraph.extend({ 85 | draggable: true, 86 | selectable: false, 87 | addProseMirrorPlugins() { 88 | return [ 89 | new Plugin({ 90 | props: { 91 | handleDOMEvents: { 92 | dragstart(view, _event: Event) { 93 | globalStyles ??= document.createElement("style"); 94 | globalStyles.innerHTML = ` 95 | body::selection { 96 | background: transparent !important; 97 | color: inherit !important; 98 | } 99 | `; 100 | console.log(globalStyles); 101 | 102 | return false; 103 | }, 104 | }, 105 | handleDrop(view, event, slice, moved) { 106 | // pulled from the `prosemirror-view` codebase (couldn't think of more concise solution) 107 | // calculate the coordinates in which the drag ends 108 | let eventPos = view.posAtCoords(eventCoords(event)); 109 | let $mouse = view.state.doc.resolve(eventPos!.pos); 110 | 111 | // find the insertion point 112 | let insertPos = slice 113 | ? dropPoint(view.state.doc, $mouse.pos, slice) 114 | : $mouse.pos; 115 | if (insertPos == null) insertPos = $mouse.pos; 116 | 117 | let tr = view.state.tr; 118 | // if the node moves, delete the current selection 119 | if (moved) tr.deleteSelection(); 120 | 121 | // mapping to change the current positions to the new positions 122 | let pos = tr.mapping.map(insertPos); 123 | let isNode = 124 | slice.openStart == 0 && 125 | slice.openEnd == 0 && 126 | slice.content.childCount == 1; 127 | let beforeInsert = tr.doc; 128 | if (isNode) 129 | tr.replaceRangeWith(pos, pos, slice.content.firstChild!); 130 | else tr.replaceRange(pos, pos, slice); 131 | if (tr.doc.eq(beforeInsert)) return; 132 | 133 | let $pos = tr.doc.resolve(pos); 134 | if ( 135 | isNode && 136 | NodeSelection.isSelectable(slice.content.firstChild!) && 137 | $pos.nodeAfter && 138 | $pos.nodeAfter.sameMarkup(slice.content.firstChild!) 139 | ) { 140 | tr.setSelection(new NodeSelection($pos)); 141 | } else { 142 | // in our use case, selectable is false so this chunk run 143 | let end = tr.mapping.map(insertPos); 144 | tr.mapping.maps[tr.mapping.maps.length - 1].forEach( 145 | (_from, _to, _newFrom, newTo) => (end = newTo) 146 | ); 147 | tr.setSelection( 148 | new TextSelection(tr.doc.resolve($pos.pos)) 149 | ).scrollIntoView(); 150 | } 151 | view.focus(); 152 | view.dispatch(tr.setMeta("uiEvent", "drop")); 153 | 154 | globalStyles?.remove(); 155 | 156 | // prevent prosemirror from handling the drop for us 157 | return true; 158 | }, 159 | }, 160 | }), 161 | ]; 162 | }, 163 | addNodeView() { 164 | return ReactNodeViewRenderer(ComponentWrapper); 165 | }, 166 | }), 167 | Heading.extend({ 168 | draggable: true, 169 | selectable: false, 170 | // addKeyboardShortcuts() { 171 | // return { 172 | // Enter: ({ editor }) => { 173 | // return editor.commands.createParagraphNear(); 174 | // }, 175 | // }; 176 | // }, 177 | addNodeView() { 178 | return ReactNodeViewRenderer(ComponentWrapper); 179 | }, 180 | }).configure({ levels: [1] }), 181 | Blockquote.extend({ 182 | draggable: true, 183 | selectable: false, 184 | addNodeView() { 185 | return ReactNodeViewRenderer(ComponentWrapper); 186 | }, 187 | }), 188 | BulletList.extend({ 189 | draggable: true, 190 | selectable: false, 191 | addNodeView() { 192 | return ReactNodeViewRenderer(ComponentWrapper); 193 | }, 194 | }), 195 | OrderedList.extend({ 196 | draggable: true, 197 | selectable: false, 198 | addNodeView() { 199 | return ReactNodeViewRenderer(ComponentWrapper); 200 | }, 201 | }), 202 | ]; 203 | -------------------------------------------------------------------------------- /apps/web/modules/editor/plugins/MathNode.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { mergeAttributes, Node } from "@tiptap/core"; 3 | 4 | import { inputRules } from "prosemirror-inputrules"; 5 | 6 | import { 7 | makeInlineMathInputRule, 8 | mathPlugin, 9 | mathSelectPlugin, 10 | } from "@benrbray/prosemirror-math"; 11 | 12 | export const Math = Node.create({ 13 | name: "math_inline", 14 | group: "inline math", 15 | content: "text*", // important! 16 | inline: true, // important! 17 | atom: true, // important! 18 | code: true, 19 | 20 | parseHTML() { 21 | return [ 22 | { 23 | tag: "math-inline", // important! 24 | }, 25 | ]; 26 | }, 27 | 28 | renderHTML({ HTMLAttributes }) { 29 | return [ 30 | "math-inline", 31 | mergeAttributes({ class: "math-node" }, HTMLAttributes), 32 | 0, 33 | ]; 34 | }, 35 | 36 | addProseMirrorPlugins() { 37 | const inputRulePlugin = inputRules({ 38 | rules: [makeInlineMathInputRule(/\$\$(.+)\$\$/, this.type)], 39 | }); 40 | 41 | return [mathPlugin, inputRulePlugin, mathSelectPlugin]; 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /apps/web/modules/editor/plugins/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import { Editor, Extension } from "@tiptap/core"; 2 | import { Node as ProsemirrorNode } from "prosemirror-model"; 3 | import { Plugin } from "prosemirror-state"; 4 | import { Decoration, DecorationSet } from "prosemirror-view"; 5 | 6 | export interface PlaceholderOptions { 7 | emptyEditorClass: string; 8 | emptyNodeClass: string; 9 | placeholder: 10 | | ((PlaceholderProps: { 11 | editor: Editor; 12 | node: ProsemirrorNode; 13 | pos: number; 14 | hasAnchor: boolean; 15 | }) => string) 16 | | string; 17 | showOnlyWhenEditable: boolean; 18 | showOnlyCurrent: boolean; 19 | includeChildren: boolean; 20 | } 21 | 22 | export const Placeholder = Extension.create({ 23 | name: "placeholder", 24 | 25 | addOptions() { 26 | return { 27 | emptyEditorClass: "is-editor-empty", 28 | emptyNodeClass: "is-empty", 29 | placeholder: "Write something …", 30 | showOnlyWhenEditable: true, 31 | showOnlyCurrent: true, 32 | includeChildren: false, 33 | }; 34 | }, 35 | 36 | addProseMirrorPlugins() { 37 | return [ 38 | new Plugin({ 39 | props: { 40 | decorations: ({ doc, selection }) => { 41 | const active = 42 | this.editor.isEditable || !this.options.showOnlyWhenEditable; 43 | const { anchor } = selection; 44 | const decorations: Decoration[] = []; 45 | 46 | if (!active) { 47 | return null; 48 | } 49 | 50 | doc.descendants((node, pos) => { 51 | const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize; 52 | const isEmpty = !node.isLeaf && !node.childCount; 53 | 54 | if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) { 55 | const classes = [this.options.emptyNodeClass]; 56 | 57 | if (this.editor.isEmpty) { 58 | classes.push(this.options.emptyEditorClass); 59 | } 60 | 61 | const decoration = Decoration.node(pos, pos + node.nodeSize, { 62 | class: classes.join(" "), 63 | style: `--placeholder:"${ 64 | typeof this.options.placeholder === "function" 65 | ? this.options.placeholder({ 66 | editor: this.editor, 67 | node, 68 | pos, 69 | hasAnchor, 70 | }) 71 | : this.options.placeholder 72 | }"`, 73 | }); 74 | 75 | decorations.push(decoration); 76 | } 77 | 78 | return this.options.includeChildren; 79 | }); 80 | 81 | return DecorationSet.create(doc, decorations); 82 | }, 83 | }, 84 | }), 85 | ]; 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /apps/web/modules/editor/plugins/SlashCommands.tsx: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | import Fuse from "fuse.js"; 3 | import { Editor, Range, ReactRenderer } from "@tiptap/react"; 4 | import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; 5 | import { Node as ProseMirrorNode } from "prosemirror-model"; 6 | import tippy, { Instance } from "tippy.js"; 7 | import { CommandsList } from "./CommandsList"; 8 | import { 9 | IconCode, 10 | IconHeading, 11 | IconLetterA, 12 | IconList, 13 | IconListNumbers, 14 | IconQuote, 15 | } from "@tabler/icons"; 16 | 17 | export type CommandsOption = { 18 | HTMLAttributes?: Record; 19 | renderLabel?: (props: { 20 | options: CommandsOption; 21 | node: ProseMirrorNode; 22 | }) => string; 23 | suggestion: Omit; 24 | }; 25 | 26 | declare module "@tiptap/core" { 27 | interface Commands { 28 | customExtension: { 29 | toggleBold: () => ReturnType; 30 | toggleItalic: () => ReturnType; 31 | toggleOrderedList: () => ReturnType; 32 | toggleBulletList: () => ReturnType; 33 | toggleBlockquote: () => ReturnType; 34 | }; 35 | } 36 | } 37 | 38 | export const Commands = Extension.create({ 39 | name: "slash-commands", 40 | addOptions() { 41 | return { 42 | suggestion: { 43 | char: "/", 44 | startOfLine: false, 45 | command: ({ editor, range, props }: any) => { 46 | props.command({ editor, range }); 47 | }, 48 | }, 49 | }; 50 | }, 51 | addProseMirrorPlugins() { 52 | return [ 53 | Suggestion({ 54 | editor: this.editor, 55 | ...this.options.suggestion, 56 | }), 57 | ]; 58 | }, 59 | }); 60 | 61 | const commands = [ 62 | { 63 | title: "Paragraph", 64 | shortcut: "p", 65 | icon: , 66 | command: ({ editor, range }: { editor: Editor; range: Range }) => { 67 | editor.chain().focus().deleteRange(range).setNode("paragraph").run(); 68 | }, 69 | }, 70 | { 71 | title: "Heading", 72 | shortcut: "h1", 73 | icon: , 74 | command: ({ editor, range }: { editor: Editor; range: Range }) => { 75 | editor 76 | .chain() 77 | .focus() 78 | .deleteRange(range) 79 | .setNode("heading", { level: 1 }) 80 | .run(); 81 | }, 82 | }, 83 | { 84 | title: "Ordered List", 85 | icon: , 86 | description: "Create a list with numberings", 87 | command: ({ editor, range }: { editor: Editor; range: Range }) => { 88 | editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 89 | }, 90 | }, 91 | { 92 | title: "Bulleted List", 93 | description: "Create a bulleted list", 94 | icon: , 95 | command: ({ editor, range }: { editor: Editor; range: Range }) => { 96 | editor.chain().focus().deleteRange(range).toggleBulletList().run(); 97 | }, 98 | }, 99 | { 100 | title: "Quote", 101 | description: "Create a quote", 102 | icon: , 103 | command: ({ editor, range }: { editor: Editor; range: Range }) => { 104 | editor.chain().focus().deleteRange(range).toggleBlockquote().run(); 105 | }, 106 | }, 107 | { 108 | title: "Code Block", 109 | description: "Create a code block", 110 | icon: , 111 | command: ({ editor, range }: { editor: Editor; range: Range }) => { 112 | editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); 113 | }, 114 | }, 115 | ]; 116 | 117 | const fuse = new Fuse(commands, { keys: ["title", "description", "shortcut"] }); 118 | 119 | export const SlashCommands = Commands.configure({ 120 | suggestion: { 121 | items: ({ query }) => { 122 | return query ? fuse.search(query).map((x) => x.item) : commands; 123 | }, 124 | render: () => { 125 | let component: ReactRenderer; 126 | let popup: Instance[]; 127 | 128 | return { 129 | onStart(props) { 130 | component = new ReactRenderer(CommandsList as any, { 131 | editor: props.editor as Editor, 132 | props, 133 | }); 134 | 135 | popup = tippy("body", { 136 | getReferenceClientRect: props.clientRect as any, 137 | appendTo: () => document.body, 138 | content: component.element, 139 | showOnCreate: true, 140 | interactive: true, 141 | trigger: "manual", 142 | placement: "bottom-start", 143 | }); 144 | }, 145 | onUpdate(props) { 146 | component.updateProps(props); 147 | popup[0].setProps({ 148 | getReferenceClientRect: props.clientRect, 149 | }); 150 | }, 151 | onKeyDown(props) { 152 | if (props.event.key === "Escape") { 153 | popup[0].hide(); 154 | return true; 155 | } 156 | if ( 157 | [ 158 | "Space", 159 | "ArrowUp", 160 | "ArrowDown", 161 | "ArrowLeft", 162 | "ArrowRight", 163 | ].indexOf(props.event.key) > -1 164 | ) { 165 | props.event.preventDefault(); 166 | } 167 | return (component.ref as any).onKeyDown(props); 168 | }, 169 | onExit() { 170 | popup[0].destroy(); 171 | component.destroy(); 172 | }, 173 | }; 174 | }, 175 | }, 176 | }); 177 | -------------------------------------------------------------------------------- /apps/web/modules/editor/plugins/TrailingNode.tsx: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | import { Plugin, PluginKey } from "prosemirror-state"; 3 | 4 | // @ts-ignore 5 | function nodeEqualsType({ types, node }) { 6 | return ( 7 | (Array.isArray(types) && types.includes(node.type)) || node.type === types 8 | ); 9 | } 10 | 11 | /** 12 | * Extension based on: 13 | * - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js 14 | * - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts 15 | */ 16 | 17 | export interface TrailingNodeOptions { 18 | node: string; 19 | notAfter: string[]; 20 | } 21 | 22 | export const TrailingNode = Extension.create({ 23 | name: "trailingNode", 24 | 25 | addOptions() { 26 | return { 27 | node: "paragraph", 28 | notAfter: ["paragraph"], 29 | }; 30 | }, 31 | 32 | addProseMirrorPlugins() { 33 | const plugin = new PluginKey(this.name); 34 | const disabledNodes = Object.entries(this.editor.schema.nodes) 35 | .map(([, value]) => value) 36 | .filter((node) => this.options.notAfter.includes(node.name)); 37 | 38 | return [ 39 | new Plugin({ 40 | key: plugin, 41 | appendTransaction: (_, __, state) => { 42 | const { doc, tr, schema } = state; 43 | const shouldInsertNodeAtEnd = plugin.getState(state); 44 | const endPosition = doc.content.size; 45 | const type = schema.nodes[this.options.node]; 46 | 47 | if (!shouldInsertNodeAtEnd) { 48 | return; 49 | } 50 | 51 | return tr.insert(endPosition, type.create()); 52 | }, 53 | state: { 54 | init: (_, state) => { 55 | const lastNode = state.tr.doc.lastChild; 56 | 57 | return !nodeEqualsType({ node: lastNode, types: disabledNodes }); 58 | }, 59 | apply: (tr, value) => { 60 | if (!tr.docChanged) { 61 | return value; 62 | } 63 | 64 | const lastNode = tr.doc.lastChild; 65 | 66 | return !nodeEqualsType({ node: lastNode, types: disabledNodes }); 67 | }, 68 | }, 69 | }), 70 | ]; 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /apps/web/modules/kbar/CommandPallette.tsx: -------------------------------------------------------------------------------- 1 | import { KBarPortal, KBarPositioner, KBarAnimator, KBarSearch } from "kbar"; 2 | import React from "react"; 3 | import { RenderResults } from "./RenderResults"; 4 | 5 | interface CommandPalletteProps {} 6 | 7 | export const CommandPallette: React.FC = ({}) => { 8 | return ( 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 |
17 |
18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/web/modules/kbar/RenderResults.tsx: -------------------------------------------------------------------------------- 1 | import { KBarResults, useMatches } from "kbar"; 2 | 3 | export function RenderResults() { 4 | const { results } = useMatches(); 5 | 6 | return ( 7 | ( 10 | 28 | )} 29 | /> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/modules/landing-page/FeatureCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Image from "next/image"; 3 | import { MdPlayArrow } from "react-icons/md"; 4 | 5 | interface FeatureCardProps { 6 | category: string; 7 | color: "purple" | "pink" | "red" | "yellow"; 8 | title: string; 9 | description: string; 10 | time: string; 11 | animal: any; 12 | wip?: boolean; 13 | } 14 | 15 | export const FeatureCard: React.FC = ({ 16 | color, 17 | title, 18 | animal, 19 | category, 20 | description, 21 | time, 22 | wip, 23 | }) => { 24 | const colors = { 25 | purple: ["text-[#5D5FEF]", "bg-[#D0D1FF]"], 26 | pink: ["text-[#EF5DA8]", "bg-[#FCDDEC]"], 27 | red: ["text-[#EF4444]", "bg-[#FEE2E2]"], 28 | yellow: ["text-[#F59E0B]", "bg-[#FEF3C7]"], 29 | }; 30 | const [accent, tint] = colors[color]; 31 | 32 | return ( 33 |
34 |
35 | animal 36 |
37 |
38 | {category} 39 |

{title}

40 |

{description}

41 | 60 |
61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /apps/web/modules/landing-page/HeroSection.tsx: -------------------------------------------------------------------------------- 1 | import { motion, useScroll, useTransform } from "framer-motion"; 2 | import Image from "next/image"; 3 | import React from "react"; 4 | import { useScreen } from "../../lib/useScreen"; 5 | import { ListCheck } from "../../modules/landing-page/ListCheck"; 6 | import { Waitlist } from "../../modules/landing-page/Waitlist"; 7 | import dashboard from "../../public/static/dashboard.png"; 8 | import { Navbar } from "./Navbar"; 9 | 10 | interface HeroSectionProps {} 11 | 12 | export const HeroSection: React.FC = ({}) => { 13 | const { isSmallerThanTablet } = useScreen(); 14 | const { scrollY } = useScroll(); 15 | const rotateX = useTransform(scrollY, [0, 500], [15, 0]); 16 | const scale = useTransform(scrollY, [0, 500], [1.1, 1.2]); 17 | 18 | const headerContainer = { 19 | hidden: { opacity: 0 }, 20 | show: { 21 | opacity: 1, 22 | transition: { 23 | staggerChildren: 0.1, 24 | }, 25 | }, 26 | }; 27 | 28 | const headerRow = { 29 | hidden: { opacity: 0, y: -12 }, 30 | show: { opacity: 1, y: 0 }, 31 | }; 32 | 33 | return ( 34 |
35 | 41 | 42 | 43 | 50 | 54 | Earn from publishing
55 | Reward your top readers 56 |
57 | 61 | Quit Medium & Substack and 62 | publish on Presage. Brainstorm, draft, and revise without 63 | distractions. Reward your readers for referring your articles. 64 | 65 | 66 | 67 | 68 | 72 |
  • 73 | 74 | 75 | {isSmallerThanTablet ? "Free Plan" : "Generous Free Plan"} 76 | 77 |
  • 78 |
  • 79 | 80 | 81 | {isSmallerThanTablet ? "Referrals" : "Grow with Referrals"} 82 | 83 |
  • 84 |
  • 85 | 86 | 87 | Open Source 88 | 89 |
  • 90 |
    91 |
    92 |
    93 | 94 | Screenshot of dashboard 101 | 102 |
    103 |
    104 |
    105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /apps/web/modules/landing-page/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AiFillGithub } from "react-icons/ai"; 3 | import bird from "../../public/static/animals/bird.png"; 4 | import rabbit from "../../public/static/animals/rabbit.png"; 5 | import rat from "../../public/static/animals/rat.png"; 6 | import walrus from "../../public/static/animals/walrus.png"; 7 | import { FeatureCard } from "./FeatureCard"; 8 | import { HeroSection } from "./HeroSection"; 9 | import { Statistic } from "./Statistic"; 10 | 11 | export const LandingPage: React.FC = () => { 12 | return ( 13 |
    14 | 15 |
    16 |
    17 |
    18 | 19 | 20 | 21 |
    22 | 🦄 And much more! 23 |
    24 |
    25 |
    26 |
    27 |
    28 |
    29 |

    30 | Sounds Great, 31 |
    32 | But How Does it Work? 33 |

    34 |
    35 |

    36 | Our goal is to support independent journalism for everyone 37 | without prejudice. We also believe in keeping our code 38 | open-source for all to see (a star never hurts). 39 |

    40 | 44 | 45 |
    46 | coderinblack /{" "} 47 | presage 48 |
    49 |
    50 |
    51 |
    52 |
    53 |
    54 |
    55 |
    56 | 64 | 72 | 81 | 89 |
    90 |
    91 |
    92 |
    93 |
    94 |
    95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /apps/web/modules/landing-page/ListCheck.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface ListCheckProps {} 4 | 5 | export const ListCheck: React.FC = ({}) => { 6 | return ( 7 | 14 | 15 | 21 | 22 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /apps/web/modules/landing-page/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import React from "react"; 5 | import { Button } from "ui"; 6 | import logo from "../../public/static/logo.svg"; 7 | import { LoginModal } from "../authentication/LoginModal"; 8 | 9 | interface NavbarProps {} 10 | 11 | export const Navbar: React.FC = ({}) => { 12 | const { data: session } = useSession(); 13 | 14 | return ( 15 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /apps/web/modules/landing-page/Statistic.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface StatisticProps { 4 | statistic: string; 5 | description: string; 6 | } 7 | 8 | export const Statistic: React.FC = ({ 9 | statistic, 10 | description, 11 | }) => { 12 | return ( 13 |
    14 |
    15 | {statistic} 16 |
    17 |
    18 | {description.split(" ").map((word, index) => ( 19 |
    23 | {word} 24 |
    25 | ))} 26 |
    27 |
    28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /apps/web/modules/landing-page/Waitlist.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input } from "ui"; 2 | import React, { useState } from "react"; 3 | import toast from "react-hot-toast"; 4 | 5 | interface WaitlistProps {} 6 | 7 | const notify = () => toast.success("Success! Thanks for joining the Waitlist."); 8 | 9 | export const Waitlist: React.FC = ({}) => { 10 | const [email, setEmail] = useState(""); 11 | const [loading, setLoading] = useState(false); 12 | 13 | return ( 14 |
    { 17 | e.preventDefault(); 18 | setLoading(true); 19 | await fetch("/api/waitlist", { 20 | method: "POST", 21 | body: JSON.stringify({ email }), 22 | }); 23 | setLoading(false); 24 | notify(); 25 | setEmail(""); 26 | }} 27 | > 28 | setEmail(e.target.value)} 32 | value={email} 33 | required 34 | /> 35 | 43 |
    44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /apps/web/modules/layout/DashboardLayout.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import React from "react"; 3 | import { useResizable } from "react-resizable-layout"; 4 | import { collapseAtom } from "../../lib/store"; 5 | import SampleSplitter from "./SampleSplitter"; 6 | import { Sidebar } from "./Sidebar"; 7 | import { SidebarControls } from "./SidebarControls"; 8 | 9 | interface DashboardLayoutProps {} 10 | 11 | export const DashboardLayout: React.FC< 12 | React.PropsWithChildren 13 | > = ({ children }) => { 14 | const collapsed = useAtomValue(collapseAtom); 15 | const { position, isDragging, splitterProps } = useResizable({ 16 | axis: "x", 17 | initial: 280, 18 | min: 240, 19 | max: 440, 20 | }); 21 | 22 | return ( 23 |
    24 |
    29 | 30 |
    31 | 32 | 33 |
    {children}
    34 |
    35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /apps/web/modules/layout/FileTree.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IconDotsVertical, 3 | IconFilePlus, 4 | IconFolder, 5 | IconFolderPlus, 6 | IconFolders, 7 | IconPencil, 8 | IconPlus, 9 | IconSwitch2, 10 | IconTrash, 11 | } from "@tabler/icons"; 12 | import { useAtom } from "jotai"; 13 | import Link from "next/link"; 14 | import { useRouter } from "next/router"; 15 | import React, { 16 | MouseEventHandler, 17 | useEffect, 18 | useMemo, 19 | useRef, 20 | useState, 21 | } from "react"; 22 | import toast from "react-hot-toast"; 23 | import { MdFolder, MdStickyNote2 } from "react-icons/md"; 24 | import { Button, Menu, MenuDivider, MenuItem, SubMenu } from "ui"; 25 | import { currentFileAtom, fileTreeAtom } from "../../lib/store"; 26 | import { InferQueryOutput, trpc } from "../../lib/trpc"; 27 | 28 | interface FileTreeProps {} 29 | 30 | const FolderDisclosure: React.FC<{ 31 | folder: InferQueryOutput<"drafts.recursive">["children"][0]; 32 | parentFolderPath?: string; 33 | }> = ({ folder, parentFolderPath = "" }) => { 34 | const containsChildren = !!(folder.children || folder.drafts.length > 0); 35 | const [currentDraft, setCurrentDraft] = useAtom(currentFileAtom); 36 | const inCurrentPath = currentDraft.absolutePath 37 | .join("/") 38 | .includes( 39 | parentFolderPath ? `${parentFolderPath}/${folder.id}` : folder.id 40 | ); 41 | const currentPath = parentFolderPath 42 | ? `${parentFolderPath}/${folder.id}` 43 | : folder.id; 44 | 45 | const router = useRouter(); 46 | useEffect(() => { 47 | if (router.pathname !== "/editor/[id]") 48 | setCurrentDraft({ absolutePath: [], stringPath: [], draftId: "" }); 49 | }, [router.pathname, setCurrentDraft]); 50 | 51 | const [fileTree, setFileTree] = useAtom(fileTreeAtom); 52 | const open = useMemo(() => fileTree.has(folder.id), [fileTree, folder.id]); 53 | 54 | useEffect(() => { 55 | if (inCurrentPath) setFileTree((prev) => new Set([...prev, folder.id])); 56 | }, [folder.id, inCurrentPath, setFileTree]); 57 | 58 | return ( 59 | <> 60 | { 63 | if (open) { 64 | setFileTree( 65 | (prev) => new Set([...[...prev].filter((id) => id !== folder.id)]) 66 | ); 67 | } else { 68 | setFileTree((prev) => new Set([...prev, folder.id])); 69 | } 70 | if (!containsChildren) { 71 | toast.error("Folder is empty.", { 72 | position: "bottom-left", 73 | }); 74 | } 75 | }} 76 | openState={open && containsChildren} 77 | folder={folder} 78 | /> 79 | {/* */} 88 | {containsChildren && open && ( 89 |
    90 | {folder.children?.map((child) => ( 91 |
    92 | 93 |
    94 | ))} 95 | {folder.drafts?.map((draft) => ( 96 |
    97 | 98 |
    99 | ))} 100 |
    101 | )} 102 | {/*
    */} 103 | 104 | ); 105 | }; 106 | 107 | export const FileTree: React.FC = ({}) => { 108 | const { data: folderTree } = trpc.useQuery(["drafts.recursive"]); 109 | const [currentFile, setCurrentFile] = useAtom(currentFileAtom); 110 | 111 | useEffect(() => { 112 | const dfs = (obj: any, path: string[]) => { 113 | if (obj && "children" in obj && obj.children.length > 0) { 114 | obj.children.forEach((node: any) => { 115 | node.drafts?.forEach((draft: any) => { 116 | if (draft.id === currentFile.draftId) { 117 | setCurrentFile((prev) => ({ 118 | ...prev, 119 | absolutePath: [...obj.path, node.id], 120 | stringPath: [...path, node.name], 121 | })); 122 | } 123 | }); 124 | dfs(node, [...path, node.name]); 125 | }); 126 | } 127 | }; 128 | if (folderTree) { 129 | dfs({ children: [folderTree], path: [] }, []); 130 | } 131 | }, [currentFile.draftId, folderTree, setCurrentFile]); 132 | 133 | return ( 134 |
    135 | {folderTree?.children?.map((folder) => ( 136 | 137 | ))} 138 | {folderTree?.drafts?.map((draft) => ( 139 | 140 | ))} 141 |
    142 | ); 143 | }; 144 | 145 | export const FolderOrFileButton: React.FC<{ 146 | openState?: boolean; 147 | absolutePath: string; 148 | onClick?: MouseEventHandler | undefined; 149 | folder?: InferQueryOutput<"drafts.recursive">["children"][0]; 150 | draft?: InferQueryOutput<"drafts.byId">; 151 | }> = ({ openState, folder, onClick, draft, absolutePath }) => { 152 | const ref = useRef(null); 153 | const [editing, setEditing] = useState(false); 154 | const [currentDraft, setCurrentDraft] = useAtom(currentFileAtom); 155 | const [name, setName] = useState( 156 | folder ? folder.name! : draft ? draft.title! : "" 157 | ); 158 | const { data: folders } = trpc.useQuery(["folders.all"]); 159 | const addFolder = trpc.useMutation(["folders.add"]); 160 | const deleteDraft = trpc.useMutation(["drafts.delete"]); 161 | const deleteFolder = trpc.useMutation(["folders.delete"]); 162 | const updateFolder = trpc.useMutation(["folders.update"]); 163 | const updateDraft = trpc.useMutation(["drafts.update"]); 164 | const addDraft = trpc.useMutation(["drafts.add"]); 165 | const utils = trpc.useContext(); 166 | 167 | useEffect(() => { 168 | if (draft && name !== draft.title) { 169 | setName(draft.title); 170 | } 171 | }, [draft?.title]); 172 | 173 | function submit() { 174 | if (folder) { 175 | updateFolder.mutate( 176 | { id: folder.id, name }, 177 | { 178 | onSuccess: () => { 179 | utils.setQueryData(["drafts.recursive"], (( 180 | old: InferQueryOutput<"drafts.recursive"> 181 | ) => { 182 | if (old) { 183 | const dfs = (node: any) => { 184 | for (const child of node.children || []) { 185 | if (child.id === folder.id) { 186 | child.name = name; 187 | return; 188 | } 189 | dfs(child); 190 | } 191 | }; 192 | dfs(old); 193 | return old; 194 | } 195 | }) as any); 196 | }, 197 | } 198 | ); 199 | } 200 | if (draft) { 201 | updateDraft.mutate( 202 | { id: draft.id, title: name }, 203 | { 204 | onSuccess: (data) => { 205 | utils.refetchQueries(["drafts.recursive"]); 206 | utils.setQueryData(["drafts.byId", { id: draft.id }], data); 207 | }, 208 | } 209 | ); 210 | } 211 | } 212 | 213 | const isOpen = useMemo( 214 | () => 215 | currentDraft.absolutePath.join("/") === absolutePath && 216 | currentDraft.draftId === draft?.id, 217 | [absolutePath, currentDraft.absolutePath, currentDraft.draftId, draft?.id] 218 | ); 219 | 220 | const [fileTree, setFileTree] = useAtom(fileTreeAtom); 221 | const router = useRouter(); 222 | 223 | const button = ( 224 |
    240 | } 241 | disableRipple={editing} 242 | variant="ghost" 243 | as={draft ? "a" : "div"} 244 | > 245 | {!editing && ( 246 | {name || "Untitled"} 247 | )} 248 | { 254 | setEditing(false); 255 | submit(); 256 | }} 257 | onChange={(e) => setName(e.target.value)} 258 | onKeyDown={(e) => { 259 | if (e.key === "Enter") { 260 | setEditing(false); 261 | ref.current?.blur(); 262 | submit(); 263 | } 264 | }} 265 | className={`relative z-[100] bg-transparent focus:outline-none py-1 border-b-2 border-gray-300 dark:border-gray-600 w-full ${ 266 | editing ? "block" : "hidden" 267 | }`} 268 | /> 269 | 270 | {folder ? ( 271 | <> 272 | 275 | 276 | 277 | } 278 | className="w-48 text-[13px]" 279 | sideOffset={4} 280 | onCloseAutoFocus 281 | > 282 | } 284 | onClick={() => { 285 | addDraft.mutate( 286 | { title: "Untitled", folderId: folder.id }, 287 | { 288 | onSuccess: (data) => { 289 | utils.refetchQueries(["drafts.recursive"]); 290 | utils.setQueryData(["drafts.recursive"], (( 291 | old: InferQueryOutput<"drafts.recursive"> 292 | ) => { 293 | if (old) { 294 | const dfs = (node: any) => { 295 | for (const child of node.children || []) { 296 | if (child.id === folder.id) { 297 | child.drafts.push(data); 298 | return; 299 | } 300 | dfs(child); 301 | } 302 | }; 303 | dfs(old); 304 | return old; 305 | } 306 | }) as any); 307 | router.push("/editor/[id]", `/editor/${data.id}`); 308 | setFileTree((prev) => new Set([...prev, data.folderId!])); 309 | }, 310 | } 311 | ); 312 | }} 313 | > 314 | New Draft 315 | 316 | } 318 | onClick={() => { 319 | addFolder.mutate( 320 | { 321 | name: "Untitled", 322 | parentId: folder.id, 323 | }, 324 | { 325 | onSuccess: (data) => { 326 | utils.refetchQueries(["drafts.recursive"]); 327 | setFileTree((prev) => new Set([...prev, data.parentId!])); 328 | }, 329 | } 330 | ); 331 | }} 332 | > 333 | New Folder 334 | 335 | 336 | 344 | 345 | 346 | } 347 | > 348 | { 350 | setEditing(true); 351 | setTimeout(() => { 352 | ref.current?.focus(); 353 | }, 0); 354 | }} 355 | icon={} 356 | > 357 | Rename 358 | 359 | }> 360 | Convert to publication 361 | 362 | 363 | { 365 | if (folder) { 366 | deleteFolder.mutate( 367 | { id: folder?.id }, 368 | { 369 | onSuccess: () => { 370 | utils.setQueryData(["drafts.recursive"], (( 371 | old: InferQueryOutput<"drafts.recursive"> 372 | ) => { 373 | if (old) { 374 | const dfs = (node: any) => { 375 | node.children = node.children || []; 376 | for (let i = 0; i < node.children.length; i++) { 377 | if (node.children[i].id === folder.id) { 378 | delete node.children[i]; 379 | return; 380 | } 381 | dfs(node.children[i]); 382 | } 383 | }; 384 | dfs(old); 385 | return old; 386 | } 387 | }) as any); 388 | if (router.pathname === "/editor/[id]") { 389 | const openDraft = utils.getQueryData([ 390 | "drafts.byId", 391 | { id: router.query.id?.toString() || "" }, 392 | ]); 393 | if (openDraft?.folderId === folder.id) { 394 | router.push("/dashboard"); 395 | } 396 | } 397 | }, 398 | } 399 | ); 400 | } 401 | }} 402 | icon={} 403 | > 404 | Delete 405 | 406 | 407 | 408 | ) : ( 409 | 417 | 418 | 419 | } 420 | > 421 | { 423 | setEditing(true); 424 | setTimeout(() => { 425 | ref.current?.focus(); 426 | }, 0); 427 | }} 428 | icon={} 429 | > 430 | Rename 431 | 432 | }> 436 | Move to folder 437 | 438 | } 439 | > 440 | } 443 | onClick={() => { 444 | if (draft) { 445 | updateDraft.mutate( 446 | { id: draft.id, folderId: null }, 447 | { 448 | onSuccess: () => { 449 | utils.refetchQueries(["drafts.recursive"]); 450 | }, 451 | } 452 | ); 453 | } 454 | }} 455 | > 456 | None (Root) 457 | 458 | 459 | {folders?.map((folder) => ( 460 | } 464 | onClick={() => { 465 | if (draft) { 466 | updateDraft.mutate( 467 | { id: draft.id, folderId: folder.id }, 468 | { 469 | onSuccess: () => { 470 | utils.refetchQueries(["drafts.recursive"]); 471 | }, 472 | } 473 | ); 474 | } 475 | }} 476 | > 477 | {folder.name} 478 | 479 | ))} 480 | 481 | { 483 | if (draft) { 484 | deleteDraft.mutate( 485 | { id: draft?.id }, 486 | { 487 | onSuccess: () => { 488 | utils.setQueryData(["drafts.recursive"], (( 489 | old: InferQueryOutput<"drafts.recursive"> 490 | ) => { 491 | if (old) { 492 | const dfs = (node: any) => { 493 | for (let i = 0; i < node.drafts.length; i++) { 494 | if (node.drafts[i].id === draft.id) { 495 | delete node.drafts[i]; 496 | return; 497 | } 498 | } 499 | for (const child of node.children || []) { 500 | dfs(child); 501 | } 502 | }; 503 | dfs(old); 504 | return old; 505 | } 506 | }) as any); 507 | }, 508 | } 509 | ); 510 | if ( 511 | router.pathname === "/editor/[id]" && 512 | router.query.id === draft.id 513 | ) { 514 | router.push("/dashboard"); 515 | } 516 | } 517 | }} 518 | icon={} 519 | > 520 | Delete 521 | 522 | 523 | )} 524 | 525 | ); 526 | 527 | if (draft) { 528 | return ( 529 | 534 | {button} 535 | 536 | ); 537 | } 538 | 539 | return button; 540 | }; 541 | -------------------------------------------------------------------------------- /apps/web/modules/layout/FloatingActions.tsx: -------------------------------------------------------------------------------- 1 | import { IconSun, IconMoon } from "@tabler/icons"; 2 | import { useKBar } from "kbar"; 3 | import React from "react"; 4 | import { useSSRTheme } from "../../lib/useSSRTheme"; 5 | 6 | interface FloatingActionsProps {} 7 | 8 | export const FloatingActions: React.FC = ({}) => { 9 | const theme = useSSRTheme(); 10 | const { query } = useKBar(); 11 | 12 | return ( 13 |
    14 |
    15 | 30 | 35 |
    36 |
    37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /apps/web/modules/layout/QuickFindPopover.tsx: -------------------------------------------------------------------------------- 1 | import { IconSearch } from "@tabler/icons"; 2 | import React from "react"; 3 | import { Button } from "ui"; 4 | 5 | interface QuickFindPopoverProps {} 6 | 7 | export const QuickFindPopover: React.FC = ({}) => { 8 | return ( 9 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /apps/web/modules/layout/SampleSplitter.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { SplitterProps } from "react-resizable-layout"; 3 | 4 | export const cn = (...args: any[]) => args.filter(Boolean).join(" "); 5 | 6 | const SampleSplitter: React.FC = ({ 7 | dir, 8 | isDragging, 9 | ...props 10 | }) => { 11 | const [isFocused, setIsFocused] = useState(false); 12 | const splitterRef = useRef(null); 13 | 14 | return ( 15 |
    setIsFocused(true)} 23 | onBlur={() => setIsFocused(false)} 24 | {...props} 25 | /> 26 | ); 27 | }; 28 | 29 | export default SampleSplitter; 30 | -------------------------------------------------------------------------------- /apps/web/modules/layout/SettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IconAt, 3 | IconBrandGithub, 4 | IconBrandInstagram, 5 | IconBrandMedium, 6 | IconBrandTwitter, 7 | IconBrandYoutube, 8 | } from "@tabler/icons"; 9 | import React from "react"; 10 | import { MdSettings } from "react-icons/md"; 11 | import { Button, Input, Modal } from "ui"; 12 | 13 | interface SettingsModalProps {} 14 | 15 | export const SettingsModal: React.FC = ({}) => { 16 | return ( 17 | 26 | } 27 | className="w-full !justify-start !p-2 text-[13px]" 28 | > 29 | Settings 30 | 31 | } 32 | title="Basic Information" 33 | > 34 |
    35 |
    36 | } 39 | placeholder="johndoe" 40 | /> 41 |
    42 | } 44 | label="Twitter URL" 45 | placeholder="https://twitter.com/johndoe" 46 | /> 47 | } 49 | label="Instagram" 50 | placeholder="https://instagram.com/johndoe" 51 | /> 52 | } 54 | label="GitHub" 55 | placeholder="https://github.com/johndoe" 56 | /> 57 | } 60 | placeholder="https://youtube.com/c/johndoe" 61 | /> 62 | } 64 | label="Medium" 65 | placeholder="https://johndoe.medium.com" 66 | /> 67 |
    68 | 69 | 72 |
    73 |
    74 |
    75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /apps/web/modules/layout/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import { useAtom } from "jotai"; 3 | import { useTheme } from "next-themes"; 4 | import Link from "next/link"; 5 | import { useRouter } from "next/router"; 6 | import React from "react"; 7 | import { MdAdd, MdHome, MdSavings, MdSubscriptions } from "react-icons/md"; 8 | import { Button } from "ui"; 9 | import { collapseAtom } from "../../lib/store"; 10 | import { InferQueryOutput, trpc } from "../../lib/trpc"; 11 | import { FileTree } from "./FileTree"; 12 | import { SettingsModal } from "./SettingsModal"; 13 | 14 | import { UserDropdown } from "./UserDropdown"; 15 | 16 | interface SidebarProps { 17 | width: number; 18 | } 19 | 20 | export const Sidebar: React.FC = ({ width }) => { 21 | const [collapsed, setCollapsed] = useAtom(collapseAtom); 22 | const addFolder = trpc.useMutation(["folders.add"]); 23 | const addDraft = trpc.useMutation(["drafts.add"]); 24 | const router = useRouter(); 25 | const utils = trpc.useContext(); 26 | const { theme } = useTheme(); 27 | 28 | return ( 29 | 38 |
    39 |
    40 | 41 | 54 | 55 | 56 | 69 | 82 |
    83 |
    84 |
      85 | 86 |
    • 87 |
    93 | } 94 | variant="ghost" 95 | onClick={() => { 96 | addDraft.mutate( 97 | { title: "Untitled" }, 98 | { 99 | onSuccess: (data) => { 100 | utils.setQueryData(["drafts.recursive"], (( 101 | old: InferQueryOutput<"drafts.recursive"> 102 | ) => { 103 | if (old) { 104 | old.drafts.push(data); 105 | return old; 106 | } 107 | }) as any); 108 | router.push(`/editor/${data.id}`); 109 | }, 110 | } 111 | ); 112 | }} 113 | > 114 | New Draft 115 | 116 | 117 |
  • 118 |
  • 124 | } 125 | variant="ghost" 126 | onClick={() => { 127 | addFolder.mutate( 128 | { name: "Untitled" }, 129 | { 130 | onSuccess: (data) => { 131 | utils.setQueryData(["drafts.recursive"], (( 132 | old: InferQueryOutput<"drafts.recursive"> 133 | ) => { 134 | if (old) { 135 | old.children.push({ 136 | depth: 1, 137 | path: [data.id], 138 | children: [], 139 | drafts: [], 140 | ...data, 141 | }); 142 | return old; 143 | } 144 | }) as any); 145 | }, 146 | } 147 | ); 148 | }} 149 | > 150 | New Folder 151 | 152 | 153 | 154 | 155 | 156 | 157 | ); 158 | }; 159 | -------------------------------------------------------------------------------- /apps/web/modules/layout/SidebarControls.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { useKBar } from "kbar"; 3 | import React from "react"; 4 | import { MdMenu, MdSearch } from "react-icons/md"; 5 | import { Button } from "ui"; 6 | import { collapseAtom } from "../../lib/store"; 7 | import { useIsMounted } from "../../lib/useIsMounted"; 8 | 9 | interface SidebarControlsProps {} 10 | 11 | export const SidebarControls: React.FC = ({}) => { 12 | const { query } = useKBar(); 13 | const isMounted = useIsMounted(); 14 | const [collapsed, setCollapsed] = useAtom(collapseAtom); 15 | 16 | return ( 17 |
    18 |
    31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/web/modules/layout/SidebarItem/icons/FocusedArchiveIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface FocusedArchiveIconProps {} 4 | 5 | export const FocusedArchiveIcon: React.FC = ({}) => { 6 | return ( 7 | 14 | 22 | 26 | 33 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /apps/web/modules/layout/SidebarItem/icons/FocusedExploreIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface FocusedExploreIconProps {} 4 | 5 | export const FocusedExploreIcon: React.FC = ({}) => { 6 | return ( 7 | 14 | 22 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /apps/web/modules/layout/SidebarItem/icons/FocusedHomeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface FocusedHomeIconProps {} 4 | 5 | export const FocusedHomeIcon: React.FC = ({}) => { 6 | return ( 7 | 14 | 22 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /apps/web/modules/layout/SidebarItem/icons/FocusedTrophyIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface FocusedTrophyIconProps {} 4 | 5 | export const FocusedTrophyIcon: React.FC = ({}) => { 6 | return ( 7 | 14 | 21 | 28 | 35 | 39 | 46 | 54 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /apps/web/modules/layout/SidebarItem/icons/asdf.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface FocusedExploreIconProps {} 4 | 5 | export const FocusedExploreIcon: React.FC = ({}) => { 6 | return ( 7 | 14 | 22 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /apps/web/modules/layout/SidebarItem/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IconArchive, 3 | IconBrandSafari, 4 | IconSmartHome, 5 | IconTrophy, 6 | } from "@tabler/icons"; 7 | import { useRouter } from "next/dist/client/router"; 8 | import Link from "next/link"; 9 | import React from "react"; 10 | import { FocusedArchiveIcon } from "./icons/FocusedArchiveIcon"; 11 | import { FocusedExploreIcon } from "./icons/FocusedExploreIcon"; 12 | import { FocusedHomeIcon } from "./icons/FocusedHomeIcon"; 13 | import { FocusedTrophyIcon } from "./icons/FocusedTrophyIcon"; 14 | 15 | interface SidebarItemProps { 16 | name: keyof typeof icons; 17 | } 18 | 19 | const icons = { 20 | dashboard: { 21 | focused: , 22 | default: , 23 | }, 24 | explore: { 25 | focused: , 26 | default: , 27 | }, 28 | subscriptions: { 29 | focused: , 30 | default: , 31 | }, 32 | rewards: { 33 | focused: , 34 | default: , 35 | }, 36 | }; 37 | 38 | const routes = { 39 | dashboard: "/dashboard", 40 | explore: "/explore", 41 | subscriptions: "/subscriptions", 42 | rewards: "/rewards", 43 | }; 44 | 45 | export const SidebarItem: React.FC = ({ name }) => { 46 | const router = useRouter(); 47 | const isFocused = router.pathname === routes[name]; 48 | 49 | return ( 50 | 51 | 56 |
    57 | {icons[name][isFocused ? "focused" : "default"]} 58 | {name} 59 |
    60 |
    61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /apps/web/modules/layout/UserDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { signOut, useSession } from "next-auth/react"; 2 | import React from "react"; 3 | import { MdCode, MdCreditCard, MdHelp, MdLogout } from "react-icons/md"; 4 | import { Avatar, Menu, MenuDivider, MenuItem } from "ui"; 5 | 6 | export const UserDropdown: React.FC = () => { 7 | const { data: session } = useSession(); 8 | 9 | return ( 10 |
    11 | {session ? ( 12 | 18 |
    19 | 25 |
    26 |
    27 | {session?.user.name} 28 |
    29 |

    Free plan

    30 |
    31 |
    32 | 33 | } 34 | > 35 | }>Upgrade 36 | }>Developer 37 | }>Help Center 38 | 39 | {/*
    40 |

    Quotas:

    41 |
      42 |
    • 0 / 100 drafts
    • 43 |
    • 0 / 1GB storage
    • 44 |
    • 0 / 3 rewards
    • 45 |
    46 |
    47 | */} 48 | signOut({ callbackUrl: "/" })} 50 | icon={} 51 | > 52 | Logout 53 | 54 |
    55 | ) : null} 56 |
    57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /apps/web/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { DefaultSession } from "next-auth"; 2 | 3 | declare module "next-auth" { 4 | /** 5 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 6 | */ 7 | interface Session { 8 | user: { 9 | id: string; 10 | } & DefaultSession["user"]; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | const withTM = require("next-transpile-modules")(["ui"]); 2 | const { env } = require("./server/env"); 3 | 4 | /** 5 | * @type {import('next').NextConfig} 6 | */ 7 | const config = { 8 | reactStrictMode: true, 9 | publicRuntimeConfig: { 10 | NODE_ENV: env.NODE_ENV, 11 | }, 12 | experimental: { 13 | images: { allowFutureImage: true }, 14 | }, 15 | }; 16 | 17 | module.exports = withTM(config); 18 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 8080", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@benrbray/prosemirror-math": "^0.2.2", 13 | "@headlessui/react": "^1.6.6", 14 | "@next-auth/prisma-adapter": "^1.0.3", 15 | "@popperjs/core": "^2.11.5", 16 | "@prisma/client": "^4.0.0", 17 | "@radix-ui/react-scroll-area": "^0.1.4", 18 | "@tabler/icons": "^1.74.0", 19 | "@tiptap/extension-bubble-menu": "^2.0.0-beta.61", 20 | "@tiptap/extension-code-block-lowlight": "^2.0.0-beta.73", 21 | "@tiptap/extension-focus": "^2.0.0-beta.45", 22 | "@tiptap/extension-highlight": "^2.0.0-beta.35", 23 | "@tiptap/extension-placeholder": "^2.0.0-beta.53", 24 | "@tiptap/extension-underline": "^2.0.0-beta.25", 25 | "@tiptap/extension-unique-id": "^2.0.0-beta.4", 26 | "@tiptap/react": "^2.0.0-beta.114", 27 | "@tiptap/starter-kit": "^2.0.0-beta.191", 28 | "@tiptap/suggestion": "^2.0.0-beta.97", 29 | "@trpc/client": "^9.26.0", 30 | "@trpc/next": "^9.26.0", 31 | "@trpc/react": "^9.26.0", 32 | "@trpc/server": "^9.26.0", 33 | "classnames": "^2.3.1", 34 | "formik": "^2.2.9", 35 | "framer-motion": "^6.5.1", 36 | "friendly-username-generator": "^2.0.4", 37 | "fuse.js": "^6.6.2", 38 | "jotai": "^1.7.5", 39 | "katex": "^0.16.0", 40 | "kbar": "^0.1.0-beta.36", 41 | "lodash.clonedeep": "^4.5.0", 42 | "lodash.debounce": "^4.0.8", 43 | "lodash.isequal": "^4.5.0", 44 | "lowlight": "^2.7.0", 45 | "next": "^12.2.2", 46 | "next-auth": "^4.10.0", 47 | "next-themes": "^0.2.0", 48 | "prosemirror-commands": "^1.3.0", 49 | "prosemirror-inputrules": "^1.2.0", 50 | "prosemirror-state": "^1.4.1", 51 | "react": "^18.2.0", 52 | "react-contenteditable": "^3.3.6", 53 | "react-dom": "^18.2.0", 54 | "react-hot-toast": "^2.3.0", 55 | "react-icons": "^4.4.0", 56 | "react-popper": "^2.3.0", 57 | "react-query": "^3.39.1", 58 | "react-resizable-layout": "^0.3.1", 59 | "react-use": "^17.4.0", 60 | "react-use-hover": "^2.0.0", 61 | "strip-attributes": "^0.2.0", 62 | "styled-components": "^5.3.5", 63 | "superjson": "^1.9.1", 64 | "tippy.js": "^6.3.7", 65 | "ui": "*", 66 | "use-debounce": "^8.0.2", 67 | "use-text-selection": "^1.1.5", 68 | "uuid": "^8.3.2", 69 | "zod": "^3.17.3", 70 | "zustand": "^4.0.0-rc.1" 71 | }, 72 | "devDependencies": { 73 | "@types/katex": "^0.14.0", 74 | "@types/lodash.clonedeep": "^4.5.7", 75 | "@types/lodash.debounce": "^4.0.7", 76 | "@types/lodash.isequal": "^4.5.6", 77 | "@types/node": "^17.0.12", 78 | "@types/react": "^18.0.15", 79 | "@types/react-dom": "^18.0.6", 80 | "@types/react-sortable-tree": "^0.3.15", 81 | "eslint": "7.32.0", 82 | "eslint-config-custom": "*", 83 | "next-transpile-modules": "9.0.0", 84 | "patch-package": "^6.4.7", 85 | "prisma": "^4.0.0", 86 | "tailwind-config": "*", 87 | "tsconfig": "*", 88 | "typescript": "^4.5.3" 89 | }, 90 | "resolutions": { 91 | "@types/react": "^18.0.15", 92 | "@types/react-dom": "^18.0.6" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /apps/web/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { httpBatchLink } from "@trpc/client/links/httpBatchLink"; 2 | import { withTRPC } from "@trpc/next"; 3 | import { KBarProvider } from "kbar"; 4 | import { SessionProvider } from "next-auth/react"; 5 | import { ThemeProvider } from "next-themes"; 6 | import { AppType } from "next/dist/shared/lib/utils"; 7 | import { FunctionComponent } from "react"; 8 | import { Toaster } from "react-hot-toast"; 9 | import superjson from "superjson"; 10 | import { AppRouter } from "../server/routers/_app"; 11 | import "../styles/globals.css"; 12 | import "../styles/lowlight.css"; 13 | // import "@benrbray/prosemirror-math/style/math.css"; 14 | // import "katex/dist/katex.min.css"; 15 | import { useRouter } from "next/router"; 16 | import { MdHome, MdSavings, MdSubscriptions } from "react-icons/md"; 17 | import { CommandPallette } from "../modules/kbar/CommandPallette"; 18 | 19 | const MyApp: AppType = ({ 20 | Component, 21 | pageProps: { session, ...pageProps }, 22 | }) => { 23 | const C = Component as FunctionComponent; 24 | const router = useRouter(); 25 | 26 | return ( 27 | 28 | , 34 | perform: () => router.push("/dashboard"), 35 | }, 36 | { 37 | id: "rewards", 38 | name: "Rewards", 39 | icon: , 40 | perform: () => router.push("/rewards"), 41 | }, 42 | { 43 | id: "monetization", 44 | name: "Monetization", 45 | icon: , 46 | perform: () => router.push("/monetization"), 47 | }, 48 | ]} 49 | > 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | }; 59 | 60 | export default withTRPC({ 61 | config({}) { 62 | /** 63 | * If you want to use SSR, you need to use the server's full URL 64 | * @link https://trpc.io/docs/ssr 65 | */ 66 | const url = process.env.VERCEL_URL 67 | ? `https://${process.env.VERCEL_URL}/api/trpc` 68 | : "http://localhost:8080/api/trpc"; 69 | 70 | return { 71 | links: [ 72 | // loggerLink({ 73 | // enabled: (opts) => 74 | // process.env.NODE_ENV === "development" || 75 | // (opts.direction === "down" && opts.result instanceof Error), 76 | // }), 77 | httpBatchLink({ url }), 78 | ], 79 | transformer: superjson, 80 | /** 81 | * @link https://react-query.tanstack.com/reference/QueryClient 82 | */ 83 | // queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } }, 84 | }; 85 | }, 86 | /** 87 | * @link https://trpc.io/docs/ssr 88 | */ 89 | ssr: false, 90 | })(MyApp); 91 | -------------------------------------------------------------------------------- /apps/web/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | class MyDocument extends Document { 4 | render() { 5 | return ( 6 | 7 | 8 | 9 |
    10 | 11 | 12 | 13 | ); 14 | } 15 | } 16 | 17 | export default MyDocument; 18 | -------------------------------------------------------------------------------- /apps/web/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 2 | import NextAuth, { NextAuthOptions } from "next-auth"; 3 | import GitHubProvider from "next-auth/providers/github"; 4 | import GoogleProvider from "next-auth/providers/google"; 5 | import { prisma } from "../../../server/prisma"; 6 | import { generateUsername } from "friendly-username-generator"; 7 | import { env } from "../../../server/env"; 8 | 9 | export const authOptions: NextAuthOptions = { 10 | // Configure one or more authentication providers 11 | adapter: { 12 | ...PrismaAdapter(prisma), 13 | createUser: (data) => { 14 | return prisma.user.create({ 15 | data: { ...data, username: generateUsername() }, 16 | }); 17 | }, 18 | }, 19 | providers: [ 20 | GitHubProvider({ 21 | clientId: env.GITHUB_CLIENT_ID, 22 | clientSecret: env.GITHUB_CLIENT_SECRET, 23 | }), 24 | GoogleProvider({ 25 | clientId: env.GOOGLE_CLIENT_ID, 26 | clientSecret: env.GOOGLE_CLIENT_SECRET, 27 | }), 28 | ], 29 | callbacks: { 30 | session: async ({ session, token }) => { 31 | if (session?.user) { 32 | session.user.id = token.uid as string; 33 | } 34 | return session; 35 | }, 36 | jwt: async ({ user, token }) => { 37 | if (user) { 38 | token.uid = user.id; 39 | } 40 | return token; 41 | }, 42 | }, 43 | session: { 44 | strategy: "jwt", 45 | }, 46 | pages: { 47 | signIn: "/", 48 | }, 49 | secret: env.NEXT_AUTH_SECRET, 50 | }; 51 | 52 | export default NextAuth(authOptions); 53 | -------------------------------------------------------------------------------- /apps/web/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains tRPC's HTTP response handler 3 | */ 4 | import * as trpcNext from "@trpc/server/adapters/next"; 5 | import { createContext } from "../../../server/context"; 6 | import { appRouter } from "../../../server/routers/_app"; 7 | // import { createContext } from "~/server/context"; 8 | // import { appRouter } from "~/server/routers/_app"; 9 | 10 | export default trpcNext.createNextApiHandler({ 11 | router: appRouter, 12 | /** 13 | * @link https://trpc.io/docs/context 14 | */ 15 | createContext, 16 | /** 17 | * @link https://trpc.io/docs/error-handling 18 | */ 19 | onError({ error }) { 20 | if (error.code === "INTERNAL_SERVER_ERROR") { 21 | // send to bug reporting 22 | console.error("Something went wrong", error); 23 | } 24 | }, 25 | /** 26 | * Enable query batching 27 | */ 28 | batching: { 29 | enabled: true, 30 | }, 31 | /** 32 | * @link https://trpc.io/docs/caching#api-response-caching 33 | */ 34 | // responseMeta() { 35 | // // ... 36 | // }, 37 | }); 38 | -------------------------------------------------------------------------------- /apps/web/pages/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IconBadge, 3 | IconBeach, 4 | IconCreditCard, 5 | IconListCheck, 6 | IconPencil, 7 | IconPlus, 8 | IconTrophy, 9 | } from "@tabler/icons"; 10 | import { NextPage } from "next"; 11 | import { Button, ThemeIcon } from "ui"; 12 | import { DashboardLayout } from "../modules/layout/DashboardLayout"; 13 | 14 | const Dashboard: NextPage = () => { 15 | return ( 16 | 17 |
    18 |

    19 | Getting Started 20 |

    21 |

    22 | Presage is a feature packed platform with an intuitive design. The 23 | getting started page is supposed to be used as a FAQ, guide, and 24 | reference. 25 |

    26 | {/* */} 34 |
    35 |
    36 | 37 | 38 | 39 |

    40 | Write 41 |

    42 |

    43 | Write drafts using our all-inclusive editor. Structure your 44 | content with folders and publications. 45 |

    46 |
    47 |
    48 | 49 | 50 | 51 |

    52 | Publish 53 |

    54 |

    55 | Publish your content with built-in comments, reactions, and 56 | sharing. 57 |

    58 |
    59 | 60 |
    61 | 62 | 63 | 64 |

    65 | Reward sharing 66 |

    67 |

    68 | Incentive your audience to share your content by rewarding them 69 | with a "twitch channel points" like system. 70 |

    71 |
    72 |
    73 | 74 | 75 | 76 |

    77 | Upgrade to Pro{" "} 78 | 79 | $12/mo 80 | 81 |

    82 |

    83 | Incentive your audience to share your content by rewarding them 84 | with a "twitch channel points" like system. 85 |

    86 |
    87 | 88 |
    89 | 90 | 91 | 92 |

    93 | Subscriptions 94 |

    95 |

    96 | Monetize your content by adding a subscriber-only paywall for any 97 | of your articles. 98 |

    99 |
    100 |
    101 |

    102 | Read our{" "} 103 | 104 | getting started wiki 105 | {" "} 106 | for more details. 107 |

    108 |
    109 |
    110 | ); 111 | }; 112 | 113 | export default Dashboard; 114 | -------------------------------------------------------------------------------- /apps/web/pages/editor/[id].tsx: -------------------------------------------------------------------------------- 1 | import { IconDownload, IconMoon, IconSun, IconUpload } from "@tabler/icons"; 2 | import { Field, Form, Formik } from "formik"; 3 | import { useAtom } from "jotai"; 4 | import { useHydrateAtoms } from "jotai/utils"; 5 | import { useKBar } from "kbar"; 6 | import isEqual from "lodash.isequal"; 7 | import { GetServerSideProps } from "next"; 8 | import { useTheme } from "next-themes"; 9 | import React, { useEffect, useMemo, useRef, useState } from "react"; 10 | import ContentEditable from "react-contenteditable"; 11 | import { MdOpenInNew, MdOutlineMoreHoriz } from "react-icons/md"; 12 | import { Button, Input, Popover } from "ui"; 13 | import { collapseAtom, currentFileAtom } from "../../lib/store"; 14 | import { InferQueryOutput, trpc } from "../../lib/trpc"; 15 | import { AutoSave } from "../../modules/editor/FormikAutoSave"; 16 | import { RichTextEditor } from "../../modules/editor/RichTextEditor"; 17 | import { DashboardLayout } from "../../modules/layout/DashboardLayout"; 18 | import { FloatingActions } from "../../modules/layout/FloatingActions"; 19 | 20 | interface EditorPageProps { 21 | id: string; 22 | } 23 | 24 | const EditorPage: React.FC = ({ id }) => { 25 | useHydrateAtoms([ 26 | [currentFileAtom, { draftId: id, absolutePath: [], stringPath: [] }], 27 | ] as const); 28 | 29 | const [_currentDraft, setCurrentDraft] = useAtom(currentFileAtom); 30 | const updateDraft = trpc.useMutation(["drafts.update"]); 31 | 32 | useEffect( 33 | () => setCurrentDraft((prev) => ({ ...prev, draftId: id })), 34 | [id, setCurrentDraft] 35 | ); 36 | 37 | const { theme } = useTheme(); 38 | const { data: draft, isLoading } = trpc.useQuery(["drafts.byId", { id }]); 39 | const draftTitleRef = useRef(null); 40 | const utils = trpc.useContext(); 41 | const initialValues = useMemo(() => { 42 | return { 43 | title: draft?.title || "", 44 | content: draft?.content as any, 45 | description: draft?.description || "", 46 | canonicalUrl: draft?.canonicalUrl || "", 47 | published: draft?.published || false, 48 | private: draft?.private || false, 49 | }; 50 | }, [draft]); 51 | 52 | return ( 53 | 54 | 55 | { 58 | if (!isEqual(values, initialValues) && !isLoading) { 59 | updateDraft.mutate( 60 | { id, ...values }, 61 | { 62 | onSuccess(data) { 63 | if (data.title !== draft?.title) { 64 | utils.setQueryData(["drafts.recursive"], (( 65 | old: InferQueryOutput<"drafts.recursive"> 66 | ) => { 67 | if (old) { 68 | const dfs = (node: any) => { 69 | for (let i = 0; i < node.drafts.length; i++) { 70 | if (node.drafts[i].id === id) { 71 | node.drafts[i] = { 72 | ...node.drafts[i], 73 | title: data.title, 74 | }; 75 | return; 76 | } 77 | } 78 | for (const child of node.children || []) { 79 | dfs(child); 80 | } 81 | }; 82 | dfs(old); 83 | return old; 84 | } 85 | }) as any); 86 | utils.setQueryData(["drafts.byId", { id }], data); 87 | } 88 | }, 89 | } 90 | ); 91 | } 92 | }} 93 | enableReinitialize 94 | > 95 | {({ values, setFieldValue }) => ( 96 |
    97 |
    98 |
    99 |
    100 | {values.published && ( 101 | 165 |
    166 | 167 |
    168 |
    169 |
    170 | 171 | { 175 | if (e.key === "Enter") { 176 | e.preventDefault(); 177 | return false; 178 | } 179 | }} 180 | onPaste={(e) => { 181 | // return only text content 182 | e.preventDefault(); 183 | const text = e.clipboardData.getData("text/plain"); 184 | document.execCommand("insertHTML", false, text); 185 | }} 186 | innerRef={draftTitleRef} 187 | disabled={false} 188 | html={values.title} 189 | onChange={() => { 190 | setFieldValue( 191 | "title", 192 | draftTitleRef.current?.innerText || "" 193 | ); 194 | }} 195 | tagName="h1" 196 | /> 197 | 198 |
    199 | 200 |
    201 | )} 202 |
    203 |
    204 | ); 205 | }; 206 | 207 | export const getServerSideProps: GetServerSideProps = async (context) => { 208 | return { 209 | props: { id: context.query.id }, 210 | }; 211 | }; 212 | 213 | export default EditorPage; 214 | -------------------------------------------------------------------------------- /apps/web/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import { LandingPage } from "../modules/landing-page/LandingPage"; 3 | 4 | const Home: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default Home; 9 | -------------------------------------------------------------------------------- /apps/web/pages/view/[id].tsx: -------------------------------------------------------------------------------- 1 | import { EditorContent, JSONContent, useEditor } from "@tiptap/react"; 2 | import { createSSGHelpers } from "@trpc/react/ssg"; 3 | import { 4 | GetServerSideProps, 5 | InferGetServerSidePropsType, 6 | NextPage, 7 | } from "next"; 8 | import Head from "next/head"; 9 | import { useEffect } from "react"; 10 | import { IconType } from "react-icons"; 11 | import { 12 | MdBookmark, 13 | MdChromeReaderMode, 14 | MdFavorite, 15 | MdInfo, 16 | MdReplyAll, 17 | } from "react-icons/md"; 18 | import superjson from "superjson"; 19 | import { Button } from "ui"; 20 | import { InferQueryOutput, trpc } from "../../lib/trpc"; 21 | import { 22 | rteClass, 23 | viewOnlyExtensions, 24 | } from "../../modules/editor/RichTextEditor"; 25 | import { FloatingActions } from "../../modules/layout/FloatingActions"; 26 | import { createContext } from "../../server/context"; 27 | import { appRouter } from "../../server/routers/_app"; 28 | 29 | function formatDate(date: Date) { 30 | return date.toLocaleDateString("en-US", { 31 | month: "short", 32 | day: "numeric", 33 | year: "numeric", 34 | }); 35 | } 36 | 37 | const ReactionButton: React.FC<{ 38 | draftId: string; 39 | type: "Favorite" | "Bookmark" | "Share"; 40 | }> = ({ type, draftId }) => { 41 | const { data: reactions } = trpc.useQuery([ 42 | "reactions.byDraftId", 43 | { id: draftId }, 44 | ]); 45 | const toggleReaction = trpc.useMutation(["reactions.toggle"]); 46 | const utils = trpc.useContext(); 47 | 48 | let ReactionIcon: IconType; 49 | 50 | if (type === "Favorite") { 51 | ReactionIcon = MdFavorite; 52 | } else if (type === "Bookmark") { 53 | ReactionIcon = MdBookmark; 54 | } else if (type === "Share") { 55 | ReactionIcon = MdReplyAll; 56 | } 57 | 58 | return ( 59 | 89 | ); 90 | }; 91 | 92 | const ViewPage: NextPage< 93 | InferGetServerSidePropsType 94 | > = ({ id }) => { 95 | const { data: draft } = trpc.useQuery(["drafts.byId", { id }]); 96 | const editor = useEditor({ 97 | editable: false, 98 | extensions: viewOnlyExtensions, 99 | editorProps: { 100 | attributes: { 101 | class: rteClass + " !ml-0", 102 | }, 103 | }, 104 | }); 105 | 106 | useEffect(() => { 107 | if (draft?.content && editor) 108 | !editor.isDestroyed && 109 | editor.commands.setContent(draft?.content as JSONContent); 110 | }, [draft?.content, editor]); 111 | 112 | useEffect(() => { 113 | return () => { 114 | editor?.destroy(); 115 | }; 116 | }, [editor]); 117 | 118 | return ( 119 |
    120 | 121 | {draft?.title} 122 | 123 | 124 | 125 | 126 | 127 | 128 |
    129 | 137 | 140 | {/* */} 143 |
    144 |
    145 |
    146 | {draft?.updatedAt ? formatDate(draft.updatedAt) : null} 147 |
    148 |

    149 | {draft?.title} 150 |

    151 |

    152 | {draft?.description} 153 |

    154 |
    155 |
    156 | Author profile picture 161 |
    162 |

    {draft?.user.name}

    163 |

    164 | {draft?.user.username} 165 |

    166 |
    167 |
    168 | 169 | 170 | 171 |
    172 |
    173 |
    174 | 175 |
    176 | Share this article to gain points. Publish on{" "} 177 | Presage. 178 |
    179 |
    180 | 181 |
    182 | 183 |
    184 |
    185 | ); 186 | }; 187 | 188 | export const getServerSideProps: GetServerSideProps = async (context) => { 189 | const id = context.query?.id as string; 190 | const ssg = createSSGHelpers({ 191 | router: appRouter, 192 | transformer: superjson, 193 | ctx: await createContext({ 194 | req: context.req as any, 195 | res: context.res as any, 196 | }), 197 | }); 198 | 199 | await ssg.fetchQuery("drafts.byId", { id }); 200 | 201 | return { 202 | props: { 203 | id, 204 | trpcState: ssg.dehydrate(), 205 | }, 206 | }; 207 | }; 208 | 209 | export default ViewPage; 210 | -------------------------------------------------------------------------------- /apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("tailwind-config/postcss.config"); 2 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20220802073129_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "ReactionType" AS ENUM ('Favorite', 'Bookmark', 'Share'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "Account" ( 6 | "id" TEXT NOT NULL, 7 | "userId" TEXT NOT NULL, 8 | "type" TEXT NOT NULL, 9 | "provider" TEXT NOT NULL, 10 | "providerAccountId" TEXT NOT NULL, 11 | "refresh_token" TEXT, 12 | "access_token" TEXT, 13 | "expires_at" INTEGER, 14 | "token_type" TEXT, 15 | "scope" TEXT, 16 | "id_token" TEXT, 17 | "session_state" TEXT, 18 | 19 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "Session" ( 24 | "id" TEXT NOT NULL, 25 | "sessionToken" TEXT NOT NULL, 26 | "userId" TEXT NOT NULL, 27 | "expires" TIMESTAMP(3) NOT NULL, 28 | 29 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 30 | ); 31 | 32 | -- CreateTable 33 | CREATE TABLE "User" ( 34 | "id" TEXT NOT NULL, 35 | "name" TEXT, 36 | "email" TEXT, 37 | "emailVerified" TIMESTAMP(3), 38 | "image" TEXT, 39 | "username" TEXT NOT NULL, 40 | 41 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 42 | ); 43 | 44 | -- CreateTable 45 | CREATE TABLE "VerificationToken" ( 46 | "identifier" TEXT NOT NULL, 47 | "token" TEXT NOT NULL, 48 | "expires" TIMESTAMP(3) NOT NULL 49 | ); 50 | 51 | -- CreateTable 52 | CREATE TABLE "Folder" ( 53 | "id" TEXT NOT NULL, 54 | "name" TEXT NOT NULL, 55 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 56 | "userId" TEXT NOT NULL, 57 | "parentId" TEXT, 58 | 59 | CONSTRAINT "Folder_pkey" PRIMARY KEY ("id") 60 | ); 61 | 62 | -- CreateTable 63 | CREATE TABLE "Draft" ( 64 | "id" TEXT NOT NULL, 65 | "title" TEXT NOT NULL, 66 | "content" JSONB, 67 | "tags" TEXT[], 68 | "canonicalUrl" TEXT, 69 | "description" TEXT, 70 | "paywalled" BOOLEAN NOT NULL DEFAULT false, 71 | "published" BOOLEAN NOT NULL DEFAULT false, 72 | "private" BOOLEAN NOT NULL DEFAULT false, 73 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 74 | "updatedAt" TIMESTAMP(3) NOT NULL, 75 | "folderId" TEXT, 76 | "userId" TEXT NOT NULL, 77 | 78 | CONSTRAINT "Draft_pkey" PRIMARY KEY ("id") 79 | ); 80 | 81 | -- CreateTable 82 | CREATE TABLE "Reaction" ( 83 | "type" "ReactionType" NOT NULL, 84 | "draftId" TEXT NOT NULL, 85 | "userId" TEXT NOT NULL, 86 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 87 | 88 | CONSTRAINT "Reaction_pkey" PRIMARY KEY ("type","draftId","userId") 89 | ); 90 | 91 | -- CreateIndex 92 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); 93 | 94 | -- CreateIndex 95 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 96 | 97 | -- CreateIndex 98 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 99 | 100 | -- CreateIndex 101 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 102 | 103 | -- CreateIndex 104 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); 105 | 106 | -- CreateIndex 107 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); 108 | 109 | -- AddForeignKey 110 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 111 | 112 | -- AddForeignKey 113 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 114 | 115 | -- AddForeignKey 116 | ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 117 | 118 | -- AddForeignKey 119 | ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE; 120 | 121 | -- AddForeignKey 122 | ALTER TABLE "Draft" ADD CONSTRAINT "Draft_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE; 123 | 124 | -- AddForeignKey 125 | ALTER TABLE "Draft" ADD CONSTRAINT "Draft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 126 | 127 | -- AddForeignKey 128 | ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_draftId_fkey" FOREIGN KEY ("draftId") REFERENCES "Draft"("id") ON DELETE CASCADE ON UPDATE CASCADE; 129 | 130 | -- AddForeignKey 131 | ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 132 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20220802073542_reaction_count/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "ReactionCount" ( 3 | "id" TEXT NOT NULL, 4 | "draftId" TEXT NOT NULL, 5 | "type" "ReactionType" NOT NULL, 6 | "count" INTEGER NOT NULL, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | 9 | CONSTRAINT "ReactionCount_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | CREATE FUNCTION reaction_count() RETURNS TRIGGER 13 | LANGUAGE plpgsql AS 14 | $$ 15 | BEGIN 16 | IF (TG_OP = 'INSERT') THEN 17 | UPDATE "ReactionCount" 18 | SET count = count + 1 19 | WHERE "draftId" = NEW."draftId" AND "type" = NEW."type"; 20 | ELSEIF (TG_OP = 'DELETE') THEN 21 | UPDATE "ReactionCount" 22 | SET count = count - 1 23 | WHERE "draftId" = OLD."draftId" AND "type" = OLD."type"; 24 | END IF; 25 | RETURN NULL; 26 | END; 27 | $$; 28 | 29 | -- create three react_count rows on new draft 30 | CREATE FUNCTION populate_counts() RETURNS TRIGGER 31 | LANGUAGE plpgsql AS 32 | $$ 33 | BEGIN 34 | IF (TG_OP = 'INSERT') THEN 35 | INSERT INTO "ReactionCount" ("draftId", "type", "count") 36 | VALUES (NEW.id, 'Favorite', 0); 37 | 38 | INSERT INTO "ReactionCount" ("draftId", "type", "count") 39 | VALUES (NEW.id, 'Bookmark', 0); 40 | 41 | INSERT INTO "ReactionCount" ("draftId", "type", "count") 42 | VALUES (NEW.id, 'Share', 0); 43 | END IF; 44 | RETURN NULL; 45 | END; 46 | $$; 47 | 48 | CREATE TRIGGER reaction_count_insert 49 | AFTER INSERT ON "Draft" 50 | FOR EACH ROW EXECUTE PROCEDURE populate_counts(); 51 | 52 | CREATE CONSTRAINT TRIGGER sync_reaction_count 53 | AFTER INSERT OR DELETE ON "Reaction" 54 | DEFERRABLE INITIALLY DEFERRED 55 | FOR EACH ROW EXECUTE PROCEDURE reaction_count(); 56 | 57 | -- CREATE TRIGGER reaction_count_trunc 58 | -- AFTER TRUNCATE ON "Draft" 59 | -- FOR EACH STATEMENT EXECUTE PROCEDURE reaction_count(); -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20220802080439_remove_reaction_id/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `ReactionCount` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - You are about to drop the column `id` on the `ReactionCount` table. All the data in the column will be lost. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "ReactionCount" DROP CONSTRAINT "ReactionCount_pkey", 10 | DROP COLUMN "id", 11 | ADD CONSTRAINT "ReactionCount_pkey" PRIMARY KEY ("draftId", "type"); 12 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /apps/web/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgres" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Account { 14 | id String @id @default(cuid()) 15 | userId String 16 | type String 17 | provider String 18 | providerAccountId String 19 | refresh_token String? @db.Text 20 | access_token String? @db.Text 21 | expires_at Int? 22 | token_type String? 23 | scope String? 24 | id_token String? @db.Text 25 | session_state String? 26 | 27 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 28 | 29 | @@unique([provider, providerAccountId]) 30 | } 31 | 32 | model Session { 33 | id String @id @default(cuid()) 34 | sessionToken String @unique 35 | userId String 36 | expires DateTime 37 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 38 | } 39 | 40 | model User { 41 | id String @id @default(cuid()) 42 | name String? 43 | email String? @unique 44 | emailVerified DateTime? 45 | image String? 46 | username String @unique 47 | accounts Account[] 48 | sessions Session[] 49 | drafts Draft[] 50 | folders Folder[] 51 | Reaction Reaction[] 52 | } 53 | 54 | model VerificationToken { 55 | identifier String 56 | token String @unique 57 | expires DateTime 58 | 59 | @@unique([identifier, token]) 60 | } 61 | 62 | model Folder { 63 | id String @id @default(cuid()) 64 | name String 65 | createdAt DateTime @default(now()) 66 | 67 | userId String 68 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 69 | 70 | parentId String? 71 | parent Folder? @relation("SubFolders", fields: [parentId], references: [id], onDelete: Cascade) 72 | children Folder[] @relation("SubFolders") 73 | drafts Draft[] 74 | } 75 | 76 | model Draft { 77 | id String @id @default(cuid()) 78 | title String 79 | content Json? 80 | tags String[] 81 | canonicalUrl String? 82 | description String? 83 | 84 | paywalled Boolean @default(false) 85 | published Boolean @default(false) 86 | private Boolean @default(false) 87 | createdAt DateTime @default(now()) 88 | updatedAt DateTime @updatedAt 89 | 90 | folderId String? 91 | folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade) 92 | userId String 93 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 94 | // tags DraftOnTags[] 95 | Reaction Reaction[] 96 | } 97 | 98 | model ReactionCount { 99 | draftId String 100 | type ReactionType 101 | count Int 102 | createdAt DateTime @default(now()) 103 | 104 | @@id([draftId, type]) 105 | } 106 | 107 | enum ReactionType { 108 | Favorite 109 | Bookmark 110 | Share 111 | } 112 | 113 | model Reaction { 114 | type ReactionType 115 | draftId String 116 | draft Draft @relation(fields: [draftId], references: [id], onDelete: Cascade) 117 | userId String 118 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 119 | createdAt DateTime @default(now()) 120 | 121 | @@id([type, draftId, userId]) 122 | } 123 | 124 | // model Tag { 125 | // id String @id @default(cuid()) 126 | // name String @unique 127 | // createdAt DateTime @default(now()) 128 | 129 | // draftId String 130 | // drafts DraftOnTags[] 131 | // } 132 | 133 | // model DraftOnTags { 134 | // draftId String 135 | // tagId String 136 | // draft Draft @relation(fields: [draftId], references: [id], onDelete: Cascade) 137 | // tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) 138 | 139 | // @@id([draftId, tagId]) 140 | // } 141 | -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderinblack08/presage/60a4a9dd95e944aaea434f0654d5376cd21c9014/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/fonts/eudoxus-sans-var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderinblack08/presage/60a4a9dd95e944aaea434f0654d5376cd21c9014/apps/web/public/fonts/eudoxus-sans-var.woff2 -------------------------------------------------------------------------------- /apps/web/public/static/animals/bird.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderinblack08/presage/60a4a9dd95e944aaea434f0654d5376cd21c9014/apps/web/public/static/animals/bird.png -------------------------------------------------------------------------------- /apps/web/public/static/animals/rabbit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderinblack08/presage/60a4a9dd95e944aaea434f0654d5376cd21c9014/apps/web/public/static/animals/rabbit.png -------------------------------------------------------------------------------- /apps/web/public/static/animals/rat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderinblack08/presage/60a4a9dd95e944aaea434f0654d5376cd21c9014/apps/web/public/static/animals/rat.png -------------------------------------------------------------------------------- /apps/web/public/static/animals/walrus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderinblack08/presage/60a4a9dd95e944aaea434f0654d5376cd21c9014/apps/web/public/static/animals/walrus.png -------------------------------------------------------------------------------- /apps/web/public/static/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderinblack08/presage/60a4a9dd95e944aaea434f0654d5376cd21c9014/apps/web/public/static/dashboard.png -------------------------------------------------------------------------------- /apps/web/server/context.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import * as trpcNext from "@trpc/server/adapters/next"; 3 | import { unstable_getServerSession } from "next-auth"; 4 | import { authOptions } from "../pages/api/auth/[...nextauth]"; 5 | 6 | import { prisma } from "./prisma"; 7 | 8 | export const createContext = async ({ 9 | req, 10 | res, 11 | }: trpcNext.CreateNextContextOptions) => { 12 | // const session = await getSessionFromCookie({ req: req as NextApiRequest }); 13 | const session = await unstable_getServerSession(req, res, authOptions); 14 | 15 | return { 16 | req, 17 | res, 18 | session, 19 | prisma, 20 | }; 21 | }; 22 | 23 | export type Context = trpc.inferAsyncReturnType; 24 | -------------------------------------------------------------------------------- /apps/web/server/createRouter.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import { Context } from "./context"; 3 | 4 | /** 5 | * Helper function to create a router with context 6 | */ 7 | export function createRouter() { 8 | return trpc.router(); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/server/env.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * This file is included in `/next.config.js` which ensures the app isn't built with invalid env vars. 4 | * It has to be a `.js`-file to be imported there. 5 | */ 6 | const { z } = require("zod"); 7 | 8 | /*eslint sort-keys: "error"*/ 9 | const envSchema = z.object({ 10 | DATABASE_URL: z.string().url(), 11 | GITHUB_CLIENT_ID: z.string(), 12 | GITHUB_CLIENT_SECRET: z.string(), 13 | GOOGLE_CLIENT_ID: z.string(), 14 | GOOGLE_CLIENT_SECRET: z.string(), 15 | NEXTAUTH_URL: z.string(), 16 | NEXT_AUTH_SECRET: z.string(), 17 | NODE_ENV: z.enum(["development", "test", "production"]), 18 | }); 19 | 20 | const env = envSchema.safeParse(process.env); 21 | 22 | if (!env.success) { 23 | console.error( 24 | "❌ Invalid environment variables:", 25 | JSON.stringify(env.error.format(), null, 4) 26 | ); 27 | process.exit(1); 28 | } 29 | 30 | module.exports.env = env.data; 31 | -------------------------------------------------------------------------------- /apps/web/server/prisma.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Instantiates a single instance PrismaClient and save it on the global object. 3 | * @link https://www.prisma.io/docs/support/help-articles/nextjs-prisma-client-dev-practices 4 | */ 5 | import { env } from "./env"; 6 | import { PrismaClient } from "@prisma/client"; 7 | 8 | const prismaGlobal = global as typeof global & { 9 | prisma?: PrismaClient; 10 | }; 11 | 12 | export const prisma: PrismaClient = 13 | prismaGlobal.prisma || 14 | new PrismaClient({ 15 | log: 16 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], 17 | }); 18 | 19 | if (env.NODE_ENV !== "production") { 20 | prismaGlobal.prisma = prisma; 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/server/routers/_app.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains the root router of your tRPC-backend 3 | */ 4 | import { createRouter } from "../createRouter"; 5 | import superjson from "superjson"; 6 | import { folderRouter } from "./folders"; 7 | import { TRPCError } from "@trpc/server"; 8 | import { draftsRouter } from "./drafts"; 9 | import { reactionsRouter } from "./reactions"; 10 | 11 | /** 12 | * Create your application's root router 13 | * If you want to use SSG, you need export this 14 | * @link https://trpc.io/docs/ssg 15 | * @link https://trpc.io/docs/router 16 | */ 17 | export const appRouter = createRouter() 18 | /** 19 | * Add data transformers 20 | * @link https://trpc.io/docs/data-transformers 21 | */ 22 | .transformer(superjson) 23 | .middleware(async ({ ctx, meta, next }) => { 24 | if (!ctx.session?.user && meta?.hasAuth) { 25 | throw new TRPCError({ code: "UNAUTHORIZED" }); 26 | } 27 | return next(); 28 | }) 29 | /** 30 | * Optionally do custom error (type safe!) formatting 31 | * @link https://trpc.io/docs/error-formatting 32 | */ 33 | // .formatError(({ shape, error }) => { }) 34 | /** 35 | * Add a health check endpoint to be called with `/api/trpc/healthz` 36 | */ 37 | .query("healthz", { 38 | async resolve() { 39 | return "yay!"; 40 | }, 41 | }) 42 | .merge("folders.", folderRouter) 43 | .merge("drafts.", draftsRouter) 44 | .merge("reactions.", reactionsRouter); 45 | 46 | export type AppRouter = typeof appRouter; 47 | -------------------------------------------------------------------------------- /apps/web/server/routers/drafts.ts: -------------------------------------------------------------------------------- 1 | import { Draft, Folder } from "@prisma/client"; 2 | import { TRPCError } from "@trpc/server"; 3 | import { z } from "zod"; 4 | import { createRouter } from "../createRouter"; 5 | import { prisma } from "../prisma"; 6 | 7 | async function convertMaterializedPaths(data: any[]) { 8 | const o: any = {}; 9 | for (const { id, path, ...rest } of data) { 10 | const parent = path[path.length - 2]; 11 | Object.assign((o[id] = o[id] || {}), { 12 | id, 13 | path, 14 | ...rest, 15 | drafts: await prisma.draft.findMany({ 16 | where: { folderId: id }, 17 | orderBy: { updatedAt: "desc" }, 18 | select: { 19 | id: true, 20 | title: true, 21 | createdAt: true, 22 | }, 23 | }), 24 | }); 25 | o[parent] = o[parent] || {}; 26 | o[parent].children = o[parent].children || []; 27 | o[parent].children.push(o[id]); 28 | } 29 | return o.undefined.children; 30 | } 31 | 32 | export const draftsRouter = createRouter() 33 | .mutation("add", { 34 | meta: { hasAuth: true }, 35 | input: z.object({ 36 | title: z.string(), 37 | folderId: z.string().optional(), 38 | }), 39 | resolve: async ({ input, ctx }) => { 40 | return ctx.prisma.draft.create({ 41 | data: { ...input, userId: ctx.session?.user.id! }, 42 | }); 43 | }, 44 | }) 45 | .mutation("delete", { 46 | meta: { hasAuth: true }, 47 | input: z.object({ id: z.string() }), 48 | resolve: async ({ input, ctx }) => { 49 | return ctx.prisma.draft.delete({ where: input }); 50 | }, 51 | }) 52 | .mutation("update", { 53 | meta: { hasAuth: true }, 54 | input: z.object({ 55 | id: z.string(), 56 | title: z.string().optional(), 57 | content: z.any().optional(), 58 | folderId: z.string().optional().nullable(), 59 | published: z.boolean().optional(), 60 | canonicalUrl: z.string().optional(), 61 | description: z.string().optional(), 62 | private: z.boolean().optional(), 63 | }), 64 | resolve: async ({ input, ctx }) => { 65 | const draft = await ctx.prisma.draft.findFirstOrThrow({ 66 | where: { id: input.id }, 67 | }); 68 | if (draft.userId !== ctx.session?.user.id) { 69 | throw new TRPCError({ code: "UNAUTHORIZED" }); 70 | } 71 | return ctx.prisma.draft.update({ 72 | where: { id: input.id }, 73 | data: { ...input }, 74 | }); 75 | }, 76 | }) 77 | .query("recursive", { 78 | meta: { hasAuth: true }, 79 | resolve: async ({ ctx }) => { 80 | const result = await ctx.prisma.$queryRaw` 81 | with recursive cte (id, name, "parentId", "userId", path, depth) as ( 82 | 83 | select id, name, "parentId", "userId", array[id] as path, 1 as depth 84 | from "Folder" 85 | where "parentId" is null and "userId" = ${ctx.session?.user.id} 86 | 87 | union all 88 | 89 | select "Folder".id, 90 | "Folder".name, 91 | "Folder"."parentId", 92 | "Folder"."userId", 93 | cte.path || "Folder".id as path, 94 | cte.depth + 1 as depth 95 | from "Folder" 96 | join cte on "Folder"."parentId" = cte.id 97 | 98 | ) select * from cte order by path; 99 | `; 100 | 101 | type Node = Folder & { 102 | depth: number; 103 | path: string[]; 104 | children: Node[]; 105 | drafts: Draft[]; 106 | }; 107 | 108 | const output = { 109 | depth: 0, 110 | path: [], 111 | children: result.length 112 | ? ((await convertMaterializedPaths(result)) as Node[]) 113 | : [], 114 | drafts: await ctx.prisma.draft.findMany({ where: { folderId: null } }), 115 | }; 116 | return output; 117 | }, 118 | }) 119 | .query("byId", { 120 | input: z.object({ id: z.string() }), 121 | resolve: async ({ input, ctx }) => { 122 | return ctx.prisma.draft.findFirstOrThrow({ 123 | where: input, 124 | include: { user: true }, 125 | }); 126 | }, 127 | }); 128 | -------------------------------------------------------------------------------- /apps/web/server/routers/folders.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { z } from "zod"; 3 | import { createRouter } from "../createRouter"; 4 | 5 | export const folderRouter = createRouter() 6 | .query("byId", { 7 | input: z.object({ id: z.string() }), 8 | resolve: async ({ input, ctx }) => { 9 | return ctx.prisma.folder.findFirstOrThrow({ where: input }); 10 | }, 11 | }) 12 | .query("all", { 13 | meta: { hasAuth: true }, 14 | resolve: async ({ ctx }) => { 15 | return ctx.prisma.folder.findMany({ 16 | where: { 17 | userId: ctx.session?.user.id!, 18 | }, 19 | }); 20 | }, 21 | }) 22 | .mutation("add", { 23 | meta: { hasAuth: true }, 24 | input: z.object({ 25 | name: z.string(), 26 | parentId: z.string().optional(), 27 | }), 28 | resolve: async ({ input, ctx }) => { 29 | return ctx.prisma.folder.create({ 30 | data: { ...input, userId: ctx.session?.user.id! }, 31 | }); 32 | }, 33 | }) 34 | .mutation("update", { 35 | meta: { hasAuth: true }, 36 | input: z.object({ 37 | id: z.string(), 38 | name: z.string().optional(), 39 | parentId: z.string().optional(), 40 | }), 41 | resolve: async ({ input, ctx }) => { 42 | const folder = await ctx.prisma.folder.findFirstOrThrow({ 43 | where: { id: input.id }, 44 | }); 45 | if (folder.userId !== ctx.session?.user.id) { 46 | throw new TRPCError({ code: "UNAUTHORIZED" }); 47 | } 48 | return ctx.prisma.folder.update({ 49 | where: { id: input.id }, 50 | data: { ...input }, 51 | }); 52 | }, 53 | }) 54 | .mutation("delete", { 55 | meta: { hasAuth: true }, 56 | input: z.object({ id: z.string() }), 57 | resolve: async ({ input, ctx }) => { 58 | return ctx.prisma.folder.delete({ where: input }); 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /apps/web/server/routers/reactions.ts: -------------------------------------------------------------------------------- 1 | import { ReactionType } from "@prisma/client"; 2 | import { z } from "zod"; 3 | import { createRouter } from "../createRouter"; 4 | 5 | export const reactionsRouter = createRouter() 6 | .query("byDraftId", { 7 | input: z.object({ id: z.string() }), 8 | resolve: async ({ ctx, input }) => { 9 | const counts = await ctx.prisma.reactionCount.findMany({ 10 | where: { draftId: input.id }, 11 | }); 12 | const output = { 13 | status: {} as Record, 14 | counts: counts.reduce((acc, curr) => { 15 | acc[curr.type] = curr.count; 16 | return acc; 17 | }, {} as Record), 18 | }; 19 | if (ctx.session) { 20 | const myReactions = await ctx.prisma.reaction.findMany({ 21 | where: { 22 | userId: ctx.session.user.id, 23 | draftId: input.id, 24 | }, 25 | }); 26 | output.status = Object.values(ReactionType).reduce((acc, curr) => { 27 | acc[curr] = myReactions.some((r) => r.type === curr); 28 | return acc; 29 | }, {} as Record); 30 | } 31 | return output; 32 | }, 33 | }) 34 | .mutation("toggle", { 35 | meta: { hasAuth: true }, 36 | input: z.object({ 37 | draftId: z.string(), 38 | type: z.nativeEnum(ReactionType), 39 | }), 40 | resolve: async ({ ctx, input }) => { 41 | const body = { ...input, userId: ctx.session!.user.id }; 42 | const reaction = await ctx.prisma.reaction.findFirst({ where: body }); 43 | if (reaction) { 44 | await ctx.prisma.reaction.delete({ 45 | where: { type_draftId_userId: body }, 46 | }); 47 | } else { 48 | await ctx.prisma.reaction.create({ data: body }); 49 | } 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /apps/web/strip-attributes.d.ts: -------------------------------------------------------------------------------- 1 | declare module "strip-attributes"; 2 | -------------------------------------------------------------------------------- /apps/web/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url("https://rsms.me/inter/inter.css"); 2 | 3 | @font-face { 4 | font-family: "Eudoxus Sans"; 5 | font-style: normal; 6 | font-weight: 100 900; 7 | font-display: optional; 8 | src: url(/fonts/eudoxus-sans-var.woff2) format("woff2"); 9 | } 10 | 11 | [contenteditable="true"]:empty:before { 12 | content: attr(placeholder); 13 | @apply font-semibold tracking-tight text-gray-300 dark:text-gray-600; 14 | } 15 | 16 | .draggable-item { 17 | display: flex; 18 | padding: 0.5rem; 19 | /* margin: 0.5rem 0; */ 20 | @apply bg-white dark:bg-gray-900; 21 | /* box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05); */ 22 | } 23 | 24 | .draggable-item > .drag-handle { 25 | flex: 0 0 auto; 26 | position: relative; 27 | width: 1rem; 28 | height: 1rem; 29 | margin-right: 0.5rem; 30 | cursor: grab; 31 | background-image: url('data:image/svg+xml;charset=UTF-8,'); 32 | @apply dark:invert; 33 | background-repeat: no-repeat; 34 | background-size: contain; 35 | background-position: center; 36 | } 37 | 38 | .draggable-item > .content { 39 | flex: 1 1 auto; 40 | } 41 | 42 | .has-focus { 43 | @apply relative z-50; 44 | border-radius: 3px; 45 | box-shadow: 0 0 0 3px #68cef8; 46 | } 47 | 48 | /** hacky way to display placeholders */ 49 | 50 | .is-empty div .content::before { 51 | content: var(--placeholder); 52 | float: left; 53 | height: 0; 54 | pointer-events: none; 55 | @apply text-gray-300 dark:text-gray-600; 56 | } 57 | 58 | .prose mark { 59 | @apply bg-yellow-100 dark:bg-yellow-400/20 dark:text-yellow-400 rounded-lg px-1.5 py-0.5; 60 | } 61 | 62 | .prose mark > strong { 63 | @apply dark:text-yellow-400; 64 | } 65 | 66 | @tailwind base; 67 | @tailwind components; 68 | @tailwind utilities; 69 | 70 | @layer base { 71 | html, 72 | body { 73 | @apply dark:bg-gray-900; 74 | } 75 | 76 | :not(h1 > *, .font-display) { 77 | font-feature-settings: "cv11"; 78 | } 79 | 80 | span.ripple { 81 | position: absolute; 82 | z-index: 50; 83 | border-radius: 50%; 84 | transform: scale(0); 85 | animation: ripple 600ms linear; 86 | @apply pointer-events-none bg-white/20 dark:bg-white/10; 87 | } 88 | 89 | @keyframes ripple { 90 | to { 91 | transform: scale(4); 92 | opacity: 0; 93 | } 94 | } 95 | 96 | kbd { 97 | @apply border rounded-md shadow-sm w-6 h-6 inline-flex items-center justify-center dark:border-gray-800; 98 | } 99 | 100 | :root { 101 | font-size: 14px; 102 | line-height: 1.6em; 103 | @apply antialiased text-gray-900 font-medium; 104 | } 105 | 106 | .grid-border > * { 107 | border-left: 1px dashed theme("colors.gray.200"); 108 | } 109 | 110 | .grid-border > *:not(:nth-child(2n)) { 111 | border-left: none; 112 | } 113 | 114 | .grid-border > *:not(:nth-child(-n + 2)) { 115 | border-top: 1px dashed theme("colors.gray.200"); 116 | } 117 | 118 | @media (max-width: 639px) { 119 | .not-sm-grid-border { 120 | @apply grid-border; 121 | } 122 | } 123 | 124 | @media (max-width: 767px) { 125 | .feature-grid { 126 | @apply divide-dashed divide-y; 127 | } 128 | } 129 | 130 | @screen md { 131 | .feature-grid { 132 | @apply grid-border; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /apps/web/styles/lowlight.css: -------------------------------------------------------------------------------- 1 | pre { 2 | @apply !p-6 !rounded-xl !text-white !bg-gray-900; 3 | } 4 | 5 | pre code { 6 | color: inherit !important; 7 | background: transparent !important; 8 | border: none !important; 9 | padding: 0 !important; 10 | } 11 | 12 | .hljs-comment, 13 | .hljs-quote { 14 | color: #8a8a8a; 15 | } 16 | 17 | .hljs-variable, 18 | .hljs-template-variable, 19 | .hljs-attribute, 20 | .hljs-tag, 21 | .hljs-name, 22 | .hljs-regexp, 23 | .hljs-link, 24 | .hljs-name, 25 | .hljs-selector-id, 26 | .hljs-selector-class { 27 | color: #f98181; 28 | } 29 | 30 | .hljs-number, 31 | .hljs-meta, 32 | .hljs-built_in, 33 | .hljs-builtin-name, 34 | .hljs-literal, 35 | .hljs-type, 36 | .hljs-params { 37 | color: #fbbc88; 38 | } 39 | 40 | .hljs-string, 41 | .hljs-symbol, 42 | .hljs-bullet { 43 | color: #b9f18d; 44 | } 45 | 46 | .hljs-title, 47 | .hljs-section { 48 | color: #faf594; 49 | } 50 | 51 | .hljs-keyword, 52 | .hljs-selector-tag { 53 | color: #70cff8; 54 | } 55 | 56 | .hljs-emphasis { 57 | font-style: italic; 58 | } 59 | 60 | .hljs-strong { 61 | font-weight: 700; 62 | } 63 | -------------------------------------------------------------------------------- /apps/web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("tailwind-config/tailwind.config"); 2 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/nextjs.json", 3 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "server/env.js"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "apps/*", 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "build": "turbo run build", 11 | "dev": "turbo run dev --parallel", 12 | "lint": "turbo run lint", 13 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 14 | "postinstall": "patch-package" 15 | }, 16 | "devDependencies": { 17 | "autoprefixer": "^10.4.7", 18 | "eslint-config-custom": "*", 19 | "patch-package": "^6.4.7", 20 | "postcss": "^8.4.14", 21 | "postinstall-postinstall": "^2.1.0", 22 | "prettier": "latest", 23 | "tailwindcss": "^3.1.6", 24 | "turbo": "latest" 25 | }, 26 | "engines": { 27 | "npm": ">=7.0.0", 28 | "node": ">=14.0.0" 29 | }, 30 | "dependencies": {}, 31 | "packageManager": "yarn@1.22.17" 32 | } 33 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["next", "prettier"], 3 | rules: { 4 | "@next/next/no-html-link-for-pages": "off", 5 | "react/jsx-key": "off", 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "version": "0.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "eslint-config-next": "^12.0.8", 8 | "eslint-config-prettier": "^8.3.0", 9 | "eslint-plugin-react": "7.28.0" 10 | }, 11 | "publishConfig": { 12 | "access": "public" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/tailwind-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailwind-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "index.js", 6 | "files": [ 7 | "tailwind.config.js", 8 | "postcss.config.js" 9 | ], 10 | "devDependencies": { 11 | "@tailwindcss/forms": "^0.5.2", 12 | "@tailwindcss/typography": "^0.5.4" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/tailwind-config/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/tailwind-config/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "../../packages/ui/src/**/*.{ts,tsx}", 5 | "./pages/**/*.{js,ts,jsx,tsx}", 6 | "./components/**/*.{js,ts,jsx,tsx}", 7 | "./modules/**/*.{js,ts,jsx,tsx}", 8 | "./editor/**/*.{js,ts,jsx,tsx}", 9 | ], 10 | darkMode: "class", 11 | theme: { 12 | extend: { 13 | colors: { gray: require("tailwindcss/colors").zinc }, 14 | fontFamily: { 15 | sans: ["Inter", ...require("tailwindcss/defaultTheme").fontFamily.sans], 16 | display: [ 17 | "Eudoxus Sans", 18 | ...require("tailwindcss/defaultTheme").fontFamily.sans, 19 | ], 20 | }, 21 | typography: { 22 | DEFAULT: { 23 | css: { 24 | "code::before": { 25 | content: '""', 26 | }, 27 | "code::after": { 28 | content: '""', 29 | }, 30 | "blockquote p:first-of-type::before": { 31 | content: '""', 32 | }, 33 | "blockquote p:last-of-type::after": { 34 | content: '""', 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | plugins: [ 42 | require("@tailwindcss/typography"), 43 | require("@tailwindcss/forms")({ strategy: "class" }), 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "downlevelIteration": true, 6 | "composite": false, 7 | "declaration": true, 8 | "declarationMap": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "inlineSources": false, 12 | "isolatedModules": true, 13 | "moduleResolution": "node", 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "preserveWatchOutput": true, 17 | "skipLibCheck": true, 18 | "strict": true 19 | }, 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/tsconfig/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "target": "es5", 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve" 19 | }, 20 | "include": ["src", "next-env.d.ts"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "index.js", 6 | "files": [ 7 | "base.json", 8 | "nextjs.json", 9 | "react-library.json" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /packages/tsconfig/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "lib": ["ES2015"], 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "jsx": "react-jsx" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/ui/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./src/Button"; 2 | export * from "./src/Avatar"; 3 | export * from "./src/IconInput"; 4 | export * from "./src/Input"; 5 | export * from "./src/Menu"; 6 | export * from "./src/Modal"; 7 | export * from "./src/ThemeIcon"; 8 | export * from "./src/ScrollArea"; 9 | export * from "./src/Popover"; 10 | export * from "./src/IconButton"; 11 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "version": "0.0.0", 4 | "main": "./index.tsx", 5 | "types": "./index.tsx", 6 | "license": "MIT", 7 | "scripts": { 8 | "lint": "eslint *.ts*" 9 | }, 10 | "devDependencies": { 11 | "@types/react": "^17.0.37", 12 | "@types/react-dom": "^17.0.11", 13 | "eslint": "^7.32.0", 14 | "eslint-config-custom": "*", 15 | "react": "^17.0.2", 16 | "tailwind-config": "*", 17 | "tsconfig": "*", 18 | "typescript": "^4.5.2" 19 | }, 20 | "dependencies": { 21 | "@headlessui/react": "^1.6.6", 22 | "@radix-ui/react-dialog": "^0.1.7", 23 | "@radix-ui/react-dropdown-menu": "^0.1.6", 24 | "@radix-ui/react-polymorphic": "^0.0.14", 25 | "@radix-ui/react-popover": "^0.1.6", 26 | "@radix-ui/react-scroll-area": "^0.1.4", 27 | "@tabler/icons": "^1.74.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("tailwind-config/postcss.config"); 2 | -------------------------------------------------------------------------------- /packages/ui/src/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface AvatarProps { 4 | size?: "sm" | "md" | "lg"; 5 | circle?: boolean; 6 | name: string; 7 | src: string; 8 | } 9 | 10 | const sizes = { 11 | sm: "w-8 h-8", 12 | md: "w-10 h-10", 13 | lg: "w-12 h-12", 14 | }; 15 | 16 | export const Avatar: React.FC = ({ 17 | circle = true, 18 | size = "md", 19 | name, 20 | src, 21 | }) => { 22 | return ( 23 | // eslint-disable-next-line @next/next/no-img-element 24 | {name} 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/ui/src/Button.tsx: -------------------------------------------------------------------------------- 1 | import type * as Polymorphic from "@radix-ui/react-polymorphic"; 2 | import React, { 3 | ButtonHTMLAttributes, 4 | DetailedHTMLProps, 5 | forwardRef, 6 | } from "react"; 7 | 8 | const variants = { 9 | size: { 10 | xs: "py-1 px-4 rounded-lg text-sm", 11 | sm: "py-2 px-5 rounded-lg text-sm", 12 | md: "py-2 px-6 rounded-xl text-base", 13 | lg: "py-2.5 px-7 rounded-xl text-base", 14 | }, 15 | iconSize: { 16 | xs: "p-0 rounded-lg", 17 | sm: "p-1 rounded-lg", 18 | md: "p-2 rounded-lg", 19 | lg: "p-3 rounded-lg", 20 | }, 21 | color: { 22 | primary: { 23 | filled: 24 | "bg-gray-800 hover:bg-gray-700 dark:hover:bg-gray-800 text-gray-100 dark:text-gray-300", 25 | outline: 26 | "bg-white text-gray-900 border shadow-sm text-gray-500 dark:text-gray-100 dark:bg-gray-900 dark:border-gray-800 dark:border-2", 27 | light: 28 | "bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-800", 29 | ghost: 30 | "bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 !font-medium", 31 | }, 32 | }, 33 | }; 34 | 35 | export type ButtonProps = DetailedHTMLProps< 36 | ButtonHTMLAttributes, 37 | HTMLButtonElement 38 | > & { 39 | disableRipple?: boolean; 40 | size?: keyof typeof variants["size"]; 41 | variant?: "filled" | "outline" | "light" | "ghost"; 42 | color?: keyof typeof variants["color"]; 43 | loading?: boolean; 44 | icon?: React.ReactNode; 45 | ref?: any; 46 | rippleColor?: string; 47 | }; 48 | 49 | type PolymorphicBox = Polymorphic.ForwardRefComponent<"button", ButtonProps>; 50 | 51 | export const Button = forwardRef( 52 | ( 53 | { 54 | disableRipple = false, 55 | as: Comp = "button", 56 | size = "md", 57 | color = "primary", 58 | variant = "filled", 59 | disabled, 60 | loading, 61 | children, 62 | className, 63 | icon, 64 | rippleColor, 65 | onMouseDown, 66 | ...props 67 | }, 68 | ref 69 | ) => { 70 | function createRipple( 71 | event: React.MouseEvent 72 | ) { 73 | const button = event.currentTarget; 74 | 75 | const circle = document.createElement("span"); 76 | const diameter = Math.max(button.clientWidth, button.clientHeight); 77 | const radius = diameter / 2; 78 | 79 | const topPos = button.getBoundingClientRect().top + window.scrollY; 80 | const leftPos = button.getBoundingClientRect().left + window.scrollX; 81 | 82 | circle.style.width = circle.style.height = `${diameter}px`; 83 | circle.style.left = `${event.clientX - (leftPos + radius)}px`; 84 | circle.style.top = `${event.clientY - (topPos + radius)}px`; 85 | if (rippleColor) { 86 | circle.classList.add("ripple", rippleColor); 87 | } else if (variant === "outline") { 88 | circle.classList.add( 89 | "ripple", 90 | "!bg-gray-900/10", 91 | "dark:!bg-gray-100/10" 92 | ); 93 | } else if (variant === "filled") { 94 | circle.classList.add("ripple"); 95 | } else if (variant === "light") { 96 | circle.classList.add( 97 | "ripple", 98 | "!bg-gray-900/20", 99 | "dark:!bg-gray-100/20" 100 | ); 101 | } else { 102 | circle.classList.add( 103 | "ripple", 104 | "!bg-gray-900/10", 105 | "dark:!bg-gray-100/10" 106 | ); 107 | } 108 | 109 | const ripple = button.getElementsByClassName("ripple")[0]; 110 | 111 | if (ripple) { 112 | ripple.remove(); 113 | } 114 | 115 | button.appendChild(circle); 116 | } 117 | 118 | return ( 119 | { 122 | if (!disableRipple) createRipple(e); 123 | onMouseDown && onMouseDown(e); 124 | }} 125 | disabled={disabled || loading} 126 | className={`relative overflow-hidden flex items-center transition justify-center font-bold select-none focus:outline-none focus-visible:ring focus-visible:ring-gray-300 ${ 127 | (disabled || loading) && "opacity-50 cursor-not-allowed" 128 | } ${variants[icon && !children ? "iconSize" : "size"][size]} ${ 129 | variants.color[color][variant] 130 | } ${className}`} 131 | {...props} 132 | > 133 | {icon && ( 134 | 137 | {icon} 138 | 139 | )} 140 | {children} 141 | 142 | ); 143 | } 144 | ) as PolymorphicBox; 145 | 146 | Button.displayName = "Button"; 147 | -------------------------------------------------------------------------------- /packages/ui/src/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | 3 | export type IconButtonProps = React.DetailedHTMLProps< 4 | React.ButtonHTMLAttributes, 5 | HTMLButtonElement 6 | > & {}; 7 | 8 | export const IconButton = forwardRef( 9 | ({ children }, ref) => { 10 | return ( 11 | 17 | ); 18 | } 19 | ); 20 | 21 | IconButton.displayName = "IconButton"; 22 | -------------------------------------------------------------------------------- /packages/ui/src/IconInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | import { Input } from "./Input"; 3 | 4 | export interface IconInputProps 5 | extends React.ComponentPropsWithoutRef<"input"> { 6 | icon?: React.ReactNode; 7 | inputClassName?: string; 8 | } 9 | 10 | export const IconInput = forwardRef( 11 | ({ className, inputClassName, icon, ...props }, ref) => { 12 | return ( 13 |
    16 |
    17 | {icon} 18 |
    19 | 24 |
    25 | ); 26 | } 27 | ); 28 | 29 | IconInput.displayName = "IconInput"; 30 | -------------------------------------------------------------------------------- /packages/ui/src/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | 3 | export interface InputProps 4 | extends Omit, "size"> { 5 | textarea?: boolean; 6 | size?: "sm" | "md"; 7 | label?: string; 8 | icon?: React.ReactNode; 9 | } 10 | 11 | export const Input = forwardRef( 12 | ({ textarea, className, size = "md", label, icon, ...props }, ref) => { 13 | const styles = `${ 14 | size === "md" ? "text-base px-4 py-2" : "text-sm px-3 py-2" 15 | } block bg-white dark:border-2 dark:border-gray-800 dark:bg-gray-900 rounded-xl border shadow-sm focus:outline-none placeholder-gray-400 dark:placeholder-gray-600 dark:text-white w-full transition focus-visible:ring focus-visible:ring-gray-300 ${ 16 | textarea && "resize-none h-32" 17 | } ${icon ? "pl-10" : ""} ${className}`; 18 | let output; 19 | if (textarea) { 20 | output = ( 21 |