mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Add Memories, Canvas, Editor
This commit is contained in:
parent
99659df86f
commit
743976d180
50 changed files with 2781 additions and 14 deletions
|
|
@ -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 (
|
||||
<div lang="en" className="bg-[#171B1F]">
|
||||
<div lang="en" className="bg-[#151515]">
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-10">
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<div
|
||||
onClick={() => 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]"
|
||||
>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Image src={AddIcon} alt="Add icon" />
|
||||
Add Content
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ async function Layout({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="h-screen flex flex-col p-4 relative">
|
||||
<main className="h-screen flex flex-col p-4 relative ">
|
||||
<Header />
|
||||
|
||||
<Menu />
|
||||
|
|
|
|||
94
apps/web/app/(dash)/memories/page.tsx
Normal file
94
apps/web/app/(dash)/memories/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="max-w-3xl min-w-3xl py-36 h-full flex mx-auto w-full flex-col gap-12">
|
||||
<h2 className="text-white w-full font-medium text-2xl text-left">
|
||||
My Memories
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full relative">
|
||||
<input
|
||||
type="text"
|
||||
className=" w-full py-3 rounded-md text-lg pl-8 bg-[#1F2428] outline-none"
|
||||
placeholder="search here..."
|
||||
/>
|
||||
<Image className="absolute top-1/2 -translate-y-1/2 left-2" src={SearchIcon} alt="Search icon" />
|
||||
</div>
|
||||
|
||||
<Filters filter={filter} setFilter={setFilterfn} />
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[#B3BCC5]">Spaces</div>
|
||||
<TabComponent title="AI Technologies" description="Resources 12" />
|
||||
<TabComponent title="Python Tricks" description="Resources 120" />
|
||||
<TabComponent title="JavaScript Hacks" description="Resources 14" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[#B3BCC5]">Pages</div>
|
||||
<LinkComponent title="How to make a custom AI model?" url="https://google.com" />
|
||||
<LinkComponent title="GPT 5 Release Date" url="https://wth.com" />
|
||||
<LinkComponent title="Why @sama never use uppercase" url="https://tom.com" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabComponent({title, description}: {title:string, description:string}){
|
||||
return (
|
||||
<div className="flex items-center my-6">
|
||||
<div>
|
||||
<div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md">
|
||||
{title.slice(0,2).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow px-4">
|
||||
<div className="text-lg text-[#fff]">{title}</div>
|
||||
<div>{description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Image src={NextIcon} alt="Search icon" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkComponent({title, url}: {title:string, url:string}){
|
||||
return (
|
||||
<div className="flex items-center my-6">
|
||||
<div>
|
||||
<div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md">
|
||||
<Image src={UrlIcon} alt="Url icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow px-4">
|
||||
<div className="text-lg text-[#fff]">{title}</div>
|
||||
<div>{url}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FilterMethods = ["All", "Spaces", "Pages", "Notes"]
|
||||
function Filters({setFilter, filter}:{setFilter: (i:string)=> void, filter: string}){
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{FilterMethods.map((i)=> {
|
||||
return <div onClick={()=> 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}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default page;
|
||||
|
|
@ -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() {
|
|||
<div className="">
|
||||
<div className="group inline-flex w-14 text-foreground-menu text-[15px] font-medium flex-col items-start gap-6 overflow-hidden rounded-[28px] bg-secondary px-3 py-4 duration-200 hover:w-40">
|
||||
{menuItems.map((item) => (
|
||||
<div
|
||||
<Link
|
||||
href={item.url}
|
||||
key={item.url}
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-1 duration-200 hover:scale-105 hover:brightness-150 active:scale-90"
|
||||
>
|
||||
|
|
@ -38,7 +40,7 @@ function Menu() {
|
|||
<p className="opacity-0 duration-200 group-hover:opacity-100">
|
||||
{item.text}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
141
apps/web/app/(editor)/api/generate/route.ts
Normal file
141
apps/web/app/(editor)/api/generate/route.ts
Normal file
|
|
@ -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<Response> {
|
||||
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);
|
||||
}
|
||||
26
apps/web/app/(editor)/api/upload/route.ts
Normal file
26
apps/web/app/(editor)/api/upload/route.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
144
apps/web/app/(editor)/components/aigenerate.tsx
Normal file
144
apps/web/app/(editor)/components/aigenerate.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="z-[60] bg-[#171B1F] fixed left-0 bottom-0 w-screen flex justify-center py-4">
|
||||
<motion.div
|
||||
animate={{
|
||||
y: visible ? "30%" : 0,
|
||||
}}
|
||||
onClick={()=> {
|
||||
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]"}`}>
|
||||
<Magic className="h-4 w-4 shrink-0 translate-y-[5%]" />
|
||||
{
|
||||
(visible && generating) ? (
|
||||
<>Generating <CrazySpinner /></>
|
||||
) : visible ? (
|
||||
<>Press Commands</>
|
||||
) : (
|
||||
<>Ask AI</>
|
||||
)
|
||||
}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
}}
|
||||
animate={{
|
||||
y: visible ? "-60%" : 20,
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
}}
|
||||
className='absolute z-50 top-0'>
|
||||
<ToolBar setGeneratingfn={setGeneratingfn} editor={editor} />
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Aigenerate
|
||||
|
||||
const options = [
|
||||
<div className='flex items-center whitespace-nowrap gap-3 relative z-[60] pointer-events-none'><Translatesvg />Translate</div>,
|
||||
<div className='flex items-center whitespace-nowrap gap-3 relative z-[60] pointer-events-none'><Rewritesvg />Change Tone</div>,
|
||||
<div className='flex items-center whitespace-nowrap gap-3 relative z-[60] pointer-events-none'><Asksvg />Ask Gemini</div>,
|
||||
<div className='flex items-center whitespace-nowrap gap-3 relative z-[60] pointer-events-none'><Autocompletesvg />Auto Complete</div>
|
||||
];
|
||||
|
||||
function ToolBar({editor, setGeneratingfn}: {editor:Editor, setGeneratingfn: (v: boolean) => void}) {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
return (
|
||||
<div className={"flex gap-6 bg-[#1F2428] active:scale-[.98] transition rounded-3xl px-1 py-1 text-sm font-medium"}>
|
||||
{options.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative block h-full w-full px-3 py-2 text-[#989EA4]"
|
||||
onMouseEnter={() => setIndex(idx)}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{index === idx && (
|
||||
<motion.span
|
||||
onClick={()=> 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 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
141
apps/web/app/(editor)/components/extensions.ts
Normal file
141
apps/web/app/(editor)/components/extensions.ts
Normal file
|
|
@ -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,
|
||||
];
|
||||
60
apps/web/app/(editor)/components/image-upload.ts
Normal file
60
apps/web/app/(editor)/components/image-upload.ts
Normal file
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
107
apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx
Normal file
107
apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx
Normal file
|
|
@ -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 (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" className="gap-2 rounded-none" variant="ghost">
|
||||
<span
|
||||
className="rounded-sm px-1"
|
||||
style={{
|
||||
backgroundColor: activeHighlightItem?.color,
|
||||
}}
|
||||
>
|
||||
A
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
sideOffset={5}
|
||||
className="my-1 border-none bg-[#1F2428] flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl "
|
||||
align="start"
|
||||
>
|
||||
<div>
|
||||
<div className="my-1 px-2 text-sm font-semibold text-muted-foreground">Background</div>
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }) => (
|
||||
<EditorBubbleItem
|
||||
key={name}
|
||||
onSelect={() => {
|
||||
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]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-sm px-2 py-px font-medium" style={{ backgroundColor: color }}>
|
||||
A
|
||||
</div>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
{editor.isActive("highlight", { color }) && <Check className="h-4 w-4" />}
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
111
apps/web/app/(editor)/components/selectors/color-selector.tsx
Normal file
111
apps/web/app/(editor)/components/selectors/color-selector.tsx
Normal file
|
|
@ -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 (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" className="gap-2 rounded-none" variant="ghost">
|
||||
<span
|
||||
className="rounded-sm px-1"
|
||||
style={{
|
||||
color: activeColorItem?.color
|
||||
}}
|
||||
>
|
||||
A
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
sideOffset={5}
|
||||
className="my-1 border-none bg-[#1F2428] flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl "
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="my-1 px-2 text-sm font-semibold text-muted-foreground">Color</div>
|
||||
{TEXT_COLORS.map(({ name, color }) => (
|
||||
<EditorBubbleItem
|
||||
key={name}
|
||||
onSelect={() => {
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-sm px-2 py-px font-medium" style={{ color }}>
|
||||
A
|
||||
</div>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
95
apps/web/app/(editor)/components/selectors/link-selector.tsx
Normal file
95
apps/web/app/(editor)/components/selectors/link-selector.tsx
Normal file
|
|
@ -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<HTMLInputElement>(null);
|
||||
const { editor } = useEditor();
|
||||
|
||||
// Autofocus on input by default
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="gap-2 rounded-none border-none">
|
||||
<p className="text-base">↗</p>
|
||||
<p
|
||||
className={cn("underline decoration-stone-400 underline-offset-4", {
|
||||
"text-blue-500": editor.isActive("link"),
|
||||
})}
|
||||
>
|
||||
Link
|
||||
</p>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
const target = e.currentTarget as HTMLFormElement;
|
||||
e.preventDefault();
|
||||
const input = target[0] as HTMLInputElement;
|
||||
const url = getUrlFromString(input.value);
|
||||
url && editor.chain().focus().setLink({ href: url }).run();
|
||||
}}
|
||||
className="flex p-1 "
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Paste a link"
|
||||
className="flex-1 bg-background p-1 text-sm outline-none"
|
||||
defaultValue={editor.getAttributes("link").href || ""}
|
||||
/>
|
||||
{editor.getAttributes("link").href ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
inputRef.current.value = "";
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" className="h-8">
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
126
apps/web/app/(editor)/components/selectors/node-selector.tsx
Normal file
126
apps/web/app/(editor)/components/selectors/node-selector.tsx
Normal file
|
|
@ -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<typeof useEditor>["editor"]) => void;
|
||||
isActive: (editor: ReturnType<typeof useEditor>["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 (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild className="gap-2 rounded-none border-none hover:bg-accent focus:ring-0">
|
||||
<Button size="sm" variant="ghost" className="gap-2">
|
||||
<span className="whitespace-nowrap text-sm">{activeItem.name}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent sideOffset={5} align="start" className="w-48 p-1 border-none bg-[#1F2428]">
|
||||
{items.map((item) => (
|
||||
<EditorBubbleItem
|
||||
key={item.name}
|
||||
onSelect={(editor) => {
|
||||
item.command(editor);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm p-1">
|
||||
<item.icon className="h-3 w-3" />
|
||||
</div>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
63
apps/web/app/(editor)/components/selectors/text-buttons.tsx
Normal file
63
apps/web/app/(editor)/components/selectors/text-buttons.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex">
|
||||
{items.map((item) => (
|
||||
<EditorBubbleItem
|
||||
key={item.name}
|
||||
onSelect={(editor) => {
|
||||
item.command(editor);
|
||||
}}
|
||||
>
|
||||
<Button size="sm" className="rounded-none" variant="ghost">
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-blue-500": item.isActive(editor),
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
163
apps/web/app/(editor)/components/slash-command.tsx
Normal file
163
apps/web/app/(editor)/components/slash-command.tsx
Normal file
|
|
@ -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: <MessageSquarePlus stroke="inherit" size={18} />,
|
||||
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: <Text stroke="inherit" size={18} />,
|
||||
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: <CheckSquare stroke="inherit" size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 stroke="inherit" size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 stroke="inherit" size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 stroke="inherit" size={18} />,
|
||||
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: <List stroke="inherit" size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrdered stroke="inherit" size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <TextQuote stroke="inherit" size={18} />,
|
||||
command: ({ editor, range }) =>
|
||||
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code stroke="inherit" size={18} />,
|
||||
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
// {
|
||||
// title: "Image",
|
||||
// description: "Upload an image from your computer.",
|
||||
// searchTerms: ["photo", "picture", "media"],
|
||||
// icon: <ImageIcon stroke="inherit" size={18} />,
|
||||
// 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: <Youtube stroke="inherit" size={18} />,
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
className="gap-2 px-4"
|
||||
value="replace"
|
||||
onSelect={() => {
|
||||
const selection = editor.view.state.selection;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
{
|
||||
from: selection.from,
|
||||
to: selection.to,
|
||||
},
|
||||
completion,
|
||||
)
|
||||
.run();
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4 text-muted-foreground" />
|
||||
Replace selection
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
className="gap-2 px-4"
|
||||
value="insert"
|
||||
onSelect={() => {
|
||||
const selection = editor.view.state.selection;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(selection.to + 1, completion)
|
||||
.run();
|
||||
}}
|
||||
>
|
||||
<TextQuote className="h-4 w-4 text-muted-foreground" />
|
||||
Insert below
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={onDiscard} value="thrash" className="gap-2 px-4">
|
||||
<TrashIcon className="h-4 w-4 text-muted-foreground" />
|
||||
Discard
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AICompletionCommands;
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<CommandGroup heading="Edit or review selection">
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
onSelect={(value) => {
|
||||
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.icon className="h-4 w-4 text-purple-500" />
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Use AI to do more">
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
const pos = editor.state.selection.from;
|
||||
|
||||
const text = getPrevText(editor, pos);
|
||||
onSelect(text, "continue");
|
||||
}}
|
||||
value="continue"
|
||||
className="gap-2 px-4"
|
||||
>
|
||||
<StepForward className="h-4 w-4 text-purple-500" />
|
||||
Continue writing
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AISelectorCommands;
|
||||
|
|
@ -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 (
|
||||
<Command className="w-[350px]">
|
||||
{hasCompletion && (
|
||||
<div className="flex max-h-[400px]">
|
||||
<ScrollArea>
|
||||
<div className="prose p-2 px-4 prose-sm">
|
||||
<Markdown>{completion}</Markdown>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-12 w-full items-center px-4 text-sm font-medium text-muted-foreground text-purple-500">
|
||||
<Magic className="mr-2 h-4 w-4 shrink-0 " />
|
||||
AI is thinking
|
||||
<div className="ml-2 mt-1">
|
||||
<CrazySpinner />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
autoFocus
|
||||
placeholder={hasCompletion ? "Tell AI what to do next" : "Ask AI to edit or generate..."}
|
||||
onFocus={() => addAIHighlight(editor)}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 h-6 w-6 -translate-y-1/2 rounded-full bg-purple-500 hover:bg-purple-900"
|
||||
onClick={() => {
|
||||
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(""));
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{hasCompletion ? (
|
||||
<AICompletionCommands
|
||||
onDiscard={() => {
|
||||
editor.chain().unsetHighlight().focus().run();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
completion={completion}
|
||||
/>
|
||||
) : (
|
||||
<AISelectorCommands onSelect={(value, option) => complete(value, { body: { option } })} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
47
apps/web/app/(editor)/components/tailwind/ui/button.tsx
Normal file
47
apps/web/app/(editor)/components/tailwind/ui/button.tsx
Normal file
|
|
@ -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<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 };
|
||||
134
apps/web/app/(editor)/components/tailwind/ui/command.tsx
Normal file
134
apps/web/app/(editor)/components/tailwind/ui/command.tsx
Normal file
|
|
@ -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<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-4" cmdk-input-wrapper="">
|
||||
<Magic className="mr-2 h-4 w-4 shrink-0 text-purple-500 " />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react'
|
||||
|
||||
function Asksvg() {
|
||||
return (
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.90925 4.63925C6.7875 3.8705 8.2125 3.8705 9.09075 4.63925C9.96975 5.408 9.96975 6.6545 9.09075 7.42325C8.9385 7.5575 8.76825 7.66775 8.58825 7.75475C8.0295 8.0255 7.50075 8.504 7.50075 9.125V9.6875M14.25 8C14.25 8.88642 14.0754 9.76417 13.7362 10.5831C13.397 11.4021 12.8998 12.1462 12.273 12.773C11.6462 13.3998 10.9021 13.897 10.0831 14.2362C9.26417 14.5754 8.38642 14.75 7.5 14.75C6.61358 14.75 5.73583 14.5754 4.91689 14.2362C4.09794 13.897 3.35382 13.3998 2.72703 12.773C2.10023 12.1462 1.60303 11.4021 1.26381 10.5831C0.924594 9.76417 0.75 8.88642 0.75 8C0.75 6.20979 1.46116 4.4929 2.72703 3.22703C3.9929 1.96116 5.70979 1.25 7.5 1.25C9.29021 1.25 11.0071 1.96116 12.273 3.22703C13.5388 4.4929 14.25 6.20979 14.25 8ZM7.5 11.9375H7.506V11.9435H7.5V11.9375Z" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default Asksvg
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react'
|
||||
|
||||
function Autocompletesvg() {
|
||||
return (
|
||||
<svg width="15" height="10" viewBox="0 0 15 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.3125 1.0625H13.6875M1.3125 5H13.6875M1.3125 8.9375H7.5" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default Autocompletesvg
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
const CrazySpinner = () => {
|
||||
return (
|
||||
<div className="flex justify-center items-center gap-1.5">
|
||||
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-[#369DFD] [animation-delay:-0.4s]" />
|
||||
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-[#369DFD] [animation-delay:-0.2s]" />
|
||||
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-[#369DFD]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CrazySpinner;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export default function FontDefault({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg width="27" height="17" viewBox="0 0 27 17" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<title>Font Default Icon</title>
|
||||
<path
|
||||
d="M2.67735 16.7224H0L6.01587 0H8.92994L14.9458 16.7224H12.2685L7.54229 3.03746H7.41169L2.67735 16.7224ZM3.12629 10.1739H11.8114V12.2968H3.12629V10.1739Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20.8127 17C20.0182 17 19.2999 16.853 18.6578 16.5591C18.0156 16.2597 17.5068 15.8269 17.1314 15.2608C16.7613 14.6947 16.5763 14.0006 16.5763 13.1787C16.5763 12.471 16.7123 11.8886 16.9844 11.4313C17.2565 10.9741 17.6238 10.6121 18.0864 10.3453C18.5489 10.0786 19.0659 9.8772 19.6373 9.74111C20.2087 9.60503 20.7909 9.5016 21.3841 9.43084C22.1351 9.34374 22.7445 9.27298 23.2125 9.21854C23.6805 9.15866 24.0206 9.0634 24.2329 8.93276C24.4451 8.80211 24.5512 8.58982 24.5512 8.29587V8.23871C24.5512 7.52562 24.3499 6.9731 23.9472 6.58117C23.5499 6.18924 22.9568 5.99328 22.1677 5.99328C21.346 5.99328 20.6984 6.17563 20.225 6.54035C19.757 6.89962 19.4332 7.29971 19.2536 7.74063L16.9599 7.21806C17.232 6.45597 17.6293 5.84086 18.1517 5.37272C18.6795 4.89914 19.2863 4.5562 19.972 4.3439C20.6576 4.12616 21.3787 4.01729 22.1351 4.01729C22.6357 4.01729 23.1663 4.07717 23.7268 4.19693C24.2927 4.31124 24.8206 4.52354 25.3103 4.83381C25.8055 5.14409 26.2109 5.58774 26.5266 6.16475C26.8422 6.73631 27 7.47935 27 8.39385V16.7224H24.6165V15.0077H24.5186C24.3607 15.3234 24.124 15.6337 23.8084 15.9385C23.4928 16.2434 23.0874 16.4965 22.5922 16.6979C22.097 16.8993 21.5038 17 20.8127 17ZM21.3433 15.0403C22.0181 15.0403 22.5949 14.907 23.0738 14.6403C23.5581 14.3735 23.9254 14.0251 24.1757 13.5951C24.4315 13.1596 24.5594 12.6942 24.5594 12.1988V10.5821C24.4723 10.6692 24.3036 10.7509 24.0533 10.8271C23.8084 10.8979 23.5282 10.9605 23.2125 11.0149C22.8969 11.0639 22.5894 11.1102 22.2902 11.1537C21.9909 11.1918 21.7405 11.2245 21.5392 11.2517C21.0658 11.3116 20.6331 11.4123 20.2413 11.5538C19.855 11.6953 19.5448 11.8995 19.3108 12.1662C19.0822 12.4275 18.968 12.7759 18.968 13.2113C18.968 13.8156 19.1911 14.2728 19.6373 14.5831C20.0835 14.8879 20.6522 15.0403 21.3433 15.0403Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
export default function FontMono({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="28"
|
||||
height="19"
|
||||
viewBox="0 0 28 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title>Font Mono Icon</title>
|
||||
<path
|
||||
d="M10.4513 13.9906H3.35401L3.80321 11.8861H9.94225L10.4513 13.9906ZM7.06738 5.72075L2.90481 18.6739H0L6.4984 0H7.66631L14.1647 18.6739H11.1701L7.06738 5.72075Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20.6032 19C19.066 19 17.8681 18.6542 17.0096 17.9626C16.1711 17.2512 15.7519 16.3323 15.7519 15.2059C15.7519 14.455 15.9216 13.7832 16.261 13.1903C16.6203 12.5777 17.1194 12.0541 17.7583 11.6193C18.4171 11.1846 19.2057 10.8487 20.1241 10.6115C21.0424 10.3547 22.0806 10.2262 23.2385 10.2262C23.4781 10.2262 23.7276 10.2361 23.9872 10.2559C24.2667 10.2559 24.5562 10.2657 24.8556 10.2855C25.175 10.3053 25.4945 10.325 25.8139 10.3448L25.9037 12.4493C25.6242 12.4098 25.3248 12.3801 25.0053 12.3604C24.7059 12.3406 24.4064 12.3307 24.107 12.3307C23.8075 12.3307 23.528 12.3307 23.2685 12.3307C22.4898 12.3307 21.8111 12.39 21.2321 12.5086C20.6731 12.6074 20.2039 12.7655 19.8246 12.9828C19.4652 13.2002 19.1957 13.467 19.016 13.7832C18.8364 14.0993 18.7465 14.4649 18.7465 14.8799C18.7465 15.2158 18.8064 15.5122 18.9262 15.7691C19.046 16.0062 19.2057 16.194 19.4053 16.3323C19.625 16.4706 19.8745 16.5793 20.154 16.6583C20.4535 16.7176 20.7729 16.7473 21.1123 16.7473C21.7711 16.7473 22.3501 16.6386 22.8492 16.4212C23.3483 16.2038 23.7676 15.8877 24.107 15.4727C24.4663 15.038 24.7358 14.5044 24.9155 13.8721C25.0952 13.22 25.185 12.4592 25.185 11.5897C25.185 10.3843 25.0453 9.44566 24.7658 8.77379C24.5062 8.10192 24.1169 7.63755 23.5979 7.38066C23.0788 7.12376 22.4299 6.99532 21.6513 6.99532C21.0125 6.99532 20.3936 7.104 19.7947 7.32137C19.2157 7.51898 18.6467 7.89444 18.0877 8.44774L16.5005 6.66927C17.2592 5.95788 18.0877 5.4441 18.9861 5.12793C19.9045 4.81175 20.8428 4.65367 21.8011 4.65367C22.6795 4.65367 23.4881 4.76235 24.2267 4.97972C24.9854 5.17733 25.6442 5.51326 26.2032 5.98752C26.7822 6.46178 27.2214 7.104 27.5209 7.9142C27.8403 8.72439 28 9.75195 28 10.9969V18.6739H24.8856V16.9844C24.626 17.3796 24.3365 17.7155 24.0171 17.9922C23.6977 18.2491 23.3483 18.4566 22.969 18.6147C22.6096 18.753 22.2303 18.8518 21.831 18.9111C21.4317 18.9704 21.0225 19 20.6032 19Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
export default function FontSerif({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="31"
|
||||
height="18"
|
||||
viewBox="0 0 31 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title>Font Serif Icon</title>
|
||||
|
||||
<path
|
||||
d="M0.108486 17.8929C0.0361621 17.8214 0 17.6875 0 17.4911C0 17.1696 0.0180811 17.0089 0.0542432 17.0089C0.198892 17.0089 0.406824 16.9732 0.67804 16.9018C0.967337 16.8304 1.23855 16.7411 1.49169 16.6339C1.7629 16.5089 1.93467 16.3839 2.007 16.2589C2.18781 15.9911 2.35958 15.6875 2.52231 15.3482C2.68504 14.9911 2.85681 14.5982 3.03762 14.1696L8.08224 2.11607C8.51619 1.02679 8.85069 0.383929 9.08574 0.1875C9.21231 0.0625 9.366 0 9.54681 0C9.67337 0 9.78186 0.0446429 9.87227 0.133929L15.5678 14.1696C15.7125 14.5089 15.8481 14.8482 15.9746 15.1875C16.1193 15.5089 16.2549 15.8214 16.3815 16.125C16.4538 16.3036 16.6165 16.4643 16.8696 16.6071C17.1228 16.7321 17.385 16.8304 17.6562 16.9018C17.9274 16.9732 18.1353 17.0089 18.28 17.0089C18.3523 17.0089 18.3885 17.1696 18.3885 17.4911C18.3885 17.6875 18.3704 17.8214 18.3342 17.8929L14.9169 17.7321L11.3911 17.8929C11.3188 17.8214 11.2826 17.6518 11.2826 17.3839C11.2826 17.1339 11.3188 17.0089 11.3911 17.0089C11.608 17.0089 11.9064 16.9554 12.2861 16.8482C12.6658 16.7411 12.8918 16.6161 12.9641 16.4732C13.0184 16.3661 13.0455 16.25 13.0455 16.125C13.0455 15.9464 13.0003 15.7054 12.9099 15.4018C12.8195 15.0982 12.6929 14.7232 12.5302 14.2768L11.5809 11.7857C11.5086 11.7143 11.4453 11.6786 11.3911 11.6786C11.1018 11.6607 10.7582 11.6518 10.3605 11.6518C9.98075 11.6339 9.54681 11.625 9.05862 11.625C8.08224 11.625 7.05162 11.6518 5.96675 11.7054C5.85827 11.7054 5.80402 11.7411 5.80402 11.8125L4.7734 14.3036C4.59259 14.75 4.45698 15.125 4.36658 15.4286C4.29425 15.7143 4.25809 15.9196 4.25809 16.0446C4.25809 16.2589 4.35754 16.4375 4.55643 16.5804C4.66492 16.6518 4.82765 16.7232 5.04462 16.7946C5.27967 16.8482 5.51473 16.9018 5.74978 16.9554C5.98484 16.9911 6.14756 17.0089 6.23797 17.0089C6.29221 17.0268 6.31933 17.1518 6.31933 17.3839C6.31933 17.5089 6.31029 17.6161 6.29221 17.7054C6.29221 17.7768 6.28317 17.8393 6.26509 17.8929C5.59609 17.8571 5.04462 17.8214 4.61067 17.7857C4.19481 17.75 3.80607 17.7321 3.44444 17.7321C3.08282 17.7321 2.64888 17.7411 2.14261 17.7589C1.63634 17.7768 0.958297 17.8214 0.108486 17.8929ZM8.57043 10.1786C9.22135 10.1786 9.96267 10.1518 10.7944 10.0982L10.8486 9.99107L8.7874 4.63393L6.56343 9.99107C6.56343 10.0625 6.58151 10.0982 6.61767 10.0982C7.25051 10.1518 7.90143 10.1786 8.57043 10.1786Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M23.0534 18C22.1493 18 21.408 17.7589 20.8294 17.2768C20.2689 16.7768 19.9886 16.0982 19.9886 15.2411C19.9886 14.6518 20.1785 14.1161 20.5582 13.6339C20.956 13.1518 21.5074 12.7411 22.2126 12.4018C22.4657 12.2768 22.6917 12.1786 22.8906 12.1071C23.1076 12.0179 23.3065 11.9375 23.4873 11.8661C24.1202 11.6696 24.6445 11.5089 25.0604 11.3839C25.4943 11.2589 25.8017 11.1696 25.9825 11.1161C26.1452 11.0268 26.2266 10.8839 26.2266 10.6875V9.21429C26.2266 8.58929 26.0819 8.09821 25.7927 7.74107C25.5034 7.38393 25.1146 7.20536 24.6264 7.20536C24.084 7.20536 23.6591 7.375 23.3517 7.71429C23.0443 8.03571 22.8906 8.5 22.8906 9.10714C22.8906 9.46429 22.755 9.73214 22.4838 9.91071C22.2126 10.0714 21.9052 10.1518 21.5617 10.1518C20.8927 10.1518 20.4226 9.9375 20.1514 9.50893C20.1514 9.08036 20.3141 8.66071 20.6395 8.25C20.965 7.82143 21.3899 7.4375 21.9143 7.09821C22.4567 6.75893 23.0534 6.49107 23.7043 6.29464C24.3552 6.08036 25.0061 5.97321 25.657 5.97321C26.7781 5.97321 27.6188 6.26786 28.1794 6.85714C28.7399 7.42857 29.0201 8.41964 29.0201 9.83036V15.1071C29.0201 15.8393 29.3185 16.2054 29.9151 16.2054C30.1864 16.2054 30.4937 16.0893 30.8373 15.8571C30.9458 15.875 31 15.9732 31 16.1518C31 16.4911 30.9458 16.7589 30.8373 16.9554C30.5661 17.1696 30.2044 17.3929 29.7524 17.625C29.3004 17.8571 28.8664 17.9732 28.4506 17.9732C28.0347 17.9732 27.6279 17.8214 27.2301 17.5179C26.8323 17.2143 26.5701 16.8839 26.4436 16.5268C26.2989 16.6518 26.1904 16.7411 26.1181 16.7946C26.0458 16.8482 25.9283 16.9375 25.7655 17.0625C25.3858 17.3125 24.97 17.5357 24.5179 17.7321C24.0659 17.9107 23.5777 18 23.0534 18ZM24.4366 16.2589C24.9248 16.2589 25.3406 16.1339 25.6842 15.8839C26.0458 15.6339 26.2266 15.3482 26.2266 15.0268V12.2946L24.4908 12.8839C24.0749 13.0625 23.7043 13.2768 23.3788 13.5268C23.0534 13.7768 22.8906 14.1339 22.8906 14.5982C22.8906 15.1696 23.0353 15.5893 23.3246 15.8571C23.6139 16.125 23.9845 16.2589 24.4366 16.2589Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
export default function LoadingCircle({ dimensions }: { dimensions?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${dimensions || "h-4 w-4"} animate-spin fill-stone-600 text-stone-200`}
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export default function Magic({ className }: { className: string }) {
|
||||
return (
|
||||
<svg width="18" height="19" className={className} viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.49998 3.25C6.63578 3.25003 6.76788 3.29429 6.87629 3.37608C6.98469 3.45788 7.06351 3.57275 7.10081 3.70333L7.77831 6.075C7.92418 6.58576 8.19785 7.05092 8.57345 7.42652C8.94906 7.80213 9.41421 8.07579 9.92498 8.22167L12.2966 8.89917C12.4271 8.93655 12.5419 9.0154 12.6236 9.1238C12.7053 9.2322 12.7495 9.36425 12.7495 9.5C12.7495 9.63574 12.7053 9.7678 12.6236 9.8762C12.5419 9.9846 12.4271 10.0634 12.2966 10.1008L9.92498 10.7783C9.41421 10.9242 8.94906 11.1979 8.57345 11.5735C8.19785 11.9491 7.92418 12.4142 7.77831 12.925L7.10081 15.2967C7.06343 15.4272 6.98458 15.5419 6.87618 15.6236C6.76778 15.7054 6.63572 15.7495 6.49998 15.7495C6.36423 15.7495 6.23218 15.7054 6.12378 15.6236C6.01538 15.5419 5.93653 15.4272 5.89914 15.2967L5.22164 12.925C5.07577 12.4142 4.80211 11.9491 4.4265 11.5735C4.05089 11.1979 3.58574 10.9242 3.07498 10.7783L0.70331 10.1008C0.572814 10.0634 0.458036 9.9846 0.376329 9.8762C0.294622 9.7678 0.250427 9.63574 0.250427 9.5C0.250427 9.36425 0.294622 9.2322 0.376329 9.1238C0.458036 9.0154 0.572814 8.93655 0.70331 8.89917L3.07498 8.22167C3.58574 8.07579 4.05089 7.80213 4.4265 7.42652C4.80211 7.05092 5.07577 6.58576 5.22164 6.075L5.89914 3.70333C5.93644 3.57275 6.01526 3.45788 6.12367 3.37608C6.23208 3.29429 6.36417 3.25003 6.49998 3.25ZM14 0.75C14.1394 0.749922 14.2749 0.79647 14.3848 0.882239C14.4947 0.968007 14.5728 1.08807 14.6066 1.22333L14.8216 2.08667C15.0183 2.87 15.63 3.48167 16.4133 3.67833L17.2766 3.89333C17.4122 3.9269 17.5325 4.00488 17.6186 4.11483C17.7046 4.22479 17.7514 4.36038 17.7514 4.5C17.7514 4.63962 17.7046 4.77521 17.6186 4.88517C17.5325 4.99512 17.4122 5.0731 17.2766 5.10667L16.4133 5.32167C15.63 5.51833 15.0183 6.13 14.8216 6.91333L14.6066 7.77667C14.5731 7.91219 14.4951 8.03257 14.3851 8.11861C14.2752 8.20465 14.1396 8.2514 14 8.2514C13.8604 8.2514 13.7248 8.20465 13.6148 8.11861C13.5049 8.03257 13.4269 7.91219 13.3933 7.77667L13.1783 6.91333C13.0822 6.52869 12.8833 6.17741 12.6029 5.89706C12.3226 5.61671 11.9713 5.41782 11.5866 5.32167L10.7233 5.10667C10.5878 5.0731 10.4674 4.99512 10.3814 4.88517C10.2953 4.77521 10.2486 4.63962 10.2486 4.5C10.2486 4.36038 10.2953 4.22479 10.3814 4.11483C10.4674 4.00488 10.5878 3.9269 10.7233 3.89333L11.5866 3.67833C11.9713 3.58218 12.3226 3.38329 12.6029 3.10294C12.8833 2.82258 13.0822 2.47131 13.1783 2.08667L13.3933 1.22333C13.4271 1.08807 13.5052 0.968007 13.6152 0.882239C13.7251 0.79647 13.8605 0.749922 14 0.75ZM12.75 12C12.8812 11.9999 13.0092 12.0412 13.1157 12.1179C13.2222 12.1946 13.3018 12.303 13.3433 12.4275L13.6716 13.4133C13.7966 13.7858 14.0883 14.0792 14.4616 14.2033L15.4475 14.5325C15.5716 14.5742 15.6795 14.6538 15.756 14.7601C15.8324 14.8664 15.8736 14.9941 15.8736 15.125C15.8736 15.2559 15.8324 15.3836 15.756 15.4899C15.6795 15.5962 15.5716 15.6758 15.4475 15.7175L14.4616 16.0467C14.0891 16.1717 13.7958 16.4633 13.6716 16.8367L13.3425 17.8225C13.3008 17.9466 13.2212 18.0545 13.1149 18.131C13.0086 18.2075 12.8809 18.2486 12.75 18.2486C12.619 18.2486 12.4914 18.2075 12.3851 18.131C12.2788 18.0545 12.1992 17.9466 12.1575 17.8225L11.8283 16.8367C11.7669 16.6527 11.6636 16.4856 11.5265 16.3485C11.3894 16.2114 11.2222 16.1081 11.0383 16.0467L10.0525 15.7175C9.92834 15.6758 9.82043 15.5962 9.74398 15.4899C9.66752 15.3836 9.6264 15.2559 9.6264 15.125C9.6264 14.9941 9.66752 14.8664 9.74398 14.7601C9.82043 14.6538 9.92834 14.5742 10.0525 14.5325L11.0383 14.2033C11.4108 14.0783 11.7041 13.7867 11.8283 13.4133L12.1575 12.4275C12.1989 12.3031 12.2784 12.1949 12.3848 12.1182C12.4911 12.0414 12.6189 12.0001 12.75 12Z" fill="#369DFD"/>
|
||||
</svg>
|
||||
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react'
|
||||
|
||||
function Rewritesvg() {
|
||||
return (
|
||||
<svg width="17" height="14" viewBox="0 0 17 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.5172 5.01108H15.2612L12.8755 2.62383C12.1074 1.85573 11.1506 1.30337 10.1014 1.02228C9.05214 0.74119 7.94738 0.741272 6.89818 1.02252C5.84897 1.30377 4.8923 1.85627 4.12433 2.62448C3.35635 3.39269 2.80415 4.34954 2.52323 5.39883M1.73873 12.7331V8.98908M1.73873 8.98908H5.48273M1.73873 8.98908L4.12373 11.3763C4.89181 12.1444 5.84857 12.6968 6.89782 12.9779C7.94706 13.259 9.05182 13.2589 10.101 12.9776C11.1502 12.6964 12.1069 12.1439 12.8749 11.3757C13.6428 10.6075 14.1951 9.65062 14.476 8.60133M15.2612 1.26708V5.00958" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Rewritesvg
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react'
|
||||
|
||||
function Translatesvg() {
|
||||
return (
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.375 14.75L10.3125 6.3125L14.25 14.75M7.5 12.5H13.125M0.75 3.21575C2.2428 3.02999 3.74569 2.93706 5.25 2.9375M5.25 2.9375C6.09 2.9375 6.92475 2.966 7.7505 3.023M5.25 2.9375V1.25M7.7505 3.023C6.882 6.9935 4.2675 10.31 0.75 12.1265M7.7505 3.023C8.4225 3.06875 9.08925 3.13325 9.75 3.21575M6.30825 9.587C5.07822 8.33647 4.10335 6.85849 3.438 5.2355" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default Translatesvg
|
||||
31
apps/web/app/(editor)/components/tailwind/ui/popover.tsx
Normal file
31
apps/web/app/(editor)/components/tailwind/ui/popover.tsx
Normal file
|
|
@ -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<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-[#171B1F] p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
40
apps/web/app/(editor)/components/tailwind/ui/scroll-area.tsx
Normal file
40
apps/web/app/(editor)/components/tailwind/ui/scroll-area.tsx
Normal file
|
|
@ -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<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
21
apps/web/app/(editor)/components/tailwind/ui/separator.tsx
Normal file
21
apps/web/app/(editor)/components/tailwind/ui/separator.tsx
Normal file
|
|
@ -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<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : " w-[1px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
145
apps/web/app/(editor)/editor.tsx
Normal file
145
apps/web/app/(editor)/editor.tsx
Normal file
|
|
@ -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 | JSONContent>(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 (
|
||||
<div className="relative w-full max-w-screen-xl">
|
||||
<div className="fixed left-0 top-0 z-10 w-screen flex bg-[#171B1F] justify-center items-center pt-6 pb-4">
|
||||
<div className="rounded-lg bg-[#21303D] px-2 py-1 text-sm text-muted-foreground">Untitled - {saveStatus}</div>
|
||||
</div>
|
||||
<EditorRoot>
|
||||
<EditorContent
|
||||
initialContent={initialContent}
|
||||
extensions={extensions}
|
||||
className="relative min-h-[55vh] mt-[20vh] w-full max-w-screen-xl bg-[#171B1F] mb-[40vh]"
|
||||
editorProps={{
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => 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={<ImageResizer />}
|
||||
>
|
||||
<Aigenerate />
|
||||
<EditorCommand className="z-50 h-auto max-h-[330px] min-w-[20rem] overflow-y-auto rounded-3xl bg-[#1F2428] shadow-md transition-all">
|
||||
<EditorCommandEmpty className="px-4 text-lg text-muted-foreground">No results</EditorCommandEmpty>
|
||||
<EditorCommandList>
|
||||
{suggestionItems.map((item) => (
|
||||
<EditorCommandItem
|
||||
value={item.title}
|
||||
onCommand={(val) => 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}
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-[#2D343A] group-aria-selected/command:bg-[#369DFD33] stroke-[#989EA4] group-aria-selected/command:stroke-[#369DFD]">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[#FFFFFF] group-aria-selected/command:text-[#369DFD]">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground group-aria-selected/command:text-[#369DFDB2]">{item.description}</p>
|
||||
</div>
|
||||
</EditorCommandItem>
|
||||
))}
|
||||
</EditorCommandList>
|
||||
</EditorCommand>
|
||||
<EditorBubble
|
||||
tippyOptions={{
|
||||
placement: openAI ? "bottom-start" : "top",
|
||||
}}
|
||||
className='flex w-fit max-w-[90vw] overflow-hidden bg-[#1F2428] text-white rounded '>
|
||||
<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} />
|
||||
<Separator orientation="vertical" />
|
||||
<BgColorSelector open={openBgColor} onOpenChange={setOpenBgColor} />
|
||||
</EditorBubble>
|
||||
</EditorContent>
|
||||
</EditorRoot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TailwindAdvancedEditor;
|
||||
8
apps/web/app/(editor)/editor/page.tsx
Normal file
8
apps/web/app/(editor)/editor/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import TailwindAdvancedEditor from "../editor";
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center bg-[#171B1F]">
|
||||
<TailwindAdvancedEditor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/web/app/(editor)/layout.tsx
Normal file
12
apps/web/app/(editor)/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="dark">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
apps/web/app/(editor)/lib/content.ts
Normal file
231
apps/web/app/(editor)/lib/content.ts
Normal file
|
|
@ -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 <Editor />\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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
27
apps/web/app/(editor)/lib/use-local-storage.ts
Normal file
27
apps/web/app/(editor)/lib/use-local-storage.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
const useLocalStorage = <T>(
|
||||
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;
|
||||
169
apps/web/app/(editor)/styles/globals.css
Normal file
169
apps/web/app/(editor)/styles/globals.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
||||
203
apps/web/app/(editor)/styles/prosemirror.css
Normal file
203
apps/web/app/(editor)/styles/prosemirror.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<svg width="1364" height="804" viewBox="0 0 1364 804" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.39184" y="1.39184" width="1361.22" height="800.306" rx="32.0122" fill="#171B1F"/>
|
||||
<rect x="1.39184" y="1.39184" width="1361.22" height="800.306" rx="32.0122" fill="#151515"/>
|
||||
<rect x="1.39184" y="1.39184" width="1361.22" height="800.306" rx="32.0122" stroke="url(#paint0_linear_380_370)" stroke-width="2.78367"/>
|
||||
<g clip-path="url(#clip0_380_370)">
|
||||
<rect x="377.086" y="266.692" width="239.061" height="81.1905" rx="10.8254" fill="#1F2428"/>
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
<stop offset="1" stop-color="#0F1114"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_380_370" x1="978.794" y1="307.287" x2="936.395" y2="307.287" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#171B1F"/>
|
||||
<stop stop-color="#151515"/>
|
||||
<stop offset="1" stop-color="#1F2428" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_380_370" x1="762.744" y1="670.298" x2="768.028" y2="776.787" gradientUnits="userSpaceOnUse">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 539 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
3
packages/ui/icons/nextarrow.svg
Normal file
3
packages/ui/icons/nextarrow.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.1875 1.375L6.8125 7L1.1875 12.625" stroke="#B3BCC5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 233 B |
3
packages/ui/icons/search.svg
Normal file
3
packages/ui/icons/search.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.75 14.7501L10.8523 10.8523M10.8523 10.8523C11.9072 9.79742 12.4998 8.36662 12.4998 6.87472C12.4998 5.38282 11.9072 3.95203 10.8523 2.8971C9.79732 1.84217 8.36653 1.24951 6.87463 1.24951C5.38273 1.24951 3.95194 1.84217 2.89701 2.8971C1.84207 3.95203 1.24942 5.38282 1.24942 6.87472C1.24942 8.36662 1.84207 9.79742 2.89701 10.8523C3.95194 11.9073 5.38273 12.4999 6.87463 12.4999C8.36653 12.4999 9.79732 11.9073 10.8523 10.8523Z" stroke="#687077" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 628 B |
4
packages/ui/icons/url.svg
Normal file
4
packages/ui/icons/url.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.89249 6.51602C10.3799 6.74871 10.8043 7.09497 11.1301 7.5257C11.4559 7.95643 11.6735 8.45906 11.7648 8.99136C11.8561 9.52367 11.8183 10.0701 11.6546 10.5848C11.4908 11.0994 11.206 11.5673 10.824 11.949L7.44899 15.324C6.81605 15.957 5.9576 16.3125 5.06249 16.3125C4.16738 16.3125 3.30893 15.957 2.67599 15.324C2.04305 14.6911 1.68747 13.8326 1.68747 12.9375C1.68747 12.0424 2.04305 11.184 2.67599 10.551L3.99374 9.23327M14.0062 8.76677L15.324 7.44902C15.9569 6.81608 16.3125 5.95763 16.3125 5.06252C16.3125 4.16741 15.9569 3.30896 15.324 2.67602C14.6911 2.04308 13.8326 1.6875 12.9375 1.6875C12.0424 1.6875 11.1839 2.04308 10.551 2.67602L7.17599 6.05102C6.79397 6.43277 6.50914 6.90063 6.34543 7.41529C6.18172 7.92995 6.1439 8.47638 6.23517 9.00868C6.32643 9.54098 6.54411 10.0436 6.86991 10.4743C7.19571 10.9051 7.62012 11.2513 8.10749 11.484" stroke="#B3BCC5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1 KiB |
Loading…
Add table
Reference in a new issue