Skip to content

Commit

Permalink
Merge pull request #136 from andrewdoro/main
Browse files Browse the repository at this point in the history
RFC: Headless core components & imperative support
  • Loading branch information
andrewdoro committed Feb 9, 2024
2 parents b4526fc + fcda444 commit 75d77cd
Show file tree
Hide file tree
Showing 81 changed files with 3,033 additions and 8,694 deletions.
10 changes: 0 additions & 10 deletions .eslintrc.json

This file was deleted.

29 changes: 17 additions & 12 deletions apps/web/app/api/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,33 @@ import { kv } from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";

// Create an OpenAI API client (that's edge friendly!)
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY || "",
});
// Using LLamma's OpenAI client:

// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
export const runtime = "edge";

const isProd = process.env.NODE_ENV === "production";

export async function POST(req: Request): Promise<Response> {
const openai = new OpenAI({
...(!isProd && {
baseURL: "http://localhost:11434/v1",
}),
apiKey: isProd ? process.env.OPENAI_API_KEY : "ollama",
});
// Check if the OPENAI_API_KEY is set, if not return 400
if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") {
if (
(!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") &&
isProd
) {
return new Response(
"Missing OPENAI_API_KEY – make sure to add it to your .env file.",
"Missing OPENAI_API_KEY - make sure to add it to your .env file.",
{
status: 400,
},
);
}
if (
process.env.NODE_ENV != "development" &&
process.env.KV_REST_API_URL &&
process.env.KV_REST_API_TOKEN
) {
if (isProd && process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) {
const ip = req.headers.get("x-forwarded-for");
const ratelimit = new Ratelimit({
redis: kv,
Expand All @@ -51,7 +56,8 @@ export async function POST(req: Request): Promise<Response> {
let { prompt } = await req.json();

const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo",
model: process.env.NODE_ENV == "development" ? "llama2" : "gpt-3.5-turbo",
stream: true,
messages: [
{
role: "system",
Expand All @@ -71,7 +77,6 @@ export async function POST(req: Request): Promise<Response> {
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
n: 1,
});

Expand Down
10 changes: 7 additions & 3 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import "@/styles/globals.css";
import "@/styles/prosemirror.css";

import { Metadata } from "next";
import { ReactNode } from "react";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import Providers from "./providers";

const title =
"Novel – Notion-style WYSIWYG editor with AI-powered autocompletions";
"Novel - Notion-style WYSIWYG editor with AI-powered autocompletions";
const description =
"Novel is a Notion-style WYSIWYG editor with AI-powered autocompletions. Built with Tiptap, OpenAI, and Vercel AI SDK.";

Expand All @@ -23,6 +24,9 @@ export const metadata: Metadata = {
creator: "@steventey",
},
metadataBase: new URL("https://novel.sh"),
};

export const viewport: Viewport = {
themeColor: "#ffffff",
};

Expand Down
171 changes: 161 additions & 10 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,170 @@
import { Github } from "@/ui/icons";
import Menu from "@/ui/menu";
import Editor from "@/ui/editor";
"use client";

import { Github } from "@/components/ui/icons";
import {
defaultEditorProps,
Editor,
EditorRoot,
EditorBubble,
EditorCommand,
EditorCommandItem,
EditorCommandEmpty,
EditorContent,
type JSONContent,
} from "novel";
import { useState } from "react";
import {
taskItem,
taskList,
tiptapImage,
tiptapLink,
updatedImage,
horizontalRule,
slashCommand,
starterKit,
placeholder,
} from "../lib/extensions";
import { NodeSelector } from "../lib/selectors/node-selector";
import { LinkSelector } from "../lib/selectors/link-selector";
import { ColorSelector } from "../lib/selectors/color-selector";
import TextButtons from "../lib/selectors/text-buttons";
import { suggestionItems } from "../lib/suggestions";
import { ImageResizer } from "novel/extensions";
import { defaultEditorContent } from "@/lib/content";
import { AISelector } from "@/lib/selectors/ai-selector";
import Magic from "@/components/ui/icons/magic";
import { Button } from "@/components/ui/button";
import Menu from "@/components/ui/menu";
import { Separator } from "@/components/ui/separator";
import useLocalStorage from "@/lib/hooks/use-local-storage";
import { useDebouncedCallback } from "use-debounce";

const extensions = [
starterKit,
horizontalRule,
tiptapLink,
tiptapImage,
updatedImage,
taskList,
taskItem,
slashCommand,
placeholder,
];
export default function Page() {
const [content, setContent] = useLocalStorage<JSONContent | null>(
"novel-content",
defaultEditorContent,
);
const [saveStatus, setSaveStatus] = useState("Saved");

const [openNode, setOpenNode] = useState(false);
const [openColor, setOpenColor] = useState(false);
const [openLink, setOpenLink] = useState(false);
const [openAI, setOpenAI] = useState(false);

const debouncedUpdates = useDebouncedCallback(async (editor: Editor) => {
const json = editor.getJSON();
setContent(json);
setSaveStatus("Saved");
}, 500);
return (
<div className="flex min-h-screen flex-col items-center sm:px-5 sm:pt-[calc(20vh)]">
<a
href="https://github.com/steven-tey/novel"
target="_blank"
className="absolute bottom-5 left-5 z-10 max-h-fit rounded-lg p-2 transition-colors duration-200 hover:bg-stone-100 sm:bottom-auto sm:top-5"
<Button
size="icon"
variant="outline"
className="absolute bottom-5 left-5 z-10 max-h-fit rounded-lg p-2 transition-colors duration-200 sm:bottom-auto sm:top-5"
>
<Github />
</a>
<a href="https://github.com/steven-tey/novel" target="_blank">
<Github />
</a>
</Button>
<Menu />
<Editor />
<div className="relative w-full max-w-screen-lg">
<div className="absolute right-5 top-5 z-10 mb-5 rounded-lg bg-accent px-2 py-1 text-sm text-muted-foreground">
{saveStatus}
</div>
<EditorRoot>
<EditorContent
extensions={extensions}
content={content}
className="relative min-h-[500px] w-full max-w-screen-lg border-muted bg-background sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"
editorProps={{
...defaultEditorProps,
attributes: {
class: `prose-lg prose-stone dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
},
}}
onUpdate={({ editor }) => {
debouncedUpdates(editor);
setSaveStatus("Unsaved");
}}
slotAfter={<ImageResizer />}
>
<EditorCommand className="z-50 h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-muted bg-background px-1 py-2 shadow-md transition-all">
<EditorCommandEmpty className="px-2 text-muted-foreground">
No results
</EditorCommandEmpty>
{suggestionItems.map((item) => (
<EditorCommandItem
value={item.title}
onCommand={(val) => item.command(val)}
className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-accent aria-selected:bg-accent `}
key={item.title}
>
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-muted bg-background">
{item.icon}
</div>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</div>
</EditorCommandItem>
))}
</EditorCommand>

<EditorBubble
tippyOptions={{
placement: openAI ? "bottom-start" : "top",
onHidden: () => {
setOpenAI(false);
},
}}
className="flex w-fit max-w-[90vw] overflow-hidden rounded border border-muted bg-background shadow-xl"
>
{openAI ? (
<AISelector open={openAI} onOpenChange={setOpenAI} />
) : (
<>
<Button
variant="ghost"
onClick={(e) => {
e.preventDefault();
setOpenAI(!openAI);
}}
className="items-center justify-between gap-2 rounded-none"
>
<Magic className="h-5 w-5" /> Ask AI
</Button>
<Separator orientation="vertical" />
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
<Separator orientation="vertical" />

<LinkSelector open={openLink} onOpenChange={setOpenLink} />

<Separator orientation="vertical" />

<TextButtons />
<Separator orientation="vertical" />

<ColorSelector open={openColor} onOpenChange={setOpenColor} />
</>
)}
</EditorBubble>
</EditorContent>
</EditorRoot>
</div>
</div>
);
}
14 changes: 9 additions & 5 deletions apps/web/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
"use client";

import { Dispatch, ReactNode, SetStateAction, createContext } from "react";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
createContext,
} from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { Toaster } from "sonner";
import { Analytics } from "@vercel/analytics/react";
Expand All @@ -27,10 +32,9 @@ export default function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider
attribute="class"
value={{
light: "light-theme",
dark: "dark-theme",
}}
enableSystem
disableTransitionOnChange
defaultTheme="system"
>
<AppContext.Provider
value={{
Expand Down
17 changes: 17 additions & 0 deletions apps/web/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
56 changes: 56 additions & 0 deletions apps/web/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"

export { Button, buttonVariants }
Loading

0 comments on commit 75d77cd

Please sign in to comment.