setIsFocused(true)}
23 | onBlur={() => setIsFocused(false)}
24 | {...props}
25 | />
26 | );
27 | };
28 |
29 | export default SampleSplitter;
30 |
--------------------------------------------------------------------------------
/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/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/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/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 |
}
19 | onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
20 | >
21 | Login with GitHub
22 |
23 |
signIn("google", { callbackUrl: "/dashboard" })}
26 | icon={ }
27 | variant="outline"
28 | >
29 | Login with Google
30 |
31 |
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/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/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/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/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 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
18 | theme?.setTheme(theme.theme === "dark" ? "light" : "dark")
19 | }
20 | >
21 | Theme
22 |
23 | {theme?.theme === "dark" ? (
24 |
25 | ) : (
26 |
27 | )}
28 |
29 |
30 |
31 | Actions
32 | ⌘
33 | K
34 |
35 |
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/packages/ui/src/Popover.tsx:
--------------------------------------------------------------------------------
1 | import * as PopoverPrimitive from "@radix-ui/react-popover";
2 | import { IconX } from "@tabler/icons";
3 | import React from "react";
4 |
5 | const cx = (...args: string[]) => args.join(" ");
6 |
7 | interface PopoverProps {
8 | trigger?: React.ReactNode;
9 | className?: string;
10 | }
11 |
12 | export const Popover: React.FC = ({
13 | trigger,
14 | className,
15 | children,
16 | }) => {
17 | return (
18 |
19 |
20 | {trigger}
21 |
31 | {children}
32 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/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 |
16 |
17 |
18 |
19 |
20 | Explore
21 |
22 |
23 | Pricing
24 |
25 | {session ? (
26 |
27 |
28 |
34 | Go to dashboard
35 |
36 |
37 |
38 | ) : (
39 |
40 |
41 |
42 | )}
43 |
44 |
45 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
22 | );
23 | } else {
24 | output = (
25 |
26 |
27 | {icon}
28 |
29 |
30 |
31 | );
32 | }
33 | return (
34 |
35 | {label && (
36 |
37 | {label}
38 |
39 | )}
40 | {output}
41 |
42 | );
43 | }
44 | );
45 |
46 | Input.displayName = "Input";
47 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
36 |
37 |
38 |
{category}
39 |
{title}
40 |
{description}
41 |
42 |
45 | {wip ? (
46 |
🚧
47 | ) : (
48 |
49 | )}
50 |
51 | {wip ? (
52 | Work in Progress
53 | ) : (
54 |
55 |
See how it works
56 |
{time}
57 |
58 | )}
59 |
60 |
61 |
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/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/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/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/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 |
73 |
74 |
75 | );
76 | };
77 |
--------------------------------------------------------------------------------
/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/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/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/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/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/modules/editor/plugins/CommandsList.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface CommandsListProps {
4 | items: any[];
5 | command: (props: any) => void;
6 | }
7 |
8 | export class CommandsList extends React.Component {
9 | focusedRef: React.RefObject;
10 |
11 | constructor(props: CommandsListProps) {
12 | super(props);
13 | this.selectItem = this.selectItem.bind(this);
14 | this.focusedRef = React.createRef();
15 | }
16 |
17 | state = {
18 | selectedIndex: 0,
19 | };
20 |
21 | onKeyDown({ event }: { event: KeyboardEvent }) {
22 | if (event.key === "ArrowUp") {
23 | this.upHandler();
24 | return true;
25 | }
26 |
27 | if (event.key === "ArrowDown") {
28 | this.downHandler();
29 | return true;
30 | }
31 |
32 | if (event.key === "Enter") {
33 | this.enterHandler();
34 | return true;
35 | }
36 |
37 | return false;
38 | }
39 |
40 | upHandler() {
41 | this.setState({
42 | selectedIndex:
43 | (this.state.selectedIndex + this.props.items.length - 1) %
44 | this.props.items.length,
45 | });
46 | // this.focusedRef.current?.scrollIntoView({ behavior: "smooth" });
47 | }
48 |
49 | downHandler() {
50 | this.setState({
51 | selectedIndex: (this.state.selectedIndex + 1) % this.props.items.length,
52 | });
53 | // this.focusedRef.current?.scrollIntoView({ behavior: "smooth" });
54 | }
55 |
56 | enterHandler() {
57 | this.selectItem(this.state.selectedIndex);
58 | }
59 |
60 | selectItem(index: number) {
61 | const item = this.props.items[index];
62 |
63 | if (item) {
64 | this.props.command(item);
65 | }
66 | }
67 |
68 | componentDidUpdate(
69 | prevProps: CommandsListProps,
70 | prevState: CommandsListProps
71 | ) {
72 | if (prevProps.items.length !== this.props.items.length) {
73 | this.setState({ selectedIndex: 0 });
74 | }
75 | }
76 |
77 | render() {
78 | const { items } = this.props;
79 | const { selectedIndex } = this.state;
80 |
81 | return (
82 |
83 | {items.length === 0 ? (
84 | No commands found
85 | ) : (
86 | items.map(({ title, icon }, index) => (
87 |
93 | this.selectItem(index)}
100 | >
101 |
106 | {icon}
107 |
108 |
115 | {title}
116 |
117 |
118 |
119 | ))
120 | )}
121 |
122 | );
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/packages/ui/src/Menu.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import type * as RadixDropdownTypes from "@radix-ui/react-dropdown-menu";
3 | import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
4 |
5 | export interface MenuProps {
6 | open?: boolean;
7 | onOpenChange?: RadixDropdownTypes.DropdownMenuProps["onOpenChange"];
8 | side?: RadixDropdownTypes.DropdownMenuContentProps["side"];
9 | align?: RadixDropdownTypes.DropdownMenuContentProps["align"];
10 | children?: React.ReactNode;
11 | alignOffset?: number;
12 | trigger: React.ReactNode;
13 | className?: string;
14 | subMenu?: boolean;
15 | sideOffset?: number;
16 | style?: React.CSSProperties;
17 | onCloseAutoFocus?: boolean;
18 | }
19 |
20 | export const Menu: React.FC = ({
21 | trigger,
22 | open,
23 | align,
24 | side,
25 | className,
26 | alignOffset,
27 | sideOffset = 8,
28 | children,
29 | style,
30 | onCloseAutoFocus,
31 | subMenu = false,
32 | ...props
33 | }) => {
34 | return (
35 |
36 | {subMenu ? (
37 | trigger
38 | ) : (
39 | {trigger}
40 | )}
41 | e.preventDefault() : undefined
45 | }
46 | style={style}
47 | alignOffset={alignOffset}
48 | align={align}
49 | side={side}
50 | asChild
51 | {...props}
52 | >
53 |
56 | {children}
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | export const MenuDivider: React.FC = () => (
64 |
65 | );
66 |
67 | interface MenuItemProps {
68 | icon?: React.ReactNode;
69 | disabled?: boolean;
70 | href?: string;
71 | className?: string;
72 | trigger?: boolean;
73 | closeOnSelect?: boolean;
74 | onClick?: (event: Event) => void;
75 | }
76 |
77 | export const MenuItem: React.FC = ({
78 | icon,
79 | children,
80 | onClick,
81 | disabled,
82 | trigger = false,
83 | closeOnSelect = true,
84 | href,
85 | className,
86 | ...props
87 | }) => {
88 | const T = !trigger ? DropdownMenu.Item : DropdownMenu.TriggerItem;
89 |
90 | return (
91 | e.stopPropagation()}
94 | onSelect={(e) => {
95 | if (!closeOnSelect) {
96 | e.preventDefault();
97 | }
98 | if (href) {
99 | window.location.href = href;
100 | }
101 | if (onClick) {
102 | onClick(e as any);
103 | }
104 | }}
105 | className={`group rounded-lg ${
106 | icon ? "flex items-center" : "block"
107 | } select-none px-4 py-1.5 w-full cursor-pointer focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 text-left ${className}`}
108 | {...props}
109 | asChild
110 | >
111 |
112 | {icon ? (
113 |
114 | {icon}
115 |
116 | ) : null}
117 |
118 | {children}
119 |
120 |
121 |
122 | );
123 | };
124 |
125 | export const SubMenu: React.FC<{ trigger: React.ReactNode }> = ({
126 | trigger,
127 | children,
128 | }) => {
129 | return (
130 |
131 | {trigger}
132 |
133 | {children}
134 |
135 |
136 | );
137 | };
138 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/packages/ui/src/Modal.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from "@headlessui/react";
2 | import { IconX } from "@tabler/icons";
3 | import * as Dialog from "@radix-ui/react-dialog";
4 | import React, { Fragment, useEffect, useState } from "react";
5 |
6 | interface ModalProps {
7 | trigger?: React.ReactNode;
8 | children:
9 | | React.ReactNode
10 | | ((props: { open: boolean; setOpen: any }) => React.ReactNode);
11 | visible?: boolean;
12 | onCancel?: any;
13 | className?: string;
14 | title?: string;
15 | }
16 |
17 | export const Modal: React.VFC = ({
18 | trigger,
19 | children,
20 | className,
21 | visible,
22 | title,
23 | onCancel,
24 | }) => {
25 | const [open, setOpen] = useState(visible ? visible : false);
26 |
27 | useEffect(() => {
28 | if (visible !== undefined) {
29 | setOpen(visible);
30 | }
31 | }, [visible]);
32 |
33 | function stopPropagation(e: React.MouseEvent) {
34 | e.stopPropagation();
35 | }
36 |
37 | function handleOpenChange(open: boolean) {
38 | if (visible !== undefined && !open) {
39 | onCancel();
40 | } else {
41 | setOpen(open);
42 | }
43 | }
44 |
45 | return (
46 |
47 | {trigger}
48 |
49 |
50 |
51 |
60 |
61 |
62 |
63 |
64 |
68 |
69 |
70 |
79 |
80 |
handleOpenChange(false)}
83 | >
84 |
88 |
89 |
90 | {title}
91 |
92 | handleOpenChange(false)}>
93 |
97 |
98 |
99 |
100 | {children instanceof Function
101 | ? children({ open, setOpen })
102 | : children}
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | );
113 | };
114 |
--------------------------------------------------------------------------------
/patches/@tiptap+react+2.0.0-beta.114.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/@tiptap/react/dist/tiptap-react.cjs.js b/node_modules/@tiptap/react/dist/tiptap-react.cjs.js
2 | index 1ef7c04..43df405 100644
3 | --- a/node_modules/@tiptap/react/dist/tiptap-react.cjs.js
4 | +++ b/node_modules/@tiptap/react/dist/tiptap-react.cjs.js
5 | @@ -192,11 +192,13 @@ class ReactRenderer {
6 | };
7 | }
8 | this.reactElement = React__default["default"].createElement(Component, { ...props });
9 | - if ((_a = this.editor) === null || _a === void 0 ? void 0 : _a.contentComponent) {
10 | - this.editor.contentComponent.setState({
11 | - renderers: this.editor.contentComponent.state.renderers.set(this.id, this),
12 | - });
13 | - }
14 | + ReactDOM.flushSync(() => {
15 | + if ((_a = this.editor) === null || _a === void 0 ? void 0 : _a.contentComponent) {
16 | + this.editor.contentComponent.setState({
17 | + renderers: this.editor.contentComponent.state.renderers.set(this.id, this),
18 | + });
19 | + }
20 | + });
21 | }
22 | updateProps(props = {}) {
23 | this.props = {
24 | @@ -206,14 +208,16 @@ class ReactRenderer {
25 | this.render();
26 | }
27 | destroy() {
28 | - var _a;
29 | - if ((_a = this.editor) === null || _a === void 0 ? void 0 : _a.contentComponent) {
30 | - const { renderers } = this.editor.contentComponent.state;
31 | - renderers.delete(this.id);
32 | - this.editor.contentComponent.setState({
33 | - renderers,
34 | - });
35 | - }
36 | + ReactDOM.flushSync(() => {
37 | + var _a;
38 | + if ((_a = this.editor) === null || _a === void 0 ? void 0 : _a.contentComponent) {
39 | + const { renderers } = this.editor.contentComponent.state;
40 | + renderers.delete(this.id);
41 | + this.editor.contentComponent.setState({
42 | + renderers,
43 | + });
44 | + }
45 | + });
46 | }
47 | }
48 |
49 | diff --git a/node_modules/@tiptap/react/dist/tiptap-react.esm.js b/node_modules/@tiptap/react/dist/tiptap-react.esm.js
50 | index 505e75a..d3a89dd 100644
51 | --- a/node_modules/@tiptap/react/dist/tiptap-react.esm.js
52 | +++ b/node_modules/@tiptap/react/dist/tiptap-react.esm.js
53 | @@ -184,11 +184,13 @@ class ReactRenderer {
54 | };
55 | }
56 | this.reactElement = React.createElement(Component, { ...props });
57 | - if ((_a = this.editor) === null || _a === void 0 ? void 0 : _a.contentComponent) {
58 | - this.editor.contentComponent.setState({
59 | - renderers: this.editor.contentComponent.state.renderers.set(this.id, this),
60 | - });
61 | - }
62 | + ReactDOM.flushSync(() => {
63 | + if ((_a = this.editor) === null || _a === void 0 ? void 0 : _a.contentComponent) {
64 | + this.editor.contentComponent.setState({
65 | + renderers: this.editor.contentComponent.state.renderers.set(this.id, this),
66 | + });
67 | + }
68 | + });
69 | }
70 | updateProps(props = {}) {
71 | this.props = {
72 | @@ -198,14 +200,16 @@ class ReactRenderer {
73 | this.render();
74 | }
75 | destroy() {
76 | - var _a;
77 | - if ((_a = this.editor) === null || _a === void 0 ? void 0 : _a.contentComponent) {
78 | - const { renderers } = this.editor.contentComponent.state;
79 | - renderers.delete(this.id);
80 | - this.editor.contentComponent.setState({
81 | - renderers,
82 | - });
83 | - }
84 | + ReactDOM.flushSync(() => {
85 | + var _a;
86 | + if ((_a = this.editor) === null || _a === void 0 ? void 0 : _a.contentComponent) {
87 | + const { renderers } = this.editor.contentComponent.state;
88 | + renderers.delete(this.id);
89 | + this.editor.contentComponent.setState({
90 | + renderers,
91 | + });
92 | + }
93 | + });
94 | }
95 | }
96 |
97 |
--------------------------------------------------------------------------------
/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 |
101 |
102 |
103 |
104 |
105 | );
106 | };
107 |
--------------------------------------------------------------------------------
/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 | {/*
32 | Edit profile page
33 | */}
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/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/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 |
51 |
52 |
53 |
54 |
55 |
56 |
64 |
72 |
81 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | );
96 | };
97 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
49 | }
50 | className={`w-full !justify-start !p-2 text-[13px] !font-medium`}
51 | >
52 | Dashboard
53 |
54 |
55 |
56 |
64 | }
65 | className="w-full !justify-start !p-2 text-[13px]"
66 | >
67 | Rewards
68 |
69 |
77 | }
78 | className="w-full !justify-start !p-2 text-[13px]"
79 | >
80 | Monetization
81 |
82 |
83 |
84 |
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 |
122 |
123 |
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 |