feat(write): added write

This commit is contained in:
Stan Girard 2023-07-02 23:09:32 +02:00
parent 9a3e819aa9
commit 90d783b8aa
29 changed files with 3138 additions and 27 deletions

View File

@ -8,11 +8,11 @@ main {
header,
section {
@apply px-5 md:px-10;
@apply px-5 px-10;
}
a {
@apply hover:text-primary dark:hover:text-gray-200 transition-colors;
@apply text-primary transition-colors;
}
@layer utilities {
@ -23,14 +23,14 @@ a {
.scrollbar::-webkit-scrollbar-track {
/* border-radius: 5px; */
/* background: #fff; */
@apply bg-white dark:bg-black;
@apply bg-white;
}
.scrollbar::-webkit-scrollbar-thumb {
/* background: #000; */
/* border-radius: 100vh; */
/* border: 3px solid #fff; */
@apply bg-gray-200 dark:bg-gray-600 border border-white rounded-sm;
@apply bg-gray-200 border border-white rounded-sm;
}
.scrollbar::-webkit-scrollbar-thumb:hover {
@ -40,7 +40,10 @@ a {
}
.custom-prose {
max-height: 60vh; /* Adjust this value based on the desired maximum height */
overflow-y: auto; /* Enable vertical scroll if the content exceeds the maximum height */
padding-right: 1rem; /* Optional: Add some padding if the scrollbar appears, so the text is not squished */
}
max-height: 60vh;
/* Adjust this value based on the desired maximum height */
overflow-y: auto;
/* Enable vertical scroll if the content exceeds the maximum height */
padding-right: 1rem;
/* Optional: Add some padding if the scrollbar appears, so the text is not squished */
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,44 @@
"use client";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/ui/primitives/popover";
import { useContext } from "react";
import { AppContext } from "./providers";
import { Check, Menu as MenuIcon } from "lucide-react";
export default function Menu() {
const { font: currentFont, setFont } = useContext(AppContext);
return (
<Popover>
<PopoverTrigger className="absolute bottom-5 right-5 z-10 flex h-8 w-8 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-stone-100 active:bg-stone-200 sm:bottom-auto sm:top-5">
<MenuIcon className="text-stone-600" width={16} />
</PopoverTrigger>
<PopoverContent className="w-52" align="end">
<div className="grid p-2">
<div className="p-2">
<p className="text-sm font-medium text-stone-500">Font Style</p>
</div>
{["Sans Serif", "Serif"].map((font) => (
<button
key={font}
className="flex items-center justify-between rounded px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
onClick={() => {
setFont(font);
}}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-stone-200 p-1">Aa</div>
<span>{font}</span>
</div>
{currentFont === font && <Check className="h-4 w-4" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -0,0 +1,19 @@
import Editor from "@/ui/editor";
import Github from "@/ui/icons/github";
import Menu from "./menu";
export default function Page() {
return (
<div className="flex min-h-screen flex-col items-center sm:px-5 sm:pt-[calc(20vh)]">
<a
href="/github"
target="_blank"
className="absolute bottom-5 left-5 z-10 max-h-fit rounded-lg p-2 transition-colors duration-200 hover:bg-stone-100 sm:bottom-auto sm:top-5"
>
<Github />
</a>
<Menu />
<Editor />
</div>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import useLocalStorage from "@/lib/hooks/use-local-storage";
import { defaultFontMapper, displayFontMapper } from "@/styles/fonts";
import { Analytics } from "@vercel/analytics/react";
import clsx from "clsx";
import { Dispatch, ReactNode, SetStateAction, createContext } from "react";
import { Toaster } from "sonner";
export const AppContext = createContext<{
font: string;
setFont: Dispatch<SetStateAction<string>>;
}>({
font: "Sans Serif",
setFont: () => {},
});
export default function Providers({ children }: { children: ReactNode }) {
const [font, setFont] = useLocalStorage<string>("novel__font", "Sans Serif");
return (
<AppContext.Provider
value={{
font,
setFont,
}}
>
<Toaster />
<body className={clsx(displayFontMapper[font], defaultFontMapper[font])}>
{children}
</body>
<Analytics />
</AppContext.Provider>
);
}

117
frontend/lib/editor.ts Normal file
View File

@ -0,0 +1,117 @@
import { EditorView } from "@tiptap/pm/view";
import { BlobResult } from "@vercel/blob";
import { toast } from "sonner";
export const handleImageUpload = (
file: File,
view: EditorView,
event: ClipboardEvent | DragEvent | Event,
) => {
// check if the file is an image
if (!file.type.includes("image/")) {
toast.error("File type not supported.");
// check if the file size is less than 50MB
} else if (file.size / 1024 / 1024 > 50) {
toast.error("File size too big (max 50MB).");
} else {
// reading locally
// const reader = new FileReader();
// reader.onload = (e) => {
// const { schema } = view.state;
// const node = schema.nodes.image.create({
// src: e.target?.result,
// alt: file,
// title: file.name,
// }); // creates the image element
// const transaction = view.state.tr.replaceSelectionWith(node);
// view.dispatch(transaction);
// };
// reader.readAsDataURL(file);
// upload to Vercel Blob
toast.promise(
fetch("/api/upload", {
method: "POST",
headers: {
"content-type": file?.type || "application/octet-stream",
"x-vercel-filename": file?.name || "image.png",
},
body: file,
}).then(async (res) => {
// Successfully uploaded image
if (res.status === 200) {
const { url } = (await res.json()) as BlobResult;
// preload the image
let image = new Image();
image.src = url;
image.onload = () => {
insertImage(url);
};
// No blob store configured
} else if (res.status === 401) {
const reader = new FileReader();
reader.onload = (e) => {
insertImage(e.target?.result as string);
};
reader.readAsDataURL(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) => e.message,
},
);
}
const insertImage = (url: string) => {
// for paste events
if (event instanceof ClipboardEvent) {
return view.dispatch(
view.state.tr.replaceSelectionWith(
view.state.schema.nodes.image.create({
src: url,
alt: file.name,
title: file.name,
}),
),
);
// for drag and drop events
} else if (event instanceof DragEvent) {
const { schema } = view.state;
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
const node = schema.nodes.image.create({
src: url,
alt: file.name,
title: file.name,
}); // creates the image element
const transaction = view.state.tr.insert(coordinates?.pos || 0, node); // places it in the correct position
return view.dispatch(transaction);
// for input upload events
} else if (event instanceof Event) {
return view.dispatch(
view.state.tr.replaceSelectionWith(
view.state.schema.nodes.image.create({
src: url,
alt: file.name,
title: file.name,
}),
),
);
}
};
};

View 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;

View File

@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
export default function useWindowSize() {
const [windowSize, setWindowSize] = useState<{
width: number | undefined;
height: number | undefined;
}>({
width: undefined,
height: undefined,
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures that effect is only run on mount
return {
windowSize,
isMobile: typeof windowSize?.width === "number" && windowSize?.width < 768,
isDesktop:
typeof windowSize?.width === "number" && windowSize?.width >= 768,
};
}

View File

@ -58,7 +58,34 @@
"tailwind-merge": "^1.12.0",
"tailwindcss": "3.3.2",
"typescript": "^5.0.4",
"victory": "^36.6.10"
"victory": "^36.6.10",
"@radix-ui/react-popover": "^1.0.6",
"@tiptap/core": "^2.0.3",
"@tiptap/extension-color": "^2.0.3",
"@tiptap/extension-highlight": "^2.0.3",
"@tiptap/extension-horizontal-rule": "^2.0.3",
"@tiptap/extension-image": "^2.0.3",
"@tiptap/extension-link": "^2.0.0-beta.220",
"@tiptap/extension-placeholder": "2.0.3",
"@tiptap/extension-task-item": "^2.0.3",
"@tiptap/extension-task-list": "^2.0.3",
"@tiptap/extension-text-style": "^2.0.3",
"@tiptap/extension-underline": "^2.0.3",
"@tiptap/pm": "^2.0.0-beta.220",
"@tiptap/react": "^2.0.3",
"@tiptap/starter-kit": "^2.0.0-beta.220",
"@tiptap/suggestion": "^2.0.3",
"@upstash/ratelimit": "^0.4.3",
"@vercel/blob": "^0.9.2",
"@vercel/kv": "^0.2.1",
"ai": "^2.1.3",
"eventsource-parser": "^0.1.0",
"lucide-react": "^0.244.0",
"openai-edge": "^1.0.0",
"sonner": "^0.5.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.1",
"use-debounce": "^9.0.3"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
@ -71,4 +98,4 @@
"react-icons": "^4.8.0",
"vitest": "^0.32.2"
}
}
}

Binary file not shown.

34
frontend/styles/fonts.ts Normal file
View File

@ -0,0 +1,34 @@
import localFont from "next/font/local";
import { Crimson_Text, Inter } from "next/font/google";
export const cal = localFont({
src: "./CalSans-SemiBold.otf",
variable: "--font-display",
});
export const crimsonBold = Crimson_Text({
weight: "700",
variable: "--font-display",
subsets: ["latin"],
});
export const inter = Inter({
variable: "--font-default",
subsets: ["latin"],
});
export const crimson = Crimson_Text({
weight: "400",
variable: "--font-default",
subsets: ["latin"],
});
export const displayFontMapper = {
"Sans Serif": cal.variable,
Serif: crimsonBold.variable,
};
export const defaultFontMapper = {
"Sans Serif": inter.variable,
Serif: crimson.variable,
};

View File

@ -0,0 +1,89 @@
.ProseMirror .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
pointer-events: none;
height: 0;
}
.ProseMirror .is-empty::before {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
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%);
}
}
/* 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: #fff;
margin: 0;
cursor: pointer;
width: 1.2em;
height: 1.2em;
position: relative;
top: 5px;
border: 2px solid black;
margin-right: 0.3rem;
display: grid;
place-content: center;
&:hover {
background-color: #f8f9fa;
}
&:active {
background-color: #e9ecef;
}
&::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: #a8a29e;
text-decoration: line-through;
text-decoration-thickness: 2px;
}

View File

@ -0,0 +1 @@
@tailwind utilities;

View File

@ -0,0 +1,116 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
import { FC, useState } from "react";
import {
BoldIcon,
ItalicIcon,
UnderlineIcon,
StrikethroughIcon,
CodeIcon,
} from "lucide-react";
import { NodeSelector } from "./node-selector";
import { ColorSelector } from "./color-selector";
import clsx from "clsx";
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof BoldIcon;
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const items: BubbleMenuItem[] = [
{
name: "bold",
isActive: () => props.editor.isActive("bold"),
command: () => props.editor.chain().focus().toggleBold().run(),
icon: BoldIcon,
},
{
name: "italic",
isActive: () => props.editor.isActive("italic"),
command: () => props.editor.chain().focus().toggleItalic().run(),
icon: ItalicIcon,
},
{
name: "underline",
isActive: () => props.editor.isActive("underline"),
command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: UnderlineIcon,
},
{
name: "strike",
isActive: () => props.editor.isActive("strike"),
command: () => props.editor.chain().focus().toggleStrike().run(),
icon: StrikethroughIcon,
},
{
name: "code",
isActive: () => props.editor.isActive("code"),
command: () => props.editor.chain().focus().toggleCode().run(),
icon: CodeIcon,
},
];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ editor }) => {
// don't show if image is selected
if (editor.isActive("image")) {
return false;
}
return editor.view.state.selection.content().size > 0;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onHidden: () => {
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
},
},
};
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return (
<BubbleMenu
{...bubbleMenuProps}
className="flex overflow-hidden rounded border border-stone-200 bg-white shadow-xl"
>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsColorSelectorOpen(false);
}}
/>
{items.map((item, index) => (
<button
key={index}
onClick={item.command}
className="p-2 text-stone-600 hover:bg-stone-100 active:bg-stone-200"
>
<item.icon
className={clsx("h-4 w-4", {
"text-blue-500": item.isActive(),
})}
/>
</button>
))}
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
setIsOpen={() => {
setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false);
}}
/>
</BubbleMenu>
);
};

View File

@ -0,0 +1,187 @@
import { Editor } from "@tiptap/core";
import { Check, ChevronDown } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
export interface BubbleColorMenuItem {
name: string;
color: string | null;
}
interface ColorSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
const TEXT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "#000000",
},
{
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",
},
];
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "#ffffff",
},
{
name: "Purple",
color: "#F6F3F8",
},
{
name: "Red",
color: "#FDEBEB",
},
{
name: "Yellow",
color: "#FEF9C3",
},
{
name: "Blue",
color: "#E6F3F7",
},
{
name: "Green",
color: "#EDF3EC",
},
{
name: "Orange",
color: "#FAEBDD",
},
{
name: "Pink",
color: "#FAF1F5",
},
{
name: "Gray",
color: "#F1F1EF",
},
];
export const ColorSelector: FC<ColorSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color }),
);
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color }),
);
return (
<div className="relative h-full">
<button
className="flex h-full items-center gap-1 p-2 text-sm font-medium text-stone-600 hover:bg-stone-100 active:bg-stone-200"
onClick={() => setIsOpen(!isOpen)}
>
<span
className="rounded-sm px-1"
style={{
color: activeColorItem?.color,
backgroundColor: activeHighlightItem?.color,
}}
>
A
</span>
<ChevronDown className="h-4 w-4 " />
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-stone-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
<div className="my-1 px-2 text-sm text-stone-500">Color</div>
{TEXT_COLORS.map(({ name, color }, index) => (
<button
key={index}
onClick={() => {
editor.commands.unsetColor();
name !== "Default" &&
editor.chain().focus().setColor(color).run();
setIsOpen(false);
}}
className="flex items-center justify-between rounded-sm px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
>
<div className="flex items-center space-x-2">
<div
className="rounded-sm border border-stone-200 px-1 py-px font-medium"
style={{ color }}
>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("textStyle", { color }) && (
<Check className="h-4 w-4" />
)}
</button>
))}
<div className="mb-1 mt-2 px-2 text-sm text-stone-500">
Background
</div>
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
<button
key={index}
onClick={() => {
editor.commands.unsetHighlight();
name !== "Default" && editor.commands.setHighlight({ color });
setIsOpen(false);
}}
className="flex items-center justify-between rounded-sm px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
>
<div className="flex items-center space-x-2">
<div
className="rounded-sm border border-stone-200 px-1 py-px font-medium"
style={{ backgroundColor: color }}
>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("highlight", { color }) && (
<Check className="h-4 w-4" />
)}
</button>
))}
</section>
)}
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./EditorBubbleMenu";

View File

@ -0,0 +1,135 @@
import { Editor } from "@tiptap/core";
import {
Check,
ChevronDown,
Heading1,
Heading2,
Heading3,
TextQuote,
ListOrdered,
TextIcon,
Code,
CheckSquare,
} from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import { BubbleMenuItem } from "./EditorBubbleMenu";
interface NodeSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const NodeSelector: FC<NodeSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const items: BubbleMenuItem[] = [
{
name: "Text",
icon: TextIcon,
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").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.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
},
{
name: "Heading 1",
icon: Heading1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }),
},
{
name: "Heading 2",
icon: Heading2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }),
},
{
name: "Heading 3",
icon: Heading3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: TextQuote,
command: () =>
editor
.chain()
.focus()
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
isActive: () => editor.isActive("blockquote"),
},
{
name: "Code",
icon: Code,
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"),
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return (
<div className="relative h-full">
<button
className="flex h-full items-center gap-1 p-2 text-sm font-medium text-stone-600 hover:bg-stone-100 active:bg-stone-200"
onClick={() => setIsOpen(!isOpen)}
>
<span>{activeItem?.name}</span>
<ChevronDown className="h-4 w-4" />
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-stone-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
{items.map((item, index) => (
<button
key={index}
onClick={() => {
item.command();
setIsOpen(false);
}}
className="flex items-center justify-between rounded-sm px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-stone-200 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" />}
</button>
))}
</section>
)}
</div>
);
};

View File

@ -0,0 +1,203 @@
const DEFAULT_EDITOR_CONTENT = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Introducing Novel" }],
},
{
type: "paragraph",
content: [
{
type: "text",
text: "Novel is a Notion-style WYSIWYG editor with AI-powered autocompletion. Built with ",
},
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://tiptap.dev/",
target: "_blank",
class:
"text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer",
},
},
],
text: "Tiptap",
},
{ type: "text", text: " and " },
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://sdk.vercel.ai/docs",
target: "_blank",
class:
"text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer",
},
},
],
text: "Vercel AI SDK",
},
{ type: "text", text: "." },
],
},
{
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",
},
},
{ 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: "Check out the " },
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://twitter.com/steventey/status/1669762868416512000",
target: "_blank",
class:
"text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer",
},
},
],
text: "launch video",
},
],
},
],
},
{
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",
class:
"text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer",
},
},
],
text: "GitHub",
},
],
},
],
},
{
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",
class:
"text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer",
},
},
],
text: "Deploy your own",
},
{ type: "text", text: " to Vercel" },
],
},
],
},
],
},
],
};
export default DEFAULT_EDITOR_CONTENT;

View File

@ -0,0 +1,128 @@
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import TiptapImage from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Highlight from "@tiptap/extension-highlight";
import SlashCommand from "./slash-command";
import { InputRule } from "@tiptap/core";
export const TiptapExtensions = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-stone-700",
},
},
codeBlock: {
HTMLAttributes: {
class:
"rounded-sm bg-stone-100 p-5 font-mono font-medium text-stone-800",
},
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-stone-200 px-1.5 py-1 font-mono font-medium text-black",
spellcheck: "false",
},
},
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 4,
},
gapcursor: false,
}),
// patch to fix horizontal rule bug: https://github.com/ueberdosis/tiptap/pull/3859#issuecomment-1536799740
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range }) => {
const attributes = {};
const { tr } = state;
const start = range.from;
let end = range.to;
tr.insert(start - 1, this.type.create(attributes)).delete(
tr.mapping.map(start),
tr.mapping.map(end),
);
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mt-4 mb-6 border-t border-stone-300",
},
}),
TiptapLink.configure({
HTMLAttributes: {
class:
"text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer",
},
}),
TiptapImage.configure({
allowBase64: true,
HTMLAttributes: {
class: "rounded-lg border border-stone-200",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
return "Press '/' for commands, or '++' for AI autocomplete...";
},
includeChildren: true,
}),
SlashCommand,
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
linkify: true,
transformCopiedText: true,
}),
];

View File

@ -0,0 +1,426 @@
import React, {
useState,
useEffect,
useCallback,
ReactNode,
useRef,
useLayoutEffect,
} from "react";
import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import { useCompletion } from "ai/react";
import tippy from "tippy.js";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
MessageSquarePlus,
Text,
TextQuote,
Image as ImageIcon,
Code,
CheckSquare,
} from "lucide-react";
import LoadingCircle from "@/ui/icons/loading-circle";
import { toast } from "sonner";
import va from "@vercel/analytics";
import Magic from "@/ui/icons/magic";
import { handleImageUpload } from "@/lib/editor";
interface CommandItemProps {
title: string;
description: string;
icon: ReactNode;
}
interface CommandProps {
editor: Editor;
range: Range;
}
const Command = Extension.create({
name: "slash-command",
addOptions() {
return {
suggestion: {
char: "/",
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: any;
}) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
const getSuggestionItems = ({ query }: { query: string }) => {
return [
{
title: "Continue writing",
description: "Use AI to expand your thoughts.",
searchTerms: ["gpt"],
icon: <Magic className="w-7 text-black" />,
},
{
title: "Send Feedback",
description: "Let us know how we can improve.",
icon: <MessageSquarePlus size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
window.open("/feedback", "_blank");
},
},
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
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 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
// upload image
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async (event) => {
if (input.files?.length) {
const file = input.files[0];
return handleImageUpload(file, editor.view, event);
}
};
input.click();
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms &&
item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
};
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const CommandList = ({
items,
command,
editor,
range,
}: {
items: CommandItemProps[];
command: any;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const { 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.");
va.track("Rate Limit Reached");
return;
}
editor.chain().focus().deleteRange(range).run();
},
onFinish: (_prompt, completion) => {
// highlight the generated text
editor.commands.setTextSelection({
from: range.from,
to: range.from + completion.length,
});
},
onError: () => {
toast.error("Something went wrong.");
},
});
const selectItem = useCallback(
(index: number) => {
const item = items[index];
va.track("Slash Command Used", {
command: item.title,
});
if (item) {
if (item.title === "Continue writing") {
// we're using this for now until we can figure out a way to stream markdown text with proper formatting: https://github.com/steven-tey/novel/discussions/7
complete(editor.getText());
// complete(editor.storage.markdown.getMarkdown());
} else {
command(item);
}
}
},
[complete, command, editor, items],
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
if (e.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex, selectItem]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
return items.length > 0 ? (
<div
id="slash-command"
ref={commandListContainer}
className="z-50 h-auto max-h-[330px] w-72 overflow-y-auto scroll-smooth rounded-md border border-stone-200 bg-white px-1 py-2 shadow-md transition-all"
>
{items.map((item: CommandItemProps, index: number) => {
return (
<button
className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-stone-900 hover:bg-stone-100 ${
index === selectedIndex ? "bg-stone-100 text-stone-900" : ""
}`}
key={index}
onClick={() => selectItem(index)}
>
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-stone-200 bg-white">
{item.title === "Continue writing" && isLoading ? (
<LoadingCircle />
) : (
item.icon
)}
</div>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-stone-500">{item.description}</p>
</div>
</button>
);
})}
</div>
) : null;
};
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(CommandList, {
props,
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
},
onExit: () => {
popup?.[0].destroy();
component?.destroy();
},
};
};
const SlashCommand = Command.configure({
suggestion: {
items: getSuggestionItems,
render: renderItems,
},
});
export default SlashCommand;

View File

@ -0,0 +1,149 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { TiptapEditorProps } from "./props";
import { TiptapExtensions } from "./extensions";
import useLocalStorage from "@/lib/hooks/use-local-storage";
import { useDebouncedCallback } from "use-debounce";
import { useCompletion } from "ai/react";
import { toast } from "sonner";
import va from "@vercel/analytics";
import DEFAULT_EDITOR_CONTENT from "./default-content";
import { EditorBubbleMenu } from "./components";
export default function Editor() {
const [content, setContent] = useLocalStorage(
"content",
DEFAULT_EDITOR_CONTENT,
);
const [saveStatus, setSaveStatus] = useState("Saved");
const [hydrated, setHydrated] = useState(false);
const debouncedUpdates = useDebouncedCallback(async ({ editor }) => {
const json = editor.getJSON();
setSaveStatus("Saving...");
setContent(json);
// Simulate a delay in saving.
setTimeout(() => {
setSaveStatus("Saved");
}, 500);
}, 750);
const editor = useEditor({
extensions: TiptapExtensions,
editorProps: TiptapEditorProps,
onUpdate: (e) => {
setSaveStatus("Unsaved");
const selection = e.editor.state.selection;
const lastTwo = e.editor.state.doc.textBetween(
selection.from - 2,
selection.from,
"\n",
);
if (lastTwo === "++" && !isLoading) {
e.editor.commands.deleteRange({
from: selection.from - 2,
to: selection.from,
});
// we're using this for now until we can figure out a way to stream markdown text with proper formatting: https://github.com/steven-tey/novel/discussions/7
complete(e.editor.getText());
// complete(e.editor.storage.markdown.getMarkdown());
va.track("Autocomplete Shortcut Used");
} else {
debouncedUpdates(e);
}
},
autofocus: "end",
});
const { complete, completion, isLoading, stop } = useCompletion({
id: "novel",
api: "/api/generate",
onResponse: (response) => {
if (response.status === 429) {
toast.error("You have reached your request limit for the day.");
va.track("Rate Limit Reached");
return;
}
},
onFinish: (_prompt, completion) => {
editor?.commands.setTextSelection({
from: editor.state.selection.from - completion.length,
to: editor.state.selection.from,
});
},
onError: () => {
toast.error("Something went wrong.");
},
});
const prev = useRef("");
// Insert chunks of the generated text
useEffect(() => {
const diff = completion.slice(prev.current.length);
prev.current = completion;
editor?.commands.insertContent(diff);
}, [isLoading, editor, completion]);
useEffect(() => {
// if user presses escape or cmd + z and it's loading,
// stop the request, delete the completion, and insert back the "++"
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" || (e.metaKey && e.key === "z")) {
stop();
if (e.key === "Escape") {
editor?.commands.deleteRange({
from: editor.state.selection.from - completion.length,
to: editor.state.selection.from,
});
}
editor?.commands.insertContent("++");
}
};
const mousedownHandler = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
stop();
if (window.confirm("AI writing paused. Continue?")) {
complete(editor?.getText() || "");
}
};
if (isLoading) {
document.addEventListener("keydown", onKeyDown);
window.addEventListener("mousedown", mousedownHandler);
} else {
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("mousedown", mousedownHandler);
}
return () => {
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("mousedown", mousedownHandler);
};
}, [stop, isLoading, editor, complete, completion.length]);
// Hydrate the editor with the content from localStorage.
useEffect(() => {
if (editor && content && !hydrated) {
editor.commands.setContent(content);
setHydrated(true);
}
}, [editor, content, hydrated]);
return (
<div
onClick={() => {
editor?.chain().focus().run();
}}
className="relative min-h-[500px] w-full max-w-screen-lg border-stone-200 p-12 px-8 sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:px-12 sm:shadow-lg"
>
<div className="absolute right-5 top-5 mb-5 rounded-lg bg-stone-100 px-2 py-1 text-sm text-stone-400">
{saveStatus}
</div>
{editor && <EditorBubbleMenu editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}

View File

@ -0,0 +1,44 @@
import { handleImageUpload } from "@/lib/editor";
import { EditorProps } from "@tiptap/pm/view";
export const TiptapEditorProps: EditorProps = {
attributes: {
class: `prose-lg prose-headings:font-display font-default focus:outline-none max-w-full`,
},
handleDOMEvents: {
keydown: (_view, event) => {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
handlePaste: (view, event) => {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
return handleImageUpload(file, view, event);
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
return handleImageUpload(file, view, event);
}
return false;
},
};

View File

@ -0,0 +1,14 @@
export default function Github({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
className={className}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}

View File

@ -0,0 +1,22 @@
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>
);
}

View File

@ -0,0 +1,30 @@
export default function Magic({ className }: { className: string }) {
return (
<svg
width="469"
height="469"
viewBox="0 0 469 469"
fill="none"
xmlns="http://www.w3.org/2000/svg"
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
className={className}
>
<path
d="M237.092 62.3004L266.754 71.4198C267.156 71.5285 267.51 71.765 267.765 72.0934C268.02 72.4218 268.161 72.8243 268.166 73.2399C268.172 73.6555 268.042 74.0616 267.796 74.3967C267.55 74.7318 267.201 74.9777 266.803 75.097L237.141 84.3145C236.84 84.4058 236.566 84.5699 236.344 84.7922C236.121 85.0146 235.957 85.2883 235.866 85.5893L226.747 115.252C226.638 115.653 226.401 116.008 226.073 116.263C225.745 116.517 225.342 116.658 224.926 116.664C224.511 116.669 224.105 116.539 223.77 116.293C223.435 116.047 223.189 115.699 223.069 115.301L213.852 85.6383C213.761 85.3374 213.597 85.0636 213.374 84.8412C213.152 84.6189 212.878 84.4548 212.577 84.3635L182.914 75.2441C182.513 75.1354 182.158 74.8989 181.904 74.5705C181.649 74.2421 181.508 73.8396 181.503 73.424C181.497 73.0084 181.627 72.6023 181.873 72.2672C182.119 71.9321 182.467 71.6863 182.865 71.5669L212.528 62.3494C212.829 62.2582 213.103 62.0941 213.325 61.8717C213.547 61.6494 213.712 61.3756 213.803 61.0747L222.922 31.4121C223.031 31.0109 223.267 30.656 223.596 30.4013C223.924 30.1465 224.327 30.0057 224.742 30.0002C225.158 29.9946 225.564 30.1247 225.899 30.3706C226.234 30.6165 226.48 30.9649 226.599 31.363L235.817 61.0257C235.908 61.3266 236.072 61.6003 236.295 61.8227C236.517 62.0451 236.791 62.2091 237.092 62.3004Z"
fill="currentColor"
/>
<path
d="M155.948 155.848L202.771 168.939C203.449 169.131 204.045 169.539 204.47 170.101C204.895 170.663 205.125 171.348 205.125 172.052C205.125 172.757 204.895 173.442 204.47 174.004C204.045 174.566 203.449 174.974 202.771 175.166L155.899 188.06C155.361 188.209 154.87 188.496 154.475 188.891C154.079 189.286 153.793 189.777 153.644 190.316L140.553 237.138C140.361 237.816 139.953 238.413 139.391 238.838C138.829 239.262 138.144 239.492 137.44 239.492C136.735 239.492 136.05 239.262 135.488 238.838C134.927 238.413 134.519 237.816 134.327 237.138L121.432 190.267C121.283 189.728 120.997 189.237 120.601 188.842C120.206 188.446 119.715 188.16 119.177 188.011L72.3537 174.92C71.676 174.728 71.0795 174.32 70.6547 173.759C70.2299 173.197 70 172.512 70 171.807C70 171.103 70.2299 170.418 70.6547 169.856C71.0795 169.294 71.676 168.886 72.3537 168.694L119.226 155.799C119.764 155.65 120.255 155.364 120.65 154.969C121.046 154.573 121.332 154.082 121.481 153.544L134.572 106.721C134.764 106.043 135.172 105.447 135.734 105.022C136.295 104.597 136.981 104.367 137.685 104.367C138.389 104.367 139.075 104.597 139.637 105.022C140.198 105.447 140.606 106.043 140.798 106.721L153.693 153.593C153.842 154.131 154.128 154.622 154.524 155.018C154.919 155.413 155.41 155.699 155.948 155.848Z"
fill="currentColor"
/>
<path
d="M386.827 289.992C404.33 292.149 403.84 305.828 386.876 307.299C346.623 310.829 298.869 316.271 282.199 360.005C274.844 379.192 269.942 403.2 267.49 432.029C267.427 432.846 267.211 433.626 266.856 434.319C266.501 435.012 266.015 435.602 265.431 436.05C254.988 444.041 251.212 434.186 250.183 425.606C239.2 332.353 214.588 316.909 124.668 306.122C123.892 306.031 123.151 305.767 122.504 305.35C121.857 304.933 121.322 304.375 120.942 303.72C116.399 295.679 119.324 291.038 129.718 289.796C224.688 278.47 236.062 262.83 250.183 169.331C252.177 156.355 257.259 154.083 265.431 162.516C266.51 163.593 267.202 165.099 267.392 166.782C279.257 258.564 293.328 278.617 386.827 289.992Z"
fill="currentColor"
/>
</svg>
);
}

View File

@ -0,0 +1,79 @@
"use client";
import {
useEffect,
useRef,
ReactNode,
Dispatch,
SetStateAction,
useMemo,
} from "react";
import { AnimatePresence, motion, useAnimation } from "framer-motion";
export default function Leaflet({
setOpen,
children,
}: {
setOpen: Dispatch<SetStateAction<boolean>>;
children: ReactNode;
}) {
const leafletRef = useRef<HTMLDivElement>(null);
const controls = useAnimation();
const transitionProps = useMemo(() => {
return { type: "spring", stiffness: 500, damping: 30 };
}, []);
useEffect(() => {
controls.start({
y: 20,
transition: transitionProps,
});
}, [controls, transitionProps]);
async function handleDragEnd(_: any, info: any) {
const offset = info.offset.y;
const velocity = info.velocity.y;
const height = leafletRef.current?.getBoundingClientRect().height || 0;
if (offset > height / 2 || velocity > 800) {
await controls.start({ y: "100%", transition: transitionProps });
setOpen(false);
} else {
controls.start({ y: 0, transition: transitionProps });
}
}
return (
<AnimatePresence>
<motion.div
ref={leafletRef}
key="leaflet"
className="group fixed inset-x-0 bottom-0 z-40 w-screen cursor-grab bg-white pb-5 active:cursor-grabbing sm:hidden"
initial={{ y: "100%" }}
animate={controls}
exit={{ y: "100%" }}
transition={transitionProps}
drag="y"
dragDirectionLock
onDragEnd={handleDragEnd}
dragElastic={{ top: 0, bottom: 1 }}
dragConstraints={{ top: 0, bottom: 0 }}
>
<div
className={`rounded-t-4xl -mb-1 flex h-7 w-full items-center justify-center border-t border-gray-200`}
>
<div className="-mr-1 h-1 w-6 rounded-full bg-gray-300 transition-all group-active:rotate-12" />
<div className="h-1 w-6 rounded-full bg-gray-300 transition-all group-active:-rotate-12" />
</div>
{children}
</motion.div>
<motion.div
key="leaflet-backdrop"
className="fixed inset-0 z-30 bg-gray-100 bg-opacity-10 backdrop-blur"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
/>
</AnimatePresence>
);
}

View File

@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import clsx from "clsx";
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={clsx(
"z-50 items-center rounded-md border border-stone-200 bg-white shadow-md animate-in zoom-in-95",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

File diff suppressed because it is too large Load Diff