diff --git a/apps/web/app/(canvas)/canvas/layout.tsx b/apps/web/app/(canvas)/canvas/layout.tsx
index 494b52b9..9bc3b6d7 100644
--- a/apps/web/app/(canvas)/canvas/layout.tsx
+++ b/apps/web/app/(canvas)/canvas/layout.tsx
@@ -1,4 +1,4 @@
-import "../canvasStyles.css"
+import "../canvasStyles.css";
export default function RootLayout({
children,
@@ -6,7 +6,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
-
+
);
diff --git a/apps/web/app/(canvas)/canvas/page.tsx b/apps/web/app/(canvas)/canvas/page.tsx
index b1d3adb5..86c283ea 100644
--- a/apps/web/app/(canvas)/canvas/page.tsx
+++ b/apps/web/app/(canvas)/canvas/page.tsx
@@ -5,11 +5,13 @@ import Canvas from "../canvas";
import React, { useState } from "react";
// import ReactTextareaAutosize from "react-textarea-autosize";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
-import { DragSvg,
+import {
+ DragSvg,
SettingsSvg,
LinkSvg,
ThreeDBlock,
- TextLoadingSvg, } from "../svg";
+ TextLoadingSvg,
+} from "../svg";
function page() {
const [value, setValue] = useState("");
@@ -32,7 +34,7 @@ function page() {
}}
value={value}
// rows={1}
- className="w-full resize-none rounded-xl bg-[#171B1F] px-3 py-4 text-xl text-[#989EA4] outline-none focus:outline-none sm:max-h-52"
+ className="w-full resize-none rounded-xl bg-[#151515] px-3 py-4 text-xl text-[#989EA4] outline-none focus:outline-none sm:max-h-52"
/>
diff --git a/apps/web/app/(dash)/dynamicisland.tsx b/apps/web/app/(dash)/dynamicisland.tsx
index a30ec8cf..6c911f3e 100644
--- a/apps/web/app/(dash)/dynamicisland.tsx
+++ b/apps/web/app/(dash)/dynamicisland.tsx
@@ -15,7 +15,6 @@ export function DynamicIsland() {
const [visible, setVisible] = useState(true);
useMotionValueEvent(scrollYProgress, "change", (current) => {
- // Check if current is not undefined and is a number
if (typeof current === "number") {
let direction = current! - scrollYProgress.getPrevious()!;
@@ -63,9 +62,12 @@ function DynamicIslandContent() {
{show ? (
setshow(!show)}
- className="bg-[#1F2428] p-2 rounded-3xl"
+ className="bg-[#1F2428] px-3 w-[2.23rem] overflow-hidden hover:w-[8.7rem] whitespace-nowrap py-2 rounded-3xl transition-[width]"
>
+
+ Add Content
+
) : (
diff --git a/apps/web/app/(dash)/layout.tsx b/apps/web/app/(dash)/layout.tsx
index dffa27fa..85f8476e 100644
--- a/apps/web/app/(dash)/layout.tsx
+++ b/apps/web/app/(dash)/layout.tsx
@@ -11,7 +11,7 @@ async function Layout({ children }: { children: React.ReactNode }) {
}
return (
-
+
diff --git a/apps/web/app/(dash)/memories/page.tsx b/apps/web/app/(dash)/memories/page.tsx
new file mode 100644
index 00000000..bc2fcd53
--- /dev/null
+++ b/apps/web/app/(dash)/memories/page.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons";
+import Image from "next/image";
+import React, { useState } from "react";
+
+function page() {
+ const [filter, setFilter] = useState("All")
+ const setFilterfn = (i:string) => setFilter(i)
+
+ const [search, setSearch] = useState("")
+ return (
+
+
+ My Memories
+
+
+
+
+
+
+
+ );
+}
+
+function TabComponent({title, description}: {title:string, description:string}){
+ return (
+
+
+
+ {title.slice(0,2).toUpperCase()}
+
+
+
+
{title}
+
{description}
+
+
+
+
+
+ )
+}
+
+function LinkComponent({title, url}: {title:string, url:string}){
+ return (
+
+ )
+}
+
+const FilterMethods = ["All", "Spaces", "Pages", "Notes"]
+function Filters({setFilter, filter}:{setFilter: (i:string)=> void, filter: string}){
+ return (
+
+ {FilterMethods.map((i)=> {
+ return
setFilter(i)} className={`transition px-6 py-2 rounded-xl ${i === filter ? "bg-[#21303D] text-[#369DFD]" : "text-[#B3BCC5] bg-[#1F2428] hover:bg-[#1f262d] hover:text-[#76a3cc]"}`}>{i}
+ })}
+
+ )
+}
+
+export default page;
diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx
index fad4a036..89f17a11 100644
--- a/apps/web/app/(dash)/menu.tsx
+++ b/apps/web/app/(dash)/menu.tsx
@@ -1,13 +1,14 @@
import React from "react";
import Image from "next/image";
import { MemoriesIcon, ExploreIcon, HistoryIcon } from "@repo/ui/icons";
+import Link from "next/link";
function Menu() {
const menuItems = [
{
icon: MemoriesIcon,
text: "Memories",
- url: "/",
+ url: "/memories",
},
{
icon: ExploreIcon,
@@ -26,7 +27,8 @@ function Menu() {
{menuItems.map((item) => (
-
@@ -38,7 +40,7 @@ function Menu() {
{item.text}
-
+
))}
diff --git a/apps/web/app/(editor)/api/generate/route.ts b/apps/web/app/(editor)/api/generate/route.ts
new file mode 100644
index 00000000..ecd9effb
--- /dev/null
+++ b/apps/web/app/(editor)/api/generate/route.ts
@@ -0,0 +1,141 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { kv } from "@vercel/kv";
+import { OpenAIStream, StreamingTextResponse } from "ai";
+import OpenAI from "openai";
+import type { ChatCompletionMessageParam } from "openai/resources/index.mjs";
+import { match } from "ts-pattern";
+
+// Create an OpenAI API client (that's edge friendly!)
+
+// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
+export const runtime = "edge";
+
+export async function POST(req: Request): Promise {
+ const openai = new OpenAI({
+ apiKey: process.env.OPENAI_API_KEY,
+ baseURL: process.env.OPENAI_BASE_URL || "https://api.openai.com/v1",
+ });
+ // Check if the OPENAI_API_KEY is set, if not return 400
+ if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") {
+ return new Response("Missing OPENAI_API_KEY - make sure to add it to your .env file.", {
+ status: 400,
+ });
+ }
+ if (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,
+ limiter: Ratelimit.slidingWindow(50, "1 d"),
+ });
+
+ const { success, limit, reset, remaining } = await ratelimit.limit(`novel_ratelimit_${ip}`);
+
+ if (!success) {
+ return new Response("You have reached your request limit for the day.", {
+ status: 429,
+ headers: {
+ "X-RateLimit-Limit": limit.toString(),
+ "X-RateLimit-Remaining": remaining.toString(),
+ "X-RateLimit-Reset": reset.toString(),
+ },
+ });
+ }
+ }
+
+ const { prompt, option, command } = await req.json();
+ const messages = match(option)
+ .with("continue", () => [
+ {
+ role: "system",
+ content:
+ "You are an AI writing assistant that continues existing text based on context from prior text. " +
+ "Give more weight/priority to the later characters than the beginning ones. " +
+ "Limit your response to no more than 200 characters, but make sure to construct complete sentences." +
+ "Use Markdown formatting when appropriate.",
+ },
+ {
+ role: "user",
+ content: prompt,
+ },
+ ])
+ .with("improve", () => [
+ {
+ role: "system",
+ content:
+ "You are an AI writing assistant that improves existing text. " +
+ "Limit your response to no more than 200 characters, but make sure to construct complete sentences." +
+ "Use Markdown formatting when appropriate.",
+ },
+ {
+ role: "user",
+ content: `The existing text is: ${prompt}`,
+ },
+ ])
+ .with("shorter", () => [
+ {
+ role: "system",
+ content:
+ "You are an AI writing assistant that shortens existing text. " + "Use Markdown formatting when appropriate.",
+ },
+ {
+ role: "user",
+ content: `The existing text is: ${prompt}`,
+ },
+ ])
+ .with("longer", () => [
+ {
+ role: "system",
+ content:
+ "You are an AI writing assistant that lengthens existing text. " +
+ "Use Markdown formatting when appropriate.",
+ },
+ {
+ role: "user",
+ content: `The existing text is: ${prompt}`,
+ },
+ ])
+ .with("fix", () => [
+ {
+ role: "system",
+ content:
+ "You are an AI writing assistant that fixes grammar and spelling errors in existing text. " +
+ "Limit your response to no more than 200 characters, but make sure to construct complete sentences." +
+ "Use Markdown formatting when appropriate.",
+ },
+ {
+ role: "user",
+ content: `The existing text is: ${prompt}`,
+ },
+ ])
+ .with("zap", () => [
+ {
+ role: "system",
+ content:
+ "You area an AI writing assistant that generates text based on a prompt. " +
+ "You take an input from the user and a command for manipulating the text" +
+ "Use Markdown formatting when appropriate.",
+ },
+ {
+ role: "user",
+ content: `For this text: ${prompt}. You have to respect the command: ${command}`,
+ },
+ ])
+ .run() as ChatCompletionMessageParam[];
+
+ const response = await openai.chat.completions.create({
+ model: "gpt-3.5-turbo",
+ stream: true,
+ messages,
+ temperature: 0.7,
+ top_p: 1,
+ frequency_penalty: 0,
+ presence_penalty: 0,
+ n: 1,
+ });
+
+ // Convert the response into a friendly text-stream
+ const stream = OpenAIStream(response);
+
+ // Respond with the stream
+ return new StreamingTextResponse(stream);
+}
diff --git a/apps/web/app/(editor)/api/upload/route.ts b/apps/web/app/(editor)/api/upload/route.ts
new file mode 100644
index 00000000..ce7f1513
--- /dev/null
+++ b/apps/web/app/(editor)/api/upload/route.ts
@@ -0,0 +1,26 @@
+import { put } from "@vercel/blob";
+import { NextResponse } from "next/server";
+
+export const runtime = "edge";
+
+export async function POST(req: Request) {
+ if (!process.env.BLOB_READ_WRITE_TOKEN) {
+ return new Response("Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.", {
+ status: 401,
+ });
+ }
+
+ const file = req.body || "";
+ const filename = req.headers.get("x-vercel-filename") || "file.txt";
+ const contentType = req.headers.get("content-type") || "text/plain";
+ const fileType = `.${contentType.split("/")[1]}`;
+
+ // construct final filename based on content-type if not provided
+ const finalName = filename.includes(fileType) ? filename : `${filename}${fileType}`;
+ const blob = await put(finalName, file, {
+ contentType,
+ access: "public",
+ });
+
+ return NextResponse.json(blob);
+}
diff --git a/apps/web/app/(editor)/components/aigenerate.tsx b/apps/web/app/(editor)/components/aigenerate.tsx
new file mode 100644
index 00000000..91c97584
--- /dev/null
+++ b/apps/web/app/(editor)/components/aigenerate.tsx
@@ -0,0 +1,144 @@
+import React, { useEffect, useState } from 'react'
+import Magic from './tailwind/ui/icons/magic'
+import CrazySpinner from './tailwind/ui/icons/crazy-spinner'
+import Asksvg from './tailwind/ui/icons/asksvg'
+import Rewritesvg from './tailwind/ui/icons/rewritesvg'
+import Translatesvg from './tailwind/ui/icons/translatesvg'
+import Autocompletesvg from './tailwind/ui/icons/autocompletesvg'
+import { motion, AnimatePresence } from "framer-motion"
+import { GoogleGenerativeAI } from "@google/generative-ai";
+import type { Editor } from '@tiptap/core'
+import { removeAIHighlight } from 'novel/extensions'
+import { useEditor } from 'novel'
+
+
+function Aigenerate() {
+ const [visible, setVisible] = useState(false)
+ const [generating, setGenerating] = useState(false)
+
+ const { editor } = useEditor();
+
+ useEffect(()=> {
+ if (!visible) {
+ setGenerating(visible)
+ } else{
+ removeAIHighlight(editor);
+ editor.chain().unsetHighlight().run();
+ }
+
+ }, [visible])
+
+ const setGeneratingfn = (v: boolean) => setGenerating(v)
+
+ return (
+
+ {
+ setVisible(!visible);
+ }}
+ className={`select-none relative z-[70] rounded-3xl text-[#369DFD] bg-[#21303D] px-4 py-3 text-sm flex gap-2 items-center font-medium whitespace-nowrap overflow-hidden transition-[width] w-[6.25rem] ${visible && "w-[10.56rem]"}`}>
+
+ {
+ (visible && generating) ? (
+ <>Generating >
+ ) : visible ? (
+ <>Press Commands>
+ ) : (
+ <>Ask AI>
+ )
+ }
+
+
+
+
+
+ )
+}
+
+export default Aigenerate
+
+const options = [
+ Translate
,
+ Change Tone
,
+ ,
+
+];
+
+function ToolBar({editor, setGeneratingfn}: {editor:Editor, setGeneratingfn: (v: boolean) => void}) {
+ const [index, setIndex] = useState(0);
+
+ return (
+
+ {options.map((item, idx) => (
+
setIndex(idx)}
+ >
+
+ {index === idx && (
+ AigenerateContent({idx, editor,setGeneratingfn })}
+ className="absolute inset-0 block h-full w-full rounded-xl bg-[#33393D]"
+ layoutId="hoverBackground"
+ initial={{ opacity: 0 }}
+ animate={{
+ opacity: 1,
+ transition: { duration: 0.15 },
+ }}
+ exit={{
+ opacity: 0,
+ transition: { duration: 0.15, delay: 0.2 },
+ }}
+ />
+ )}
+
+ {item}
+
+ ))}
+
+ );
+}
+
+async function AigenerateContent({idx, editor, setGeneratingfn}:{idx:number, editor:Editor, setGeneratingfn: (v:boolean) => void}){
+ setGeneratingfn(true);
+ const from = editor.view.state.selection.from
+ const to = editor.view.state.selection.to
+ editor.chain().setTextSelection({from, to}).run();
+ editor.commands.setAIHighlight({color: "#0538A2"})
+ const query = editor.state.doc.textBetween(from, to, " $$$ ")
+ console.log("hello there!", from, to)
+ setGeneratingfn(false);
+
+ // const genAI = new GoogleGenerativeAI("AIzaSyDGwJCP9SH5gryyvh65LJ6xTZ0SOdNvzyY");
+ // const model = genAI.getGenerativeModel({ model: "gemini-pro"});
+
+
+
+ // const result = (await model.generateContent(`${ty}, ${query}`)).response.text();
+
+ // .insertContentAt(
+ // {
+ // from: from,
+ // to: to,
+ // },
+ // result,
+ // )
+ // .run();
+}
diff --git a/apps/web/app/(editor)/components/extensions.ts b/apps/web/app/(editor)/components/extensions.ts
new file mode 100644
index 00000000..0c581154
--- /dev/null
+++ b/apps/web/app/(editor)/components/extensions.ts
@@ -0,0 +1,141 @@
+import {
+ AIHighlight,
+ CharacterCount,
+ CodeBlockLowlight,
+ GlobalDragHandle,
+ HorizontalRule,
+ Placeholder,
+ StarterKit,
+ TaskItem,
+ TaskList,
+ TiptapImage,
+ TiptapLink,
+ UpdatedImage,
+ Youtube,
+} from "novel/extensions";
+import { UploadImagesPlugin } from "novel/plugins";
+
+import { cx } from "class-variance-authority";
+import { common, createLowlight } from "lowlight";
+
+//TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects
+const aiHighlight = AIHighlight;
+//You can overwrite the placeholder with your own configuration
+const placeholder = Placeholder;
+const tiptapLink = TiptapLink.configure({
+ HTMLAttributes: {
+ class: cx(
+ "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
+ ),
+ },
+});
+
+const tiptapImage = TiptapImage.extend({
+ addProseMirrorPlugins() {
+ return [
+ UploadImagesPlugin({
+ imageClass: cx("opacity-40 rounded-lg border border-stone-200"),
+ }),
+ ];
+ },
+}).configure({
+ allowBase64: true,
+ HTMLAttributes: {
+ class: cx("rounded-lg border border-muted"),
+ },
+});
+
+const updatedImage = UpdatedImage.configure({
+ HTMLAttributes: {
+ class: cx("rounded-lg border border-muted"),
+ },
+});
+
+const taskList = TaskList.configure({
+ HTMLAttributes: {
+ class: cx("not-prose pl-2 "),
+ },
+});
+const taskItem = TaskItem.configure({
+ HTMLAttributes: {
+ class: cx("flex gap-2 items-start my-4"),
+ },
+ nested: true,
+});
+
+const horizontalRule = HorizontalRule.configure({
+ HTMLAttributes: {
+ class: cx("mt-4 mb-6 border-t border-muted-foreground"),
+ },
+});
+
+const starterKit = StarterKit.configure({
+ bulletList: {
+ HTMLAttributes: {
+ class: cx("list-disc list-outside leading-3 -mt-2"),
+ },
+ },
+ orderedList: {
+ HTMLAttributes: {
+ class: cx("list-decimal list-outside leading-3 -mt-2"),
+ },
+ },
+ listItem: {
+ HTMLAttributes: {
+ class: cx("leading-normal -mb-2"),
+ },
+ },
+ blockquote: {
+ HTMLAttributes: {
+ class: cx("border-l-4 border-primary"),
+ },
+ },
+ codeBlock: {
+ HTMLAttributes: {
+ class: cx("rounded-md bg-muted text-muted-foreground border p-5 font-mono font-medium"),
+ },
+ },
+ code: {
+ HTMLAttributes: {
+ class: cx("rounded-md bg-muted px-1.5 py-1 font-mono font-medium"),
+ spellcheck: "false",
+ },
+ },
+ horizontalRule: false,
+ dropcursor: {
+ color: "#DBEAFE",
+ width: 4,
+ },
+ gapcursor: false,
+});
+
+const codeBlockLowlight = CodeBlockLowlight.configure({
+ // configure lowlight: common / all / use highlightJS in case there is a need to specify certain language grammars only
+ // common: covers 37 language grammars which should be good enough in most cases
+ lowlight: createLowlight(common),
+});
+
+const youtube = Youtube.configure({
+ HTMLAttributes: {
+ class: cx("rounded-lg border border-muted"),
+ },
+ inline: false,
+});
+
+const characterCount = CharacterCount.configure();
+
+export const defaultExtensions = [
+ starterKit,
+ placeholder,
+ tiptapLink,
+ tiptapImage,
+ updatedImage,
+ taskList,
+ taskItem,
+ horizontalRule,
+ aiHighlight,
+ codeBlockLowlight,
+ youtube,
+ characterCount,
+ GlobalDragHandle,
+];
diff --git a/apps/web/app/(editor)/components/image-upload.ts b/apps/web/app/(editor)/components/image-upload.ts
new file mode 100644
index 00000000..0cf93a40
--- /dev/null
+++ b/apps/web/app/(editor)/components/image-upload.ts
@@ -0,0 +1,60 @@
+import { createImageUpload } from "novel/plugins";
+import { toast } from "sonner";
+
+const onUpload = (file: File) => {
+ const promise = fetch("/api/upload", {
+ method: "POST",
+ headers: {
+ "content-type": file?.type || "application/octet-stream",
+ "x-vercel-filename": file?.name || "image.png",
+ },
+ body: file,
+ });
+
+ return new Promise((resolve, reject) => {
+ toast.promise(
+ promise.then(async (res) => {
+ // Successfully uploaded image
+ if (res.status === 200) {
+ const { url } = (await res.json()) as { url: string };
+ // preload the image
+ const image = new Image();
+ image.src = url;
+ image.onload = () => {
+ resolve(url);
+ };
+ // No blob store configured
+ } else if (res.status === 401) {
+ resolve(file);
+ throw new Error("`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead.");
+ // Unknown error
+ } else {
+ throw new Error("Error uploading image. Please try again.");
+ }
+ }),
+ {
+ loading: "Uploading image...",
+ success: "Image uploaded successfully.",
+ error: (e) => {
+ reject(e);
+ return e.message;
+ },
+ },
+ );
+ });
+};
+
+export const uploadFn = createImageUpload({
+ onUpload,
+ validateFn: (file) => {
+ if (!file.type.includes("image/")) {
+ toast.error("File type not supported.");
+ return false;
+ }
+ if (file.size / 1024 / 1024 > 20) {
+ toast.error("File size too big (max 20MB).");
+ return false;
+ }
+ return true;
+ },
+});
diff --git a/apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx b/apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx
new file mode 100644
index 00000000..77da0f03
--- /dev/null
+++ b/apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx
@@ -0,0 +1,107 @@
+import { Check, ChevronDown } from "lucide-react";
+import { EditorBubbleItem, useEditor } from "novel";
+
+import { Button } from "@repo/ui/shadcn/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover";
+export interface BubbleColorMenuItem {
+ name: string;
+ color: string;
+}
+
+const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
+ {
+ name: "Default",
+ color: "var(--novel-highlight-default)",
+ },
+ {
+ name: "Purple",
+ color: "var(--novel-highlight-purple)",
+ },
+ {
+ name: "Red",
+ color: "var(--novel-highlight-red)",
+ },
+ {
+ name: "Yellow",
+ color: "var(--novel-highlight-yellow)",
+ },
+ {
+ name: "Blue",
+ color: "var(--novel-highlight-blue)",
+ },
+ {
+ name: "Green",
+ color: "var(--novel-highlight-green)",
+ },
+ {
+ name: "Orange",
+ color: "var(--novel-highlight-orange)",
+ },
+ {
+ name: "Pink",
+ color: "var(--novel-highlight-pink)",
+ },
+ {
+ name: "Gray",
+ color: "var(--novel-highlight-gray)",
+ },
+];
+
+interface ColorSelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const BgColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
+ const { editor } = useEditor();
+
+ if (!editor) return null;
+
+ const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => editor.isActive("highlight", { color }));
+
+ return (
+
+
+
+
+ A
+
+
+
+
+
+
+
+
Background
+ {HIGHLIGHT_COLORS.map(({ name, color }) => (
+
{
+ editor.commands.unsetHighlight();
+ name !== "Default" && editor.commands.setHighlight({ color });
+ }}
+ className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-[#21303D]"
+ >
+
+ {editor.isActive("highlight", { color }) && }
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/web/app/(editor)/components/selectors/color-selector.tsx b/apps/web/app/(editor)/components/selectors/color-selector.tsx
new file mode 100644
index 00000000..557c4255
--- /dev/null
+++ b/apps/web/app/(editor)/components/selectors/color-selector.tsx
@@ -0,0 +1,111 @@
+import { Check, ChevronDown } from "lucide-react";
+import { EditorBubbleItem, useEditor } from "novel";
+
+import { Button } from "@repo/ui/shadcn/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover";
+export interface BubbleColorMenuItem {
+ name: string;
+ color: string;
+}
+
+const TEXT_COLORS: BubbleColorMenuItem[] = [
+ {
+ name: "Default",
+ color: "var(--novel-black)",
+ },
+ {
+ name: "Purple",
+ color: "#9333EA",
+ },
+ {
+ name: "Red",
+ color: "#E00000",
+ },
+ {
+ name: "Yellow",
+ color: "#EAB308",
+ },
+ {
+ name: "Blue",
+ color: "#2563EB",
+ },
+ {
+ name: "Green",
+ color: "#008A00",
+ },
+ {
+ name: "Orange",
+ color: "#FFA500",
+ },
+ {
+ name: "Pink",
+ color: "#BA4081",
+ },
+ {
+ name: "Gray",
+ color: "#A8A29E",
+ },
+];
+
+
+interface ColorSelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
+ const { editor } = useEditor();
+
+ if (!editor) return null;
+ const activeColorItem = TEXT_COLORS.find(({ color }) => editor.isActive("textStyle", { color }));
+
+ return (
+
+
+
+
+ A
+
+
+
+
+
+
+
+
Color
+ {TEXT_COLORS.map(({ name, color }) => (
+
{
+ editor.commands.unsetColor();
+ name !== "Default" &&
+ editor
+ .chain()
+ .focus()
+ .setColor(color || "")
+ .run();
+ }}
+ className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
+ >
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/web/app/(editor)/components/selectors/link-selector.tsx b/apps/web/app/(editor)/components/selectors/link-selector.tsx
new file mode 100644
index 00000000..3dc28266
--- /dev/null
+++ b/apps/web/app/(editor)/components/selectors/link-selector.tsx
@@ -0,0 +1,95 @@
+import { Button } from "@repo/ui/shadcn/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover";
+import { cn } from "@repo/ui/lib/utils";
+import { Check, Trash } from "lucide-react";
+import { useEditor } from "novel";
+import { useEffect, useRef } from "react";
+
+export function isValidUrl(url: string) {
+ try {
+ new URL(url);
+ return true;
+ } catch (_e) {
+ return false;
+ }
+}
+export function getUrlFromString(str: string) {
+ if (isValidUrl(str)) return str;
+ try {
+ if (str.includes(".") && !str.includes(" ")) {
+ return new URL(`https://${str}`).toString();
+ }
+ } catch (_e) {
+ return null;
+ }
+}
+interface LinkSelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => {
+ const inputRef = useRef(null);
+ const { editor } = useEditor();
+
+ // Autofocus on input by default
+ useEffect(() => {
+ inputRef.current?.focus();
+ });
+ if (!editor) return null;
+
+ return (
+
+
+
+ ↗
+
+ Link
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/app/(editor)/components/selectors/node-selector.tsx b/apps/web/app/(editor)/components/selectors/node-selector.tsx
new file mode 100644
index 00000000..c6092b68
--- /dev/null
+++ b/apps/web/app/(editor)/components/selectors/node-selector.tsx
@@ -0,0 +1,126 @@
+import {
+ Check,
+ CheckSquare,
+ ChevronDown,
+ Code,
+ Heading1,
+ Heading2,
+ Heading3,
+ ListOrdered,
+ type LucideIcon,
+ TextIcon,
+ TextQuote,
+} from "lucide-react";
+import { EditorBubbleItem, useEditor } from "novel";
+
+import { Button } from "@repo/ui/shadcn/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover";
+
+export type SelectorItem = {
+ name: string;
+ icon: LucideIcon;
+ command: (editor: ReturnType["editor"]) => void;
+ isActive: (editor: ReturnType["editor"]) => boolean;
+};
+
+const items: SelectorItem[] = [
+ {
+ name: "Text",
+ icon: TextIcon,
+ command: (editor) => editor.chain().focus().clearNodes().run(),
+ // I feel like there has to be a more efficient way to do this – feel free to PR if you know how!
+ isActive: (editor) =>
+ editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"),
+ },
+ {
+ name: "Heading 1",
+ icon: Heading1,
+ command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(),
+ isActive: (editor) => editor.isActive("heading", { level: 1 }),
+ },
+ {
+ name: "Heading 2",
+ icon: Heading2,
+ command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(),
+ isActive: (editor) => editor.isActive("heading", { level: 2 }),
+ },
+ {
+ name: "Heading 3",
+ icon: Heading3,
+ command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(),
+ isActive: (editor) => editor.isActive("heading", { level: 3 }),
+ },
+ {
+ name: "To-do List",
+ icon: CheckSquare,
+ command: (editor) => editor.chain().focus().clearNodes().toggleTaskList().run(),
+ isActive: (editor) => editor.isActive("taskItem"),
+ },
+ {
+ name: "Bullet List",
+ icon: ListOrdered,
+ command: (editor) => editor.chain().focus().clearNodes().toggleBulletList().run(),
+ isActive: (editor) => editor.isActive("bulletList"),
+ },
+ {
+ name: "Numbered List",
+ icon: ListOrdered,
+ command: (editor) => editor.chain().focus().clearNodes().toggleOrderedList().run(),
+ isActive: (editor) => editor.isActive("orderedList"),
+ },
+ {
+ name: "Quote",
+ icon: TextQuote,
+ command: (editor) => editor.chain().focus().clearNodes().toggleBlockquote().run(),
+ isActive: (editor) => editor.isActive("blockquote"),
+ },
+ {
+ name: "Code",
+ icon: Code,
+ command: (editor) => editor.chain().focus().clearNodes().toggleCodeBlock().run(),
+ isActive: (editor) => editor.isActive("codeBlock"),
+ },
+];
+interface NodeSelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => {
+ const { editor } = useEditor();
+ if (!editor) return null;
+ const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? {
+ name: "Multiple",
+ };
+
+ return (
+
+
+
+ {activeItem.name}
+
+
+
+
+ {items.map((item) => (
+ {
+ item.command(editor);
+ onOpenChange(false);
+ }}
+ className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent"
+ >
+
+ {activeItem.name === item.name && }
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/app/(editor)/components/selectors/text-buttons.tsx b/apps/web/app/(editor)/components/selectors/text-buttons.tsx
new file mode 100644
index 00000000..f75d7b3c
--- /dev/null
+++ b/apps/web/app/(editor)/components/selectors/text-buttons.tsx
@@ -0,0 +1,63 @@
+import { Button } from "@repo/ui/shadcn/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover";
+import { BoldIcon, CodeIcon, ItalicIcon, StrikethroughIcon, UnderlineIcon } from "lucide-react";
+import { EditorBubbleItem, useEditor } from "novel";
+import type { SelectorItem } from "./node-selector";
+import { cn } from "@repo/ui/lib/utils";
+
+export const TextButtons = () => {
+ const { editor } = useEditor();
+ if (!editor) return null;
+ const items: SelectorItem[] = [
+ {
+ name: "bold",
+ isActive: (editor) => editor.isActive("bold"),
+ command: (editor) => editor.chain().focus().toggleBold().run(),
+ icon: BoldIcon,
+ },
+ {
+ name: "italic",
+ isActive: (editor) => editor.isActive("italic"),
+ command: (editor) => editor.chain().focus().toggleItalic().run(),
+ icon: ItalicIcon,
+ },
+ {
+ name: "underline",
+ isActive: (editor) => editor.isActive("underline"),
+ command: (editor) => editor.chain().focus().toggleUnderline().run(),
+ icon: UnderlineIcon,
+ },
+ {
+ name: "strike",
+ isActive: (editor) => editor.isActive("strike"),
+ command: (editor) => editor.chain().focus().toggleStrike().run(),
+ icon: StrikethroughIcon,
+ },
+ {
+ name: "code",
+ isActive: (editor) => editor.isActive("code"),
+ command: (editor) => editor.chain().focus().toggleCode().run(),
+ icon: CodeIcon,
+ },
+ ];
+ return (
+
+ {items.map((item) => (
+ {
+ item.command(editor);
+ }}
+ >
+
+
+
+
+ ))}
+
+ );
+};
diff --git a/apps/web/app/(editor)/components/slash-command.tsx b/apps/web/app/(editor)/components/slash-command.tsx
new file mode 100644
index 00000000..1bfb1690
--- /dev/null
+++ b/apps/web/app/(editor)/components/slash-command.tsx
@@ -0,0 +1,163 @@
+import {
+ CheckSquare,
+ Code,
+ Heading1,
+ Heading2,
+ Heading3,
+ ImageIcon,
+ List,
+ ListOrdered,
+ MessageSquarePlus,
+ Text,
+ TextQuote,
+ Youtube
+} from "lucide-react";
+import { createSuggestionItems } from "novel/extensions";
+import { Command, renderItems } from "novel/extensions";
+import { uploadFn } from "./image-upload";
+
+export const suggestionItems = createSuggestionItems([
+ {
+ title: "Send Feedback",
+ description: "Let us know how we can improve.",
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).run();
+ window.open("/feedback", "_blank");
+ },
+ },
+ {
+ title: "Text",
+ description: "Just start typing with plain text.",
+ searchTerms: ["p", "paragraph"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
+ },
+ },
+ {
+ title: "To-do List",
+ description: "Track tasks with a to-do list.",
+ searchTerms: ["todo", "task", "list", "check", "checkbox"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleTaskList().run();
+ },
+ },
+ {
+ title: "Heading 1",
+ description: "Big section heading.",
+ searchTerms: ["title", "big", "large"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
+ },
+ },
+ {
+ title: "Heading 2",
+ description: "Medium section heading.",
+ searchTerms: ["subtitle", "medium"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
+ },
+ },
+ {
+ title: "Heading 3",
+ description: "Small section heading.",
+ searchTerms: ["subtitle", "small"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
+ },
+ },
+ {
+ title: "Bullet List",
+ description: "Create a simple bullet list.",
+ searchTerms: ["unordered", "point"],
+ icon:
,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
+ },
+ },
+ {
+ title: "Numbered List",
+ description: "Create a list with numbering.",
+ searchTerms: ["ordered"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
+ },
+ },
+ {
+ title: "Quote",
+ description: "Capture a quote.",
+ searchTerms: ["blockquote"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
+ },
+ {
+ title: "Code",
+ description: "Capture a code snippet.",
+ searchTerms: ["codeblock"],
+ icon: ,
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
+ },
+ // {
+ // title: "Image",
+ // description: "Upload an image from your computer.",
+ // searchTerms: ["photo", "picture", "media"],
+ // icon: ,
+ // command: ({ editor, range }) => {
+ // editor.chain().focus().deleteRange(range).run();
+ // // upload image
+ // const input = document.createElement("input");
+ // input.type = "file";
+ // input.accept = "image/*";
+ // input.onchange = async () => {
+ // if (input.files?.length) {
+ // const file = input.files[0];
+ // const pos = editor.view.state.selection.from;
+ // uploadFn(file, editor.view, pos);
+ // }
+ // };
+ // input.click();
+ // },
+ // },
+ // {
+ // title: "Youtube",
+ // description: "Embed a Youtube video.",
+ // searchTerms: ["video", "youtube", "embed"],
+ // icon: ,
+ // command: ({ editor, range }) => {
+ // const videoLink = prompt("Please enter Youtube Video Link");
+ // //From https://regexr.com/3dj5t
+ // const ytregex = new RegExp(
+ // /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
+ // );
+
+ // if (ytregex.test(videoLink)) {
+ // editor
+ // .chain()
+ // .focus()
+ // .deleteRange(range)
+ // .setYoutubeVideo({
+ // src: videoLink,
+ // })
+ // .run();
+ // } else {
+ // if (videoLink !== null) {
+ // alert("Please enter a correct Youtube Video Link");
+ // }
+ // }
+ // },
+ // },
+]);
+
+export const slashCommand = Command.configure({
+ suggestion: {
+ items: () => suggestionItems,
+ render: renderItems,
+ },
+});
diff --git a/apps/web/app/(editor)/components/tailwind/generative/ai-completion-command.tsx b/apps/web/app/(editor)/components/tailwind/generative/ai-completion-command.tsx
new file mode 100644
index 00000000..7d37b74a
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/generative/ai-completion-command.tsx
@@ -0,0 +1,66 @@
+import { CommandGroup, CommandItem, CommandSeparator } from "../ui/command";
+import { useEditor } from "novel";
+import { Check, TextQuote, TrashIcon } from "lucide-react";
+
+const AICompletionCommands = ({
+ completion,
+ onDiscard,
+}: {
+ completion: string;
+ onDiscard: () => void;
+}) => {
+ const { editor } = useEditor();
+ return (
+ <>
+
+ {
+ const selection = editor.view.state.selection;
+
+ editor
+ .chain()
+ .focus()
+ .insertContentAt(
+ {
+ from: selection.from,
+ to: selection.to,
+ },
+ completion,
+ )
+ .run();
+ }}
+ >
+
+ Replace selection
+
+ {
+ const selection = editor.view.state.selection;
+ editor
+ .chain()
+ .focus()
+ .insertContentAt(selection.to + 1, completion)
+ .run();
+ }}
+ >
+
+ Insert below
+
+
+
+
+
+
+
+ Discard
+
+
+ >
+ );
+};
+
+export default AICompletionCommands;
diff --git a/apps/web/app/(editor)/components/tailwind/generative/ai-selector-commands.tsx b/apps/web/app/(editor)/components/tailwind/generative/ai-selector-commands.tsx
new file mode 100644
index 00000000..dad106b0
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/generative/ai-selector-commands.tsx
@@ -0,0 +1,76 @@
+import { ArrowDownWideNarrow, CheckCheck, RefreshCcwDot, StepForward, WrapText } from "lucide-react";
+import { useEditor } from "novel";
+import { getPrevText } from "novel/utils";
+import { CommandGroup, CommandItem, CommandSeparator } from "../ui/command";
+
+const options = [
+ {
+ value: "improve",
+ label: "Improve writing",
+ icon: RefreshCcwDot,
+ },
+
+ {
+ value: "fix",
+ label: "Fix grammar",
+ icon: CheckCheck,
+ },
+ {
+ value: "shorter",
+ label: "Make shorter",
+ icon: ArrowDownWideNarrow,
+ },
+ {
+ value: "longer",
+ label: "Make longer",
+ icon: WrapText,
+ },
+];
+
+interface AISelectorCommandsProps {
+ onSelect: (value: string, option: string) => void;
+}
+
+const AISelectorCommands = ({ onSelect }: AISelectorCommandsProps) => {
+ const { editor } = useEditor();
+
+ return (
+ <>
+
+ {options.map((option) => (
+ {
+ const slice = editor.state.selection.content();
+ const text = editor.storage.markdown.serializer.serialize(slice.content);
+ onSelect(text, value);
+ }}
+ className="flex gap-2 px-4"
+ key={option.value}
+ value={option.value}
+ >
+
+ {option.label}
+
+ ))}
+
+
+
+ {
+ const pos = editor.state.selection.from;
+
+ const text = getPrevText(editor, pos);
+ onSelect(text, "continue");
+ }}
+ value="continue"
+ className="gap-2 px-4"
+ >
+
+ Continue writing
+
+
+ >
+ );
+};
+
+export default AISelectorCommands;
diff --git a/apps/web/app/(editor)/components/tailwind/generative/ai-selector.tsx b/apps/web/app/(editor)/components/tailwind/generative/ai-selector.tsx
new file mode 100644
index 00000000..88ca1681
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/generative/ai-selector.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { Command, CommandInput } from "@/components/tailwind/ui/command";
+
+import { useCompletion } from "ai/react";
+import { ArrowUp } from "lucide-react";
+import { useEditor } from "novel";
+import { addAIHighlight } from "novel/extensions";
+import { useState } from "react";
+import Markdown from "react-markdown";
+import { toast } from "sonner";
+import { Button } from "../ui/button";
+import CrazySpinner from "../ui/icons/crazy-spinner";
+import Magic from "../ui/icons/magic";
+import { ScrollArea } from "../ui/scroll-area";
+import AICompletionCommands from "./ai-completion-command";
+import AISelectorCommands from "./ai-selector-commands";
+//TODO: I think it makes more sense to create a custom Tiptap extension for this functionality https://tiptap.dev/docs/editor/ai/introduction
+
+interface AISelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function AISelector({ onOpenChange }: AISelectorProps) {
+ const { editor } = useEditor();
+ const [inputValue, setInputValue] = useState("");
+
+ const { completion, complete, isLoading } = useCompletion({
+ // id: "novel",
+ api: "/api/generate",
+ onResponse: (response) => {
+ if (response.status === 429) {
+ toast.error("You have reached your request limit for the day.");
+ return;
+ }
+ },
+ onError: (e) => {
+ toast.error(e.message);
+ },
+ });
+
+ const hasCompletion = completion.length > 0;
+
+ return (
+
+ {hasCompletion && (
+
+ )}
+
+ {isLoading && (
+
+
+ AI is thinking
+
+
+
+
+ )}
+ {!isLoading && (
+ <>
+
+
addAIHighlight(editor)}
+ />
+ {
+ if (completion)
+ return complete(completion, {
+ body: { option: "zap", command: inputValue },
+ }).then(() => setInputValue(""));
+
+ const slice = editor.state.selection.content();
+ const text = editor.storage.markdown.serializer.serialize(slice.content);
+
+ complete(text, {
+ body: { option: "zap", command: inputValue },
+ }).then(() => setInputValue(""));
+ }}
+ >
+
+
+
+ {hasCompletion ? (
+ {
+ editor.chain().unsetHighlight().focus().run();
+ onOpenChange(false);
+ }}
+ completion={completion}
+ />
+ ) : (
+ complete(value, { body: { option } })} />
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/app/(editor)/components/tailwind/ui/button.tsx b/apps/web/app/(editor)/components/tailwind/ui/button.tsx
new file mode 100644
index 00000000..9995a5e8
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/button.tsx
@@ -0,0 +1,47 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@repo/ui/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,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return ;
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/apps/web/app/(editor)/components/tailwind/ui/command.tsx b/apps/web/app/(editor)/components/tailwind/ui/command.tsx
new file mode 100644
index 00000000..751bbc53
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/command.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import * as React from "react";
+import type { DialogProps } from "@radix-ui/react-dialog";
+import { Command as CommandPrimitive } from "cmdk";
+
+import { cn } from "@repo/ui/lib/utils";
+import { Dialog, DialogContent } from "@repo/ui/shadcn/dialog";
+import Magic from "./icons/magic";
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Command.displayName = CommandPrimitive.displayName;
+
+interface CommandDialogProps extends DialogProps {}
+
+const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+};
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+
+CommandInput.displayName = CommandPrimitive.Input.displayName;
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandList.displayName = CommandPrimitive.List.displayName;
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => );
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName;
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandItem.displayName = CommandPrimitive.Item.displayName;
+
+const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => {
+ return ;
+};
+CommandShortcut.displayName = "CommandShortcut";
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+};
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/asksvg.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/asksvg.tsx
new file mode 100644
index 00000000..aa38fe08
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/asksvg.tsx
@@ -0,0 +1,12 @@
+import React from 'react'
+
+function Asksvg() {
+ return (
+
+
+
+
+ )
+}
+
+export default Asksvg
\ No newline at end of file
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/autocompletesvg.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/autocompletesvg.tsx
new file mode 100644
index 00000000..c433fcad
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/autocompletesvg.tsx
@@ -0,0 +1,12 @@
+import React from 'react'
+
+function Autocompletesvg() {
+ return (
+
+
+
+
+ )
+}
+
+export default Autocompletesvg
\ No newline at end of file
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/crazy-spinner.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/crazy-spinner.tsx
new file mode 100644
index 00000000..2e95deee
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/crazy-spinner.tsx
@@ -0,0 +1,11 @@
+const CrazySpinner = () => {
+ return (
+
+ );
+};
+
+export default CrazySpinner;
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/font-default.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/font-default.tsx
new file mode 100644
index 00000000..bf29e4b5
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/font-default.tsx
@@ -0,0 +1,15 @@
+export default function FontDefault({ className }: { className?: string }) {
+ return (
+
+ Font Default Icon
+
+
+
+ );
+}
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/font-mono.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/font-mono.tsx
new file mode 100644
index 00000000..6e7b301b
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/font-mono.tsx
@@ -0,0 +1,22 @@
+export default function FontMono({ className }: { className?: string }) {
+ return (
+
+ Font Mono Icon
+
+
+
+ );
+}
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/font-serif.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/font-serif.tsx
new file mode 100644
index 00000000..92225a36
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/font-serif.tsx
@@ -0,0 +1,23 @@
+export default function FontSerif({ className }: { className?: string }) {
+ return (
+
+ Font Serif Icon
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/index.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/index.tsx
new file mode 100644
index 00000000..5b284462
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/index.tsx
@@ -0,0 +1,3 @@
+export { default as FontDefault } from "./font-default";
+export { default as FontSerif } from "./font-serif";
+export { default as FontMono } from "./font-mono";
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/loading-circle.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/loading-circle.tsx
new file mode 100644
index 00000000..2a638117
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/loading-circle.tsx
@@ -0,0 +1,20 @@
+export default function LoadingCircle({ dimensions }: { dimensions?: string }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/magic.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/magic.tsx
new file mode 100644
index 00000000..04dce39e
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/magic.tsx
@@ -0,0 +1,8 @@
+export default function Magic({ className }: { className: string }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/rewritesvg.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/rewritesvg.tsx
new file mode 100644
index 00000000..fad9eb90
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/rewritesvg.tsx
@@ -0,0 +1,11 @@
+import React from 'react'
+
+function Rewritesvg() {
+ return (
+
+
+
+ )
+}
+
+export default Rewritesvg
\ No newline at end of file
diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/translatesvg.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/translatesvg.tsx
new file mode 100644
index 00000000..cde82da6
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/icons/translatesvg.tsx
@@ -0,0 +1,12 @@
+import React from 'react'
+
+function Translatesvg() {
+ return (
+
+
+
+
+ )
+}
+
+export default Translatesvg
\ No newline at end of file
diff --git a/apps/web/app/(editor)/components/tailwind/ui/popover.tsx b/apps/web/app/(editor)/components/tailwind/ui/popover.tsx
new file mode 100644
index 00000000..0bf7e696
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/popover.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+
+import { cn } from "@repo/ui/lib/utils";
+
+const Popover = PopoverPrimitive.Root;
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent };
diff --git a/apps/web/app/(editor)/components/tailwind/ui/scroll-area.tsx b/apps/web/app/(editor)/components/tailwind/ui/scroll-area.tsx
new file mode 100644
index 00000000..eb6c1f59
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/scroll-area.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import * as React from "react";
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+
+import { cn } from "@repo/ui/lib/utils";
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+
+export { ScrollArea, ScrollBar };
diff --git a/apps/web/app/(editor)/components/tailwind/ui/separator.tsx b/apps/web/app/(editor)/components/tailwind/ui/separator.tsx
new file mode 100644
index 00000000..d956f2f0
--- /dev/null
+++ b/apps/web/app/(editor)/components/tailwind/ui/separator.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import { cn } from "@repo/ui/lib/utils";
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
+
+));
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/apps/web/app/(editor)/editor.tsx b/apps/web/app/(editor)/editor.tsx
new file mode 100644
index 00000000..140207e0
--- /dev/null
+++ b/apps/web/app/(editor)/editor.tsx
@@ -0,0 +1,145 @@
+"use client";
+import { defaultEditorContent } from "./lib/content";
+import {
+ EditorCommand,
+ EditorCommandEmpty,
+ EditorCommandItem,
+ EditorCommandList,
+ EditorContent,
+ type EditorInstance,
+ EditorRoot,
+ type JSONContent,
+ EditorBubble,
+} from "novel";
+import { ImageResizer, handleCommandNavigation } from "novel/extensions";
+import { useEffect, useState } from "react";
+import { useDebouncedCallback } from "use-debounce";
+import { defaultExtensions } from "./components/extensions";
+import { ColorSelector } from "./components/selectors/color-selector";
+import { LinkSelector } from "./components/selectors/link-selector";
+import { NodeSelector } from "./components/selectors/node-selector";
+import { Separator } from "./components/tailwind/ui/separator";
+import { TextButtons } from "./components/selectors/text-buttons";
+import { BgColorSelector } from "./components/selectors/bgcolor-selector";
+
+import { handleImageDrop, handleImagePaste } from "novel/plugins";
+import { uploadFn } from "./components/image-upload";
+import { slashCommand, suggestionItems } from "./components/slash-command";
+import Aigenerate from "./components/aigenerate";
+
+const hljs = require('highlight.js');
+
+const extensions = [...defaultExtensions, slashCommand];
+
+const TailwindAdvancedEditor = () => {
+ const [initialContent, setInitialContent] = useState(null);
+ const [saveStatus, setSaveStatus] = useState("Saved");
+ const [charsCount, setCharsCount] = useState();
+
+ const [openNode, setOpenNode] = useState(false);
+ const [openColor, setOpenColor] = useState(false);
+ const [openBgColor, setOpenBgColor] = useState(false);
+ const [openLink, setOpenLink] = useState(false);
+ const [openAI, setOpenAI] = useState(false);
+
+ //Apply Codeblock Highlighting on the HTML from editor.getHTML()
+ const highlightCodeblocks = (content: string) => {
+ const doc = new DOMParser().parseFromString(content, 'text/html');
+ doc.querySelectorAll('pre code').forEach((el) => {
+ // @ts-ignore
+ // https://highlightjs.readthedocs.io/en/latest/api.html?highlight=highlightElement#highlightelement
+ hljs.highlightElement(el);
+ });
+ return new XMLSerializer().serializeToString(doc);
+ };
+
+ const debouncedUpdates = useDebouncedCallback(async (editor: EditorInstance) => {
+ const json = editor.getJSON();
+ setCharsCount(editor.storage.characterCount.words());
+ window.localStorage.setItem("html-content", highlightCodeblocks(editor.getHTML()));
+ window.localStorage.setItem("novel-content", JSON.stringify(json));
+ window.localStorage.setItem("markdown", editor.storage.markdown.getMarkdown());
+ setSaveStatus("Saved");
+ }, 500);
+
+ useEffect(() => {
+ const content = window.localStorage.getItem("novel-content");
+ if (content) setInitialContent(JSON.parse(content));
+ else setInitialContent(defaultEditorContent);
+ }, []);
+
+ if (!initialContent) return null;
+
+ return (
+
+
+
Untitled - {saveStatus}
+
+
+ handleCommandNavigation(event),
+ },
+ handlePaste: (view, event) => handleImagePaste(view, event, uploadFn),
+ handleDrop: (view, event, _slice, moved) => handleImageDrop(view, event, moved, uploadFn),
+ attributes: {
+ class:
+ "prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full",
+ },
+ }}
+ onUpdate={({ editor }) => {
+ debouncedUpdates(editor);
+ setSaveStatus("Unsaved");
+
+ }}
+ slotAfter={ }
+ >
+
+
+ No results
+
+ {suggestionItems.map((item) => (
+ item.command(val)}
+ className="flex w-full items-center space-x-4 rounded-md px-4 py-3 text-left text-sm hover:bg-accent aria-selected:bg-[#21303D] group/command"
+ key={item.title}
+ >
+
+ {item.icon}
+
+
+
{item.title}
+
{item.description}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default TailwindAdvancedEditor;
diff --git a/apps/web/app/(editor)/editor/page.tsx b/apps/web/app/(editor)/editor/page.tsx
new file mode 100644
index 00000000..d0298065
--- /dev/null
+++ b/apps/web/app/(editor)/editor/page.tsx
@@ -0,0 +1,8 @@
+import TailwindAdvancedEditor from "../editor";
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/(editor)/layout.tsx b/apps/web/app/(editor)/layout.tsx
new file mode 100644
index 00000000..1bf97715
--- /dev/null
+++ b/apps/web/app/(editor)/layout.tsx
@@ -0,0 +1,12 @@
+import "./styles/prosemirror.css";
+import "./styles/globals.css"
+import type { ReactNode } from "react";
+
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/app/(editor)/lib/content.ts b/apps/web/app/(editor)/lib/content.ts
new file mode 100644
index 00000000..6464cfa1
--- /dev/null
+++ b/apps/web/app/(editor)/lib/content.ts
@@ -0,0 +1,231 @@
+export const defaultEditorContent = {
+ type: "doc",
+ content: [
+ {
+ type: "heading",
+ attrs: { level: 2 },
+ content: [{ type: "text", text: "Introducing Novel" }],
+ },
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ marks: [
+ {
+ type: "link",
+ attrs: {
+ href: "https://github.com/steven-tey/novel",
+ target: "_blank",
+ },
+ },
+ ],
+ text: "Novel",
+ },
+ {
+ type: "text",
+ text: " is a Notion-style WYSIWYG editor with AI-powered autocompletion. Built with ",
+ },
+ {
+ type: "text",
+ marks: [
+ {
+ type: "link",
+ attrs: {
+ href: "https://tiptap.dev/",
+ target: "_blank",
+ },
+ },
+ ],
+ text: "Tiptap",
+ },
+ { type: "text", text: " + " },
+ {
+ type: "text",
+ marks: [
+ {
+ type: "link",
+ attrs: {
+ href: "https://sdk.vercel.ai/docs",
+ target: "_blank",
+ },
+ },
+ ],
+ text: "Vercel AI SDK",
+ },
+ { type: "text", text: "." },
+ ],
+ },
+ {
+ type: "heading",
+ attrs: { level: 3 },
+ content: [{ type: "text", text: "Installation" }],
+ },
+ {
+ type: "codeBlock",
+ attrs: { language: null },
+ content: [{ type: "text", text: "npm i novel" }],
+ },
+ {
+ type: "heading",
+ attrs: { level: 3 },
+ content: [{ type: "text", text: "Usage" }],
+ },
+ {
+ type: "codeBlock",
+ attrs: { language: null },
+ content: [
+ {
+ type: "text",
+ text: 'import { Editor } from "novel";\n\nexport default function App() {\n return (\n \n )\n}',
+ },
+ ],
+ },
+ {
+ type: "heading",
+ attrs: { level: 3 },
+ content: [{ type: "text", text: "Features" }],
+ },
+ {
+ type: "orderedList",
+ attrs: { tight: true, start: 1 },
+ content: [
+ {
+ type: "listItem",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "Slash menu & bubble menu" }],
+ },
+ ],
+ },
+ {
+ type: "listItem",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "AI autocomplete (type " },
+ { type: "text", marks: [{ type: "code" }], text: "++" },
+ {
+ type: "text",
+ text: " to activate, or select from slash menu)",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "listItem",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "Image uploads (drag & drop / copy & paste, or select from slash menu) ",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "image",
+ attrs: {
+ src: "https://public.blob.vercel-storage.com/pJrjXbdONOnAeZAZ/banner-2wQk82qTwyVgvlhTW21GIkWgqPGD2C.png",
+ alt: "banner.png",
+ title: "banner.png",
+ width: null,
+ height: null,
+ },
+ },
+ { type: "horizontalRule" },
+ {
+ type: "heading",
+ attrs: { level: 3 },
+ content: [{ type: "text", text: "Learn more" }],
+ },
+ {
+ type: "taskList",
+ content: [
+ {
+ type: "taskItem",
+ attrs: { checked: false },
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "Star us on " },
+ {
+ type: "text",
+ marks: [
+ {
+ type: "link",
+ attrs: {
+ href: "https://github.com/steven-tey/novel",
+ target: "_blank",
+ },
+ },
+ ],
+ text: "GitHub",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "taskItem",
+ attrs: { checked: false },
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "Install the " },
+ {
+ type: "text",
+ marks: [
+ {
+ type: "link",
+ attrs: {
+ href: "https://www.npmjs.com/package/novel",
+ target: "_blank",
+ },
+ },
+ ],
+ text: "NPM package",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "taskItem",
+ attrs: { checked: false },
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ marks: [
+ {
+ type: "link",
+ attrs: {
+ href: "https://vercel.com/templates/next.js/novel",
+ target: "_blank",
+ },
+ },
+ ],
+ text: "Deploy your own",
+ },
+ { type: "text", text: " to Vercel" },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
diff --git a/apps/web/app/(editor)/lib/use-local-storage.ts b/apps/web/app/(editor)/lib/use-local-storage.ts
new file mode 100644
index 00000000..5f2ebeb9
--- /dev/null
+++ b/apps/web/app/(editor)/lib/use-local-storage.ts
@@ -0,0 +1,27 @@
+import { useEffect, useState } from "react";
+
+const useLocalStorage = (
+ key: string,
+ initialValue: T,
+ // eslint-disable-next-line no-unused-vars
+): [T, (value: T) => void] => {
+ const [storedValue, setStoredValue] = useState(initialValue);
+
+ useEffect(() => {
+ // Retrieve from localStorage
+ const item = window.localStorage.getItem(key);
+ if (item) {
+ setStoredValue(JSON.parse(item));
+ }
+ }, [key]);
+
+ const setValue = (value: T) => {
+ // Save state
+ setStoredValue(value);
+ // Save to localStorage
+ window.localStorage.setItem(key, JSON.stringify(value));
+ };
+ return [storedValue, setValue];
+};
+
+export default useLocalStorage;
diff --git a/apps/web/app/(editor)/styles/globals.css b/apps/web/app/(editor)/styles/globals.css
new file mode 100644
index 00000000..0b9378f3
--- /dev/null
+++ b/apps/web/app/(editor)/styles/globals.css
@@ -0,0 +1,169 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 222.2 84% 4.9%;
+
+ --radius: 0.5rem;
+
+ --novel-highlight-default: #ffffff;
+ --novel-highlight-purple: #f6f3f8;
+ --novel-highlight-red: #fdebeb;
+ --novel-highlight-yellow: #fbf4a2;
+ --novel-highlight-blue: #c1ecf9;
+ --novel-highlight-green: #acf79f;
+ --novel-highlight-orange: #faebdd;
+ --novel-highlight-pink: #faf1f5;
+ --novel-highlight-gray: #f1f1ef;
+ }
+
+ .dark {
+ --background: #171B1F;
+ --foreground: 210 40% 98%;
+
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 212.7 26.8% 83.9%;
+
+ --novel-highlight-default: #000000;
+ --novel-highlight-purple: #3f2c4b;
+ --novel-highlight-red: #5c1a1a;
+ --novel-highlight-yellow: #5c4b1a;
+ --novel-highlight-blue: #1a3d5c;
+ --novel-highlight-green: #1a5c20;
+ --novel-highlight-orange: #5c3a1a;
+ --novel-highlight-pink: #5c1a3a;
+ --novel-highlight-gray: #3a3a3a;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+
+pre {
+ background: #0d0d0d;
+ border-radius: 0.5rem;
+ color: #fff;
+ font-family: "JetBrainsMono", monospace;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+
+ .hljs-comment,
+ .hljs-quote {
+ color: #616161;
+ }
+
+ .hljs-variable,
+ .hljs-template-variable,
+ .hljs-attribute,
+ .hljs-tag,
+ .hljs-name,
+ .hljs-regexp,
+ .hljs-link,
+ .hljs-name,
+ .hljs-selector-id,
+ .hljs-selector-class {
+ color: #f98181;
+ }
+
+ .hljs-number,
+ .hljs-meta,
+ .hljs-built_in,
+ .hljs-builtin-name,
+ .hljs-literal,
+ .hljs-type,
+ .hljs-params {
+ color: #fbbc88;
+ }
+
+ .hljs-string,
+ .hljs-symbol,
+ .hljs-bullet {
+ color: #b9f18d;
+ }
+
+ .hljs-title,
+ .hljs-section {
+ color: #faf594;
+ }
+
+ .hljs-keyword,
+ .hljs-selector-tag {
+ color: #70cff8;
+ }
+
+ .hljs-emphasis {
+ font-style: italic;
+ }
+
+ .hljs-strong {
+ font-weight: 700;
+ }
+}
+
+::-webkit-scrollbar-thumb, ::-webkit-scrollbar-thumb:hover, ::-webkit-scrollbar-track {
+ background: transparent;
+}
+
diff --git a/apps/web/app/(editor)/styles/prosemirror.css b/apps/web/app/(editor)/styles/prosemirror.css
new file mode 100644
index 00000000..7298c98b
--- /dev/null
+++ b/apps/web/app/(editor)/styles/prosemirror.css
@@ -0,0 +1,203 @@
+.ProseMirror {
+ @apply p-12 px-8 sm:px-12;
+}
+
+.ProseMirror .is-editor-empty:first-child::before {
+ content: attr(data-placeholder);
+ float: left;
+ color: hsl(var(--muted-foreground));
+ pointer-events: none;
+ height: 0;
+}
+.ProseMirror .is-empty::before {
+ content: attr(data-placeholder);
+ float: left;
+ color: hsl(var(--muted-foreground));
+ pointer-events: none;
+ height: 0;
+}
+
+/* Custom image styles */
+
+.ProseMirror img {
+ transition: filter 0.1s ease-in-out;
+
+ &:hover {
+ cursor: pointer;
+ filter: brightness(90%);
+ }
+
+ &.ProseMirror-selectednode {
+ outline: 3px solid #5abbf7;
+ filter: brightness(90%);
+ }
+}
+
+.img-placeholder {
+ position: relative;
+
+ &:before {
+ content: "";
+ box-sizing: border-box;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: 3px solid var(--novel-stone-200);
+ border-top-color: var(--novel-stone-800);
+ animation: spinning 0.6s linear infinite;
+ }
+}
+
+@keyframes spinning {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
+
+ul[data-type="taskList"] li > label {
+ margin-right: 0.2rem;
+ user-select: none;
+}
+
+@media screen and (max-width: 768px) {
+ ul[data-type="taskList"] li > label {
+ margin-right: 0.5rem;
+ }
+}
+
+ul[data-type="taskList"] li > label input[type="checkbox"] {
+ -webkit-appearance: none;
+ appearance: none;
+ background-color: hsl(var(--background));
+ margin: 0;
+ cursor: pointer;
+ width: 1.2em;
+ height: 1.2em;
+ position: relative;
+ top: 5px;
+ border: 2px solid hsl(var(--border));
+ margin-right: 0.3rem;
+ display: grid;
+ place-content: center;
+
+ &:hover {
+ background-color: hsl(var(--accent));
+ }
+
+ &:active {
+ background-color: hsl(var(--accent));
+ }
+
+ &::before {
+ content: "";
+ width: 0.65em;
+ height: 0.65em;
+ transform: scale(0);
+ transition: 120ms transform ease-in-out;
+ box-shadow: inset 1em 1em;
+ transform-origin: center;
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
+ }
+
+ &:checked::before {
+ transform: scale(1);
+ }
+}
+
+ul[data-type="taskList"] li[data-checked="true"] > div > p {
+ color: var(--muted-foreground);
+ text-decoration: line-through;
+ text-decoration-thickness: 2px;
+}
+
+/* Overwrite tippy-box original max-width */
+
+.tippy-box {
+ max-width: 400px !important;
+}
+
+.ProseMirror:not(.dragging) .ProseMirror-selectednode {
+ outline: none !important;
+ background-color: var(--novel-highlight-blue);
+ transition: background-color 0.2s;
+ box-shadow: none;
+}
+
+.drag-handle {
+ position: fixed;
+ opacity: 1;
+ transition: opacity ease-in 0.2s;
+ border-radius: 0.25rem;
+
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
+ background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem);
+ background-repeat: no-repeat;
+ background-position: center;
+ width: 1.2rem;
+ height: 1.5rem;
+ z-index: 50;
+ cursor: grab;
+
+ &:hover {
+ background-color: var(--novel-stone-100);
+ transition: background-color 0.2s;
+ }
+
+ &:active {
+ background-color: var(--novel-stone-200);
+ transition: background-color 0.2s;
+ cursor: grabbing;
+ }
+
+ &.hide {
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ @media screen and (max-width: 600px) {
+ display: none;
+ pointer-events: none;
+ }
+}
+
+.dark .drag-handle {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
+}
+
+/* Custom Youtube Video CSS */
+iframe {
+ border: 8px solid #ffd00027;
+ border-radius: 4px;
+ min-width: 200px;
+ min-height: 200px;
+ display: block;
+ outline: 0px solid transparent;
+}
+
+div[data-youtube-video] > iframe {
+ cursor: move;
+ aspect-ratio: 16 / 9;
+ width: 100%;
+}
+
+.ProseMirror-selectednode iframe {
+ transition: outline 0.15s;
+ outline: 6px solid #fbbf24;
+}
+
+@media only screen and (max-width: 480px) {
+ div[data-youtube-video] > iframe {
+ max-height: 50px;
+ }
+}
+
+@media only screen and (max-width: 720px) {
+ div[data-youtube-video] > iframe {
+ max-height: 100px;
+ }
+}
\ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index 16a59cb1..0f25e203 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -18,12 +18,15 @@
"@radix-ui/react-popover": "^1.0.7",
"@repo/ui": "*",
"cmdk": "^1.0.0",
+ "lowlight": "^3.1.0",
"million": "^3.1.6",
"next": "^14.1.1",
+ "novel": "^0.4.2",
"nuqs": "^1.17.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-resizable-panels": "^2.0.19"
+ "react-resizable-panels": "^2.0.19",
+ "use-debounce": "^10.0.1"
},
"devDependencies": {
"@next/eslint-plugin-next": "^14.1.1",
diff --git a/apps/web/public/landing-ui.svg b/apps/web/public/landing-ui.svg
index 43e64a70..2d398618 100644
--- a/apps/web/public/landing-ui.svg
+++ b/apps/web/public/landing-ui.svg
@@ -1,5 +1,5 @@
-
+
@@ -54,7 +54,7 @@
-
+
diff --git a/package.json b/package.json
index fd2341b3..1fc76d17 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,7 @@
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-scroll-area": "^1.0.5",
+ "@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
diff --git a/packages/ui/icons/index.ts b/packages/ui/icons/index.ts
index e2331ee6..7788c20f 100644
--- a/packages/ui/icons/index.ts
+++ b/packages/ui/icons/index.ts
@@ -5,6 +5,9 @@ import ExploreIcon from "./explore.svg";
import MemoriesIcon from "./memories.svg";
import ArrowRightIcon from "./arrowright.svg";
import SelectIcon from "./select.svg";
+import SearchIcon from "./search.svg";
+import NextIcon from "./nextarrow.svg";
+import UrlIcon from "./url.svg";
export {
AddIcon,
@@ -14,4 +17,7 @@ export {
MemoriesIcon,
ArrowRightIcon,
SelectIcon,
+ SearchIcon,
+ NextIcon,
+ UrlIcon
};
diff --git a/packages/ui/icons/nextarrow.svg b/packages/ui/icons/nextarrow.svg
new file mode 100644
index 00000000..b0f4a5fe
--- /dev/null
+++ b/packages/ui/icons/nextarrow.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/packages/ui/icons/search.svg b/packages/ui/icons/search.svg
new file mode 100644
index 00000000..af8b6664
--- /dev/null
+++ b/packages/ui/icons/search.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/packages/ui/icons/url.svg b/packages/ui/icons/url.svg
new file mode 100644
index 00000000..65084459
--- /dev/null
+++ b/packages/ui/icons/url.svg
@@ -0,0 +1,4 @@
+
+
+
+