diff --git a/.gitignore b/.gitignore index c4c909d1..b946812b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,46 +1,15 @@ -# dependencies -node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc +# base .DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files +.env .env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts - -dbschema/edgeql-js -dbschema/interfaces.ts - -# other +# ts +node_modules package-lock.json pnpm-lock.yaml -.env -cli/run.ts +.vercel +# next .next-types -.env .next +# other +private +past-* diff --git a/bun.lockb b/bun.lockb index 16415812..dce45b0b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli/run.ts b/cli/run.ts new file mode 100644 index 00000000..9d597a10 --- /dev/null +++ b/cli/run.ts @@ -0,0 +1,12 @@ +import { getEnvOrThrow } from "@/lib/utils" + +async function run() { + try { + const OPENAI_API_KEY = getEnvOrThrow("OPENAI_API_KEY") + console.log(OPENAI_API_KEY) + } catch (err) { + console.log(err, "err") + } +} + +await run() diff --git a/cli/seed.ts b/cli/seed.ts new file mode 100644 index 00000000..c88d838d --- /dev/null +++ b/cli/seed.ts @@ -0,0 +1,59 @@ +import { getEnvOrThrow } from "@/lib/utils" +import { LaAccount } from "@/web/lib/schema" +import { startWorker } from "jazz-nodejs" +import { Group, ID } from "jazz-tools" +import { appendFile } from "node:fs/promises" + +const JAZZ_WORKER_SECRET = getEnvOrThrow("JAZZ_WORKER_SECRET") + +async function seed() { + const args = Bun.argv + const command = args[2] + try { + switch (command) { + case undefined: + console.log("No command provided") + break + case "setup": + await setup() + break + case "prod": + await prodSeed() + break + default: + console.log("Unknown command") + break + } + console.log("done") + } catch (err) { + console.error("Error occurred:", err) + } +} + +// sets up jazz global group and writes it to .env +async function setup() { + const { worker } = await startWorker({ + accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9", + accountSecret: JAZZ_WORKER_SECRET + }) + const user = (await await LaAccount.createAs(worker, { + creationProps: { name: "nikiv" } + }))! + const publicGlobalGroup = Group.create({ owner: worker }) + publicGlobalGroup.addMember("everyone", "reader") + await appendFile("./.env", `\nJAZZ_PUBLIC_GLOBAL_GROUP=${JSON.stringify(publicGlobalGroup.id)}`) + const adminGlobalGroup = Group.create({ owner: worker }) + adminGlobalGroup.addMember(user, "admin") + await appendFile("./.env", `\nJAZZ_ADMIN_GLOBAL_GROUP=${JSON.stringify(adminGlobalGroup.id)}`) +} + +async function prodSeed() { + const { worker } = await startWorker({ + accountID: "co_zhvp7ryXJzDvQagX61F6RCZFJB9", + accountSecret: JAZZ_WORKER_SECRET + }) + const globalGroup = await Group.load(process.env.JAZZ_PUBLIC_GLOBAL_GROUP as ID, worker, {}) + if (!globalGroup) return // TODO: err + // TODO: complete full seed (connections, topics from old LA) +} +await seed() diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 00000000..2a660567 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,7 @@ +export function getEnvOrThrow(env: string) { + const value = process.env[env] + if (!value) { + throw new Error(`${env} environment variable is not set`) + } + return value +} diff --git a/license b/license new file mode 100644 index 00000000..e4f391fb --- /dev/null +++ b/license @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Learn Anything (learn-anything.xyz) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index e379ccc6..e1b4a655 100644 --- a/package.json +++ b/package.json @@ -3,20 +3,29 @@ "scripts": { "dev": "bun web", "web": "cd web && bun dev", - "web:build": "bun run --filter '*' build" - }, - "devDependencies": { - "bun-types": "^1.1.18" + "web:build": "bun run --filter '*' build", + "cli": "bun run --watch cli/run.ts", + "seed": "bun --watch cli/seed.ts" }, "workspaces": [ "web" ], + "dependencies": { + "jazz-nodejs": "^0.7.23", + "react-icons": "^5.2.1" + }, + "devDependencies": { + "bun-types": "^1.1.21" + }, "prettier": { + "plugins": [ + "prettier-plugin-tailwindcss" + ], "useTabs": true, "semi": false, "trailingComma": "none", "printWidth": 120, "arrowParens": "avoid" }, - "private": true + "license": "MIT" } diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 00000000..d0c69440 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_APP_NAME="Learn Anything" +NEXT_PUBLIC_APP_URL=http://localhost:3000 \ No newline at end of file diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx new file mode 100644 index 00000000..1c296d0d --- /dev/null +++ b/web/app/(pages)/layout.tsx @@ -0,0 +1,15 @@ +import { Sidebar } from "@/components/custom/sidebar/sidebar" + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + +
+
+ {children} +
+
+
+ ) +} diff --git a/web/app/(pages)/links/page.tsx b/web/app/(pages)/links/page.tsx new file mode 100644 index 00000000..a8361cae --- /dev/null +++ b/web/app/(pages)/links/page.tsx @@ -0,0 +1,5 @@ +import { LinkWrapper } from "@/components/routes/link/wrapper" + +export default function LinkPage() { + return +} diff --git a/web/app/(pages)/pages/[id]/page.tsx b/web/app/(pages)/pages/[id]/page.tsx new file mode 100644 index 00000000..a235ccb3 --- /dev/null +++ b/web/app/(pages)/pages/[id]/page.tsx @@ -0,0 +1,5 @@ +import { DetailPageWrapper } from "@/components/routes/page/detail/wrapper" + +export default function DetailPage({ params }: { params: { id: string } }) { + return +} diff --git a/web/app/(pages)/profile/_components/wrapper.tsx b/web/app/(pages)/profile/_components/wrapper.tsx new file mode 100644 index 00000000..8db87158 --- /dev/null +++ b/web/app/(pages)/profile/_components/wrapper.tsx @@ -0,0 +1,14 @@ +"use client" + +import { useAccount } from "@/lib/providers/jazz-provider" + +export const ProfileWrapper = () => { + const account = useAccount() + + return ( +
+

{account.me.profile?.name}

+

Profile Page

+
+ ) +} diff --git a/web/app/(pages)/profile/page.tsx b/web/app/(pages)/profile/page.tsx new file mode 100644 index 00000000..8b4cb3e8 --- /dev/null +++ b/web/app/(pages)/profile/page.tsx @@ -0,0 +1,5 @@ +import { ProfileWrapper } from "./_components/wrapper" + +export default function ProfilePage() { + return +} diff --git a/web/app/(pages)/search/page.tsx b/web/app/(pages)/search/page.tsx new file mode 100644 index 00000000..c6797e3a --- /dev/null +++ b/web/app/(pages)/search/page.tsx @@ -0,0 +1,5 @@ +import { SearchWrapper } from "@/components/routes/search/wrapper" + +export default function ProfilePage() { + return +} diff --git a/web/app/(topics)/[topic]/layout.tsx b/web/app/(topics)/[topic]/layout.tsx new file mode 100644 index 00000000..f1c3e520 --- /dev/null +++ b/web/app/(topics)/[topic]/layout.tsx @@ -0,0 +1,14 @@ +import { Sidebar } from "@/components/custom/sidebar/sidebar" + +export default function TopicsLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+
+ {children} +
+
+
+ ) +} diff --git a/web/app/(topics)/[topic]/page.tsx b/web/app/(topics)/[topic]/page.tsx new file mode 100644 index 00000000..3ead540f --- /dev/null +++ b/web/app/(topics)/[topic]/page.tsx @@ -0,0 +1,5 @@ +import GlobalTopic from "@/components/routes/globalTopic/globalTopic" + +export default function GlobalTopicPage({ params }: { params: { topic: string } }) { + return +} diff --git a/web/app/api/metadata/route.test.ts b/web/app/api/metadata/route.test.ts new file mode 100644 index 00000000..83c96baa --- /dev/null +++ b/web/app/api/metadata/route.test.ts @@ -0,0 +1,101 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server" +import axios from "axios" +import { GET } from "./route" + +jest.mock("axios") +const mockedAxios = axios as jest.Mocked + +describe("Metadata Fetcher", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should return metadata when URL is valid", async () => { + const mockHtml = ` + + + Test Title + + + + + ` + + mockedAxios.get.mockResolvedValue({ data: mockHtml }) + + const req = { + url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com" + } as unknown as NextRequest + + const response = await GET(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ + title: "Test Title", + description: "Test Description", + favicon: "https://example.com/favicon.ico", + url: "https://example.com" + }) + }) + + it("should return an error when URL is missing", async () => { + const req = { + url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata" + } as unknown as NextRequest + + const response = await GET(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ error: "URL is required" }) + }) + + it("should return default values when fetching fails", async () => { + mockedAxios.get.mockRejectedValue(new Error("Network error")) + + const req = { + url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com" + } as unknown as NextRequest + + const response = await GET(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ + title: "No title available", + description: "No description available", + favicon: null, + url: "https://example.com" + }) + }) + + it("should handle missing metadata gracefully", async () => { + const mockHtml = ` + + + + + ` + + mockedAxios.get.mockResolvedValue({ data: mockHtml }) + + const req = { + url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com" + } as unknown as NextRequest + + const response = await GET(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ + title: "No title available", + description: "No description available", + favicon: null, + url: "https://example.com" + }) + }) +}) diff --git a/web/app/api/metadata/route.ts b/web/app/api/metadata/route.ts new file mode 100644 index 00000000..b0b458d5 --- /dev/null +++ b/web/app/api/metadata/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server" +import axios from "axios" +import * as cheerio from "cheerio" + +interface Metadata { + title: string + description: string + favicon: string | null + url: string +} + +const DEFAULT_VALUES = { + TITLE: "No title available", + DESCRIPTION: "No description available", + IMAGE: null, + FAVICON: null +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const url = searchParams.get("url") + + if (!url) { + return NextResponse.json({ error: "URL is required" }, { status: 400 }) + } + + try { + const { data } = await axios.get(url, { + timeout: 5000, + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + }) + + const $ = cheerio.load(data) + + const metadata: Metadata = { + title: $("title").text() || $('meta[property="og:title"]').attr("content") || DEFAULT_VALUES.TITLE, + description: + $('meta[name="description"]').attr("content") || + $('meta[property="og:description"]').attr("content") || + DEFAULT_VALUES.DESCRIPTION, + favicon: + $('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON, + url: url + } + + if (metadata.favicon && !metadata.favicon.startsWith("http")) { + metadata.favicon = new URL(metadata.favicon, url).toString() + } + + return NextResponse.json(metadata) + } catch (error) { + const defaultMetadata: Metadata = { + title: DEFAULT_VALUES.TITLE, + description: DEFAULT_VALUES.DESCRIPTION, + favicon: DEFAULT_VALUES.FAVICON, + url: url + } + return NextResponse.json(defaultMetadata) + } +} diff --git a/web/app/api/search-stream/route.ts b/web/app/api/search-stream/route.ts new file mode 100644 index 00000000..21ca9a50 --- /dev/null +++ b/web/app/api/search-stream/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server" + +export async function POST(request: NextRequest) { + let data: unknown + try { + data = (await request.json()) as unknown + } catch (error) { + return new NextResponse("Invalid JSON", { status: 400 }) + } + + if (typeof data !== "object" || !data) { + return new NextResponse("Missing request data", { status: 400 }) + } + + if (!("question" in data) || typeof data.question !== "string") { + return new NextResponse("Missing `question` data field.", { status: 400 }) + } + + const chunks: string[] = [ + "# Hello", + " from th", + "e server", + "\n\n your question", + " was:\n\n", + "> ", + data.question, + "\n\n", + "**good bye!**" + ] + + const stream = new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk) + await new Promise(resolve => setTimeout(resolve, 1000)) + } + + controller.close() + } + }) + + return new NextResponse(stream) +} diff --git a/web/app/globals.css b/web/app/globals.css index 56208c36..0214df23 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,28 +1,89 @@ @tailwind base; @tailwind components; @tailwind utilities; - -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - } -} +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb)); + font-family: "Inter", sans-serif; } -@layer utilities { - .text-balance { - text-wrap: balance; +@layer base { + body { + @apply bg-background text-foreground; + font-feature-settings: + "rlig" 1, + "calt" 1; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; + min-height: 100vh; + line-height: 1.5; + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; } } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index d2910887..3adedfc5 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,8 +1,23 @@ import type { Metadata } from "next" +// import { Inter as FontSans } from "next/font/google" import { Inter } from "next/font/google" +import { cn } from "@/lib/utils" +import { ThemeProvider } from "@/lib/providers/theme-provider" import "./globals.css" +import { JazzProvider } from "@/lib/providers/jazz-provider" +import { JotaiProvider } from "@/lib/providers/jotai-provider" +import { Toaster } from "@/components/ui/sonner" +import { ConfirmProvider } from "@/lib/providers/confirm-provider" -const inter = Inter({ subsets: ["latin"] }) +// const fontSans = FontSans({ +// subsets: ["latin"], +// variable: "--font-sans" +// }) + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-sans" +}) export const metadata: Metadata = { title: "Learn Anything", @@ -15,8 +30,19 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - {children} + + + + + + + {children} + + + + + + ) } diff --git a/web/app/page.tsx b/web/app/page.tsx index d9621817..70761f42 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,5 +1,12 @@ -"use client" +import { Button } from "@/components/ui/button" +import Link from "next/link" -export default function Home() { - return
+export default function HomePage() { + return ( +
+ + + +
+ ) } diff --git a/web/components.json b/web/components.json new file mode 100644 index 00000000..c0a1e61e --- /dev/null +++ b/web/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/web/components/custom/ai-search.tsx b/web/components/custom/ai-search.tsx new file mode 100644 index 00000000..d0904d51 --- /dev/null +++ b/web/components/custom/ai-search.tsx @@ -0,0 +1,88 @@ +"use client" +import React, { useEffect, useState } from "react" +import * as smd from "streaming-markdown" + +interface AiSearchProps { + searchQuery: string +} + +const AiSearch: React.FC = (props: { searchQuery: string }) => { + const [error, setError] = useState("") + + let root_el = React.useRef(null) + + let [parser, md_el] = React.useMemo(() => { + let md_el = document.createElement("div") + let renderer = smd.default_renderer(md_el) + let parser = smd.parser(renderer) + return [parser, md_el] + }, []) + + useEffect(() => { + if (root_el.current) { + root_el.current.appendChild(md_el) + } + }, [root_el.current, md_el]) + + useEffect(() => { + let question = props.searchQuery + + fetchData() + async function fetchData() { + let response: Response + try { + response = await fetch("/api/search-stream", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ question: question }) + }) + } catch (error) { + console.error("Error fetching data:", error) + setError("Error fetching data") + return + } + + if (!response.body) { + console.error("Response has no body") + setError("Response has no body") + return + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + while (true) { + let res = await reader.read() + + if (res.value) { + let text = decoder.decode(res.value) + smd.parser_write(parser, text) + } + + if (res.done) { + smd.parser_end(parser) + break + } + } + } + }, [props.searchQuery, parser]) + + return ( +
+
+
+

✨ This is what I have found:

+
+
+
+

{error}

+ +
+ ) +} + +export default AiSearch diff --git a/web/components/custom/content-header.tsx b/web/components/custom/content-header.tsx new file mode 100644 index 00000000..64c8725c --- /dev/null +++ b/web/components/custom/content-header.tsx @@ -0,0 +1,61 @@ +"use client" + +import React from "react" +import { Separator } from "@/components/ui/separator" +import { Button } from "../ui/button" +import { PanelLeftIcon } from "lucide-react" +import { useAtom } from "jotai" +import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar" +import { useMedia } from "react-use" +import { cn } from "@/lib/utils" + +type ContentHeaderProps = Omit, "title"> + +export const ContentHeader = React.forwardRef( + ({ children, className, ...props }, ref) => { + return ( +
+ {children} +
+ ) + } +) + +ContentHeader.displayName = "ContentHeader" + +export const SidebarToggleButton: React.FC = () => { + const [isCollapse] = useAtom(isCollapseAtom) + const [, toggle] = useAtom(toggleCollapseAtom) + const isTablet = useMedia("(max-width: 1024px)") + + if (!isCollapse && !isTablet) return null + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + toggle() + } + + return ( +
+ + +
+ ) +} diff --git a/web/components/custom/demo-auth.tsx b/web/components/custom/demo-auth.tsx new file mode 100644 index 00000000..1de84dd3 --- /dev/null +++ b/web/components/custom/demo-auth.tsx @@ -0,0 +1,166 @@ +import React, { useEffect, useMemo, useState } from "react" +import { BrowserDemoAuth, AuthProvider } from "jazz-browser" +import { Account, CoValueClass, ID } from "jazz-tools" +import { AgentSecret } from "cojson" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +// Types +export type AuthState = "loading" | "ready" | "signedIn" + +export type ReactAuthHook = (setJazzAuthState: (state: AuthState) => void) => { + auth: AuthProvider + AuthUI: React.ReactNode + logOut?: () => void +} + +type DemoAuthProps = { + accountSchema?: CoValueClass & typeof Account + appName: string + appHostname?: string + Component?: DemoAuth.Component + seedAccounts?: { + [name: string]: { accountID: ID; accountSecret: AgentSecret } + } +} + +type AuthComponentProps = { + appName: string + loading: boolean + existingUsers: string[] + logInAs: (existingUser: string) => void + signUp: (username: string) => void +} + +// Main DemoAuth function +export function DemoAuth({ + accountSchema = Account as CoValueClass & typeof Account, + appName, + appHostname, + Component = DemoAuth.BasicUI, + seedAccounts +}: DemoAuthProps): ReactAuthHook { + return function useLocalAuth(setJazzAuthState) { + const [authState, setAuthState] = useState("loading") + const [existingUsers, setExistingUsers] = useState([]) + const [logInAs, setLogInAs] = useState<(existingUser: string) => void>(() => () => {}) + const [signUp, setSignUp] = useState<(username: string) => void>(() => () => {}) + const [logOut, setLogOut] = useState<(() => void) | undefined>(undefined) + const [logOutCounter, setLogOutCounter] = useState(0) + + useEffect(() => { + setJazzAuthState(authState) + }, [authState, setJazzAuthState]) + + const auth = useMemo(() => { + return new BrowserDemoAuth( + accountSchema, + { + onReady(next) { + setAuthState("ready") + setExistingUsers(next.existingUsers) + setLogInAs(() => next.logInAs) + setSignUp(() => next.signUp) + }, + onSignedIn(next) { + setAuthState("signedIn") + setLogOut(() => () => { + next.logOut() + setAuthState("loading") + setLogOutCounter(c => c + 1) + }) + } + }, + appName, + seedAccounts + ) + }, []) + + const AuthUI = ( + + ) + + return { auth, AuthUI, logOut } + } +} + +const DemoAuthBasicUI: React.FC = ({ appName, existingUsers, logInAs, signUp }) => { + const [username, setUsername] = useState("") + const darkMode = useDarkMode() + + return ( +
+
+

{appName}

+ + +
+
+ ) +} + +// Helper components +const SignUpForm: React.FC<{ + username: string + setUsername: (value: string) => void + signUp: (username: string) => void + darkMode: boolean +}> = ({ username, setUsername, signUp, darkMode }) => ( +
{ + e.preventDefault() + signUp(username) + }} + className="flex flex-col gap-y-4" + > + setUsername(e.target.value)} + autoComplete="webauthn" + /> + +
+) + +const ExistingUsersList: React.FC<{ + existingUsers: string[] + logInAs: (user: string) => void + darkMode: boolean +}> = ({ existingUsers, logInAs, darkMode }) => ( +
+ {existingUsers.map(user => ( + + ))} +
+) + +// Hooks +const useDarkMode = () => { + const [darkMode, setDarkMode] = useState(false) + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + setDarkMode(mediaQuery.matches) + + const handler = (e: MediaQueryListEvent) => setDarkMode(e.matches) + mediaQuery.addEventListener("change", handler) + return () => mediaQuery.removeEventListener("change", handler) + }, []) + + return darkMode +} + +// DemoAuth namespace +export namespace DemoAuth { + export type Component = React.FC + export const BasicUI = DemoAuthBasicUI +} diff --git a/web/components/custom/logo.tsx b/web/components/custom/logo.tsx new file mode 100644 index 00000000..11de1826 --- /dev/null +++ b/web/components/custom/logo.tsx @@ -0,0 +1,57 @@ +import * as React from "react" + +interface Logo extends React.SVGProps {} + +export const Logo = ({ className, ...props }: Logo) => { + return ( + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/web/components/custom/sidebar/partial/page-section.tsx b/web/components/custom/sidebar/partial/page-section.tsx new file mode 100644 index 00000000..479abfe3 --- /dev/null +++ b/web/components/custom/sidebar/partial/page-section.tsx @@ -0,0 +1,114 @@ +import { SidebarItem } from "../sidebar" +import { z } from "zod" +import { useAccount } from "@/lib/providers/jazz-provider" +import { Input } from "@/components/ui/input" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form" +import { PlusIcon } from "lucide-react" +import { generateUniqueSlug } from "@/lib/utils" +import { PersonalPage } from "@/lib/schema/personal-page" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +const createPageSchema = z.object({ + title: z.string({ message: "Please enter a valid title" }).min(1, { message: "Please enter a valid title" }) +}) + +type PageFormValues = z.infer + +export const PageSection: React.FC = () => { + const { me } = useAccount() + const personalPages = me.root?.personalPages || [] + + return ( +
+
+
+ Pages + +
+
+ +
+
+ {personalPages.map( + page => page && + )} +
+
+
+ ) +} + +const CreatePageForm: React.FC = () => { + const { me } = useAccount() + + const form = useForm({ + resolver: zodResolver(createPageSchema), + defaultValues: { + title: "" + } + }) + + const onSubmit = (values: PageFormValues) => { + try { + const personalPages = me?.root?.personalPages?.toJSON() || [] + const slug = generateUniqueSlug(personalPages, values.title) + + const newPersonalPage = PersonalPage.create( + { + title: values.title, + slug: slug, + content: "" + }, + { owner: me._owner } + ) + + me.root?.personalPages?.push(newPersonalPage) + + toast.success("Page created successfully") + } catch (error) { + console.error(error) + toast.error("Failed to create page") + } + } + + return ( + + + + + +
+ + ( + + New page + + + + + + )} + /> + + + + +
+
+ ) +} diff --git a/web/components/custom/sidebar/partial/topic-section.tsx b/web/components/custom/sidebar/partial/topic-section.tsx new file mode 100644 index 00000000..b713a50b --- /dev/null +++ b/web/components/custom/sidebar/partial/topic-section.tsx @@ -0,0 +1,100 @@ +import { useState, useEffect, useRef } from "react" +import { usePathname } from "next/navigation" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { ChevronDown, BookOpen, Bookmark, GraduationCap, Check } from "lucide-react" +import { SidebarItem } from "../sidebar" + +const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"] + +export const TopicSection = () => { + const [showOptions, setShowOptions] = useState(false) + const [selectedStatus, setSelectedStatus] = useState(null) + const sectionRef = useRef(null) + + const learningOptions = [ + { text: "To Learn", icon: , color: "text-white/70" }, + { + text: "Learning", + icon: , + color: "text-[#D29752]" + }, + { + text: "Learned", + icon: , + color: "text-[#708F51]" + } + ] + + const statusSelect = (status: string) => { + setSelectedStatus(status === "Show All" ? null : status) + setShowOptions(false) + } + + useEffect(() => { + const overlayClick = (event: MouseEvent) => { + if (sectionRef.current && !sectionRef.current.contains(event.target as Node)) { + setShowOptions(false) + } + } + + document.addEventListener("mousedown", overlayClick) + return () => { + document.removeEventListener("mousedown", overlayClick) + } + }, []) + + const availableOptions = selectedStatus + ? [ + { + text: "Show All", + icon: , + color: "text-white" + }, + ...learningOptions.filter(option => option.text !== selectedStatus) + ] + : learningOptions + + // const topicClick = (topic: string) => { + // router.push(`/${topic.toLowerCase()}`) + // } + + return ( +
+ + + {showOptions && ( +
+ {availableOptions.map(option => ( + + ))} +
+ )} +
+ {TOPICS.map(topic => ( + + ))} +
+
+ ) +} + +export default TopicSection diff --git a/web/components/custom/sidebar/sidebar.tsx b/web/components/custom/sidebar/sidebar.tsx new file mode 100644 index 00000000..1dd7ec13 --- /dev/null +++ b/web/components/custom/sidebar/sidebar.tsx @@ -0,0 +1,179 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { useMedia } from "react-use" +import { useAtom } from "jotai" +import { LinkIcon, SearchIcon } from "lucide-react" +import { Logo } from "@/components/custom/logo" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { isCollapseAtom } from "@/store/sidebar" + +import { PageSection } from "./partial/page-section" +import { TopicSection } from "./partial/topic-section" + +interface SidebarContextType { + isCollapsed: boolean + setIsCollapsed: React.Dispatch> +} + +const SidebarContext = React.createContext({ + isCollapsed: false, + setIsCollapsed: () => {} +}) + +const useSidebarCollapse = (isTablet: boolean): [boolean, React.Dispatch>] => { + const [isCollapsed, setIsCollapsed] = useAtom(isCollapseAtom) + const pathname = usePathname() + + React.useEffect(() => { + if (isTablet) setIsCollapsed(true) + }, [pathname, setIsCollapsed, isTablet]) + + React.useEffect(() => { + setIsCollapsed(isTablet) + }, [isTablet, setIsCollapsed]) + + return [isCollapsed, setIsCollapsed] +} + +interface SidebarItemProps { + label: string + url: string + icon?: React.ReactNode + onClick?: () => void + children?: React.ReactNode +} + +export const SidebarItem: React.FC = React.memo(({ label, url, icon, onClick, children }) => { + const pathname = usePathname() + const isActive = pathname === url + + return ( +
+ + {icon && ( + + {icon} + + )} + {label} + {children} + +
+ ) +}) + +const LogoAndSearch: React.FC = React.memo(() => { + const pathname = usePathname() + return ( +
+
+ + + +
+ {pathname === "/search" ? ( + + + + ) : ( + + + + )} +
+
+ ) +}) + +const SidebarContent: React.FC = React.memo(() => { + const { isCollapsed } = React.useContext(SidebarContext) + const isTablet = useMedia("(max-width: 1024px)") + + return ( + + ) +}) + +export const Sidebar: React.FC = () => { + const isTablet = useMedia("(max-width: 1024px)") + const [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet) + + const sidebarClasses = cn( + "h-full overflow-hidden transition-all duration-300 ease-in-out", + isCollapsed ? "w-0" : "w-auto min-w-56" + ) + + const sidebarInnerClasses = cn( + "h-full w-auto min-w-56 transition-transform duration-300 ease-in-out", + isCollapsed ? "-translate-x-full" : "translate-x-0" + ) + + const contextValue = React.useMemo(() => ({ isCollapsed, setIsCollapsed }), [isCollapsed, setIsCollapsed]) + + if (isTablet) { + return ( + <> +
setIsCollapsed(true)} + /> +
+
+ + + +
+
+ + ) + } + + return ( +
+
+ + + +
+
+ ) +} + +export default Sidebar diff --git a/web/components/la-editor/components/bubble-menu/bubble-menu.tsx b/web/components/la-editor/components/bubble-menu/bubble-menu.tsx new file mode 100644 index 00000000..e56e30c9 --- /dev/null +++ b/web/components/la-editor/components/bubble-menu/bubble-menu.tsx @@ -0,0 +1,60 @@ +import { useTextmenuCommands } from "../../hooks/use-text-menu-commands" +import { PopoverWrapper } from "../ui/popover-wrapper" +import { useTextmenuStates } from "../../hooks/use-text-menu-states" +import { BubbleMenu as TiptapBubbleMenu, Editor } from "@tiptap/react" +import { ToolbarButton } from "../ui/toolbar-button" +import { Icon } from "../ui/icon" +import * as React from "react" + +export type BubbleMenuProps = { + editor: Editor +} + +export const BubbleMenu = ({ editor }: BubbleMenuProps) => { + const commands = useTextmenuCommands(editor) + const states = useTextmenuStates(editor) + + return ( + + +
+ + + + + + + + + + {/* + + */} + + + + + + + + + + {/* + + */} +
+
+
+ ) +} + +export default BubbleMenu diff --git a/web/components/la-editor/components/bubble-menu/index.ts b/web/components/la-editor/components/bubble-menu/index.ts new file mode 100644 index 00000000..32c445a6 --- /dev/null +++ b/web/components/la-editor/components/bubble-menu/index.ts @@ -0,0 +1 @@ +export * from "./bubble-menu" diff --git a/web/components/la-editor/components/ui/icon.tsx b/web/components/la-editor/components/ui/icon.tsx new file mode 100644 index 00000000..5e0f94d5 --- /dev/null +++ b/web/components/la-editor/components/ui/icon.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { icons } from "lucide-react" + +export type IconProps = { + name: keyof typeof icons + className?: string + strokeWidth?: number + [key: string]: any +} + +export const Icon = React.memo(({ name, className, size, strokeWidth, ...props }: IconProps) => { + const IconComponent = icons[name] + + if (!IconComponent) { + return null + } + + return +}) + +Icon.displayName = "Icon" diff --git a/web/components/la-editor/components/ui/popover-wrapper.tsx b/web/components/la-editor/components/ui/popover-wrapper.tsx new file mode 100644 index 00000000..565e9d07 --- /dev/null +++ b/web/components/la-editor/components/ui/popover-wrapper.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +export type PopoverWrapperProps = React.HTMLProps + +export const PopoverWrapper = React.forwardRef( + ({ children, className, ...props }, ref) => { + return ( +
+ {children} +
+ ) + } +) + +PopoverWrapper.displayName = "PopoverWrapper" diff --git a/web/components/la-editor/components/ui/shortcut.tsx b/web/components/la-editor/components/ui/shortcut.tsx new file mode 100644 index 00000000..978a2b4a --- /dev/null +++ b/web/components/la-editor/components/ui/shortcut.tsx @@ -0,0 +1,45 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { getShortcutKey } from "../../lib/utils" + +export interface ShortcutKeyWrapperProps extends React.HTMLAttributes { + ariaLabel: string +} + +const ShortcutKeyWrapper = React.forwardRef( + ({ className, ariaLabel, children, ...props }, ref) => { + return ( + + {children} + + ) + } +) + +ShortcutKeyWrapper.displayName = "ShortcutKeyWrapper" + +export interface ShortcutKeyProps extends React.HTMLAttributes { + shortcut: string +} + +const ShortcutKey = React.forwardRef(({ className, shortcut, ...props }, ref) => { + return ( + + {getShortcutKey(shortcut)} + + ) +}) + +ShortcutKey.displayName = "ShortcutKey" + +export const Shortcut = { + Wrapper: ShortcutKeyWrapper, + Key: ShortcutKey +} diff --git a/web/components/la-editor/components/ui/toolbar-button.tsx b/web/components/la-editor/components/ui/toolbar-button.tsx new file mode 100644 index 00000000..8556a35c --- /dev/null +++ b/web/components/la-editor/components/ui/toolbar-button.tsx @@ -0,0 +1,49 @@ +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Toggle } from "@/components/ui/toggle" + +import * as React from "react" +import { cn } from "@/lib/utils" +import type { TooltipContentProps } from "@radix-ui/react-tooltip" + +interface ToolbarButtonProps extends React.ComponentPropsWithoutRef { + isActive?: boolean + tooltip?: string + tooltipOptions?: TooltipContentProps +} + +const ToolbarButton = React.forwardRef(function ToolbarButton( + { isActive, children, tooltip, className, tooltipOptions, ...props }, + ref +) { + return ( + + + + + {children} + + + {tooltip && ( + +
{tooltip}
+
+ )} +
+
+ ) +}) + +ToolbarButton.displayName = "ToolbarButton" + +export { ToolbarButton } diff --git a/web/components/la-editor/extensions/blockquote/blockquote.ts b/web/components/la-editor/extensions/blockquote/blockquote.ts new file mode 100644 index 00000000..0db11b0c --- /dev/null +++ b/web/components/la-editor/extensions/blockquote/blockquote.ts @@ -0,0 +1,13 @@ +/* + * Add block-node class to blockquote element + */ +import { mergeAttributes } from "@tiptap/core" +import { Blockquote as TiptapBlockquote } from "@tiptap/extension-blockquote" + +export const Blockquote = TiptapBlockquote.extend({ + renderHTML({ HTMLAttributes }) { + return ["blockquote", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: "block-node" }), 0] + } +}) + +export default Blockquote diff --git a/web/components/la-editor/extensions/blockquote/index.ts b/web/components/la-editor/extensions/blockquote/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/web/components/la-editor/extensions/bullet-list/bullet-list.ts b/web/components/la-editor/extensions/bullet-list/bullet-list.ts new file mode 100644 index 00000000..1b2f3efc --- /dev/null +++ b/web/components/la-editor/extensions/bullet-list/bullet-list.ts @@ -0,0 +1,14 @@ +import { BulletList as TiptapBulletList } from "@tiptap/extension-bullet-list" + +export const BulletList = TiptapBulletList.extend({ + addOptions() { + return { + ...this.parent?.(), + HTMLAttributes: { + class: "list-node" + } + } + } +}) + +export default BulletList diff --git a/web/components/la-editor/extensions/bullet-list/index.ts b/web/components/la-editor/extensions/bullet-list/index.ts new file mode 100644 index 00000000..1c27d144 --- /dev/null +++ b/web/components/la-editor/extensions/bullet-list/index.ts @@ -0,0 +1 @@ +export * from "./bullet-list" diff --git a/web/components/la-editor/extensions/code-block-lowlight/code-block-lowlight.ts b/web/components/la-editor/extensions/code-block-lowlight/code-block-lowlight.ts new file mode 100644 index 00000000..63291eb6 --- /dev/null +++ b/web/components/la-editor/extensions/code-block-lowlight/code-block-lowlight.ts @@ -0,0 +1,17 @@ +import { CodeBlockLowlight as TiptapCodeBlockLowlight } from "@tiptap/extension-code-block-lowlight" +import { common, createLowlight } from "lowlight" + +export const CodeBlockLowlight = TiptapCodeBlockLowlight.extend({ + addOptions() { + return { + ...this.parent?.(), + lowlight: createLowlight(common), + defaultLanguage: null, + HTMLAttributes: { + class: "block-node" + } + } + } +}) + +export default CodeBlockLowlight diff --git a/web/components/la-editor/extensions/code-block-lowlight/index.ts b/web/components/la-editor/extensions/code-block-lowlight/index.ts new file mode 100644 index 00000000..4d6759ea --- /dev/null +++ b/web/components/la-editor/extensions/code-block-lowlight/index.ts @@ -0,0 +1 @@ +export * from "./code-block-lowlight" diff --git a/web/components/la-editor/extensions/code/code.ts b/web/components/la-editor/extensions/code/code.ts new file mode 100644 index 00000000..c29c847c --- /dev/null +++ b/web/components/la-editor/extensions/code/code.ts @@ -0,0 +1,15 @@ +import { Code as TiptapCode } from "@tiptap/extension-code" + +export const Code = TiptapCode.extend({ + addOptions() { + return { + ...this.parent?.(), + HTMLAttributes: { + class: "inline", + spellCheck: "false" + } + } + } +}) + +export default Code diff --git a/web/components/la-editor/extensions/code/index.ts b/web/components/la-editor/extensions/code/index.ts new file mode 100644 index 00000000..57a6d399 --- /dev/null +++ b/web/components/la-editor/extensions/code/index.ts @@ -0,0 +1 @@ +export * from "./code" diff --git a/web/components/la-editor/extensions/dropcursor/dropcursor.ts b/web/components/la-editor/extensions/dropcursor/dropcursor.ts new file mode 100644 index 00000000..383b0d0c --- /dev/null +++ b/web/components/la-editor/extensions/dropcursor/dropcursor.ts @@ -0,0 +1,13 @@ +import { Dropcursor as TiptapDropcursor } from "@tiptap/extension-dropcursor" + +export const Dropcursor = TiptapDropcursor.extend({ + addOptions() { + return { + ...this.parent?.(), + width: 2, + class: "ProseMirror-dropcursor border" + } + } +}) + +export default Dropcursor diff --git a/web/components/la-editor/extensions/dropcursor/index.ts b/web/components/la-editor/extensions/dropcursor/index.ts new file mode 100644 index 00000000..aaf0411d --- /dev/null +++ b/web/components/la-editor/extensions/dropcursor/index.ts @@ -0,0 +1 @@ +export * from "./dropcursor" diff --git a/web/components/la-editor/extensions/heading/heading.ts b/web/components/la-editor/extensions/heading/heading.ts new file mode 100644 index 00000000..452cb880 --- /dev/null +++ b/web/components/la-editor/extensions/heading/heading.ts @@ -0,0 +1,29 @@ +/* + * Add heading level validation. decimal (0-9) + * Add heading class to heading element + */ +import { mergeAttributes } from "@tiptap/core" +import TiptapHeading from "@tiptap/extension-heading" +import type { Level } from "@tiptap/extension-heading" + +export const Heading = TiptapHeading.extend({ + addOptions() { + return { + ...this.parent?.(), + levels: [1, 2, 3] as Level[], + HTMLAttributes: { + class: "heading-node" + } + } + }, + + renderHTML({ node, HTMLAttributes }) { + const nodeLevel = parseInt(node.attrs.level, 10) as Level + const hasLevel = this.options.levels.includes(nodeLevel) + const level = hasLevel ? nodeLevel : this.options.levels[0] + + return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + } +}) + +export default Heading diff --git a/web/components/la-editor/extensions/heading/index.ts b/web/components/la-editor/extensions/heading/index.ts new file mode 100644 index 00000000..6528f482 --- /dev/null +++ b/web/components/la-editor/extensions/heading/index.ts @@ -0,0 +1 @@ +export * from "./heading" diff --git a/web/components/la-editor/extensions/horizontal-rule/horizontal-rule.ts b/web/components/la-editor/extensions/horizontal-rule/horizontal-rule.ts new file mode 100644 index 00000000..349515df --- /dev/null +++ b/web/components/la-editor/extensions/horizontal-rule/horizontal-rule.ts @@ -0,0 +1,18 @@ +/* + * Wrap the horizontal rule in a div element. + * Also add a keyboard shortcut to insert a horizontal rule. + */ +import { HorizontalRule as TiptapHorizontalRule } from "@tiptap/extension-horizontal-rule" + +export const HorizontalRule = TiptapHorizontalRule.extend({ + addKeyboardShortcuts() { + return { + "Mod-Alt--": () => + this.editor.commands.insertContent({ + type: this.name + }) + } + } +}) + +export default HorizontalRule diff --git a/web/components/la-editor/extensions/horizontal-rule/index.ts b/web/components/la-editor/extensions/horizontal-rule/index.ts new file mode 100644 index 00000000..7cc78542 --- /dev/null +++ b/web/components/la-editor/extensions/horizontal-rule/index.ts @@ -0,0 +1 @@ +export * from "./horizontal-rule" diff --git a/web/components/la-editor/extensions/index.ts b/web/components/la-editor/extensions/index.ts new file mode 100644 index 00000000..da03a983 --- /dev/null +++ b/web/components/la-editor/extensions/index.ts @@ -0,0 +1,43 @@ +import { StarterKit } from "./starter-kit" +import { TaskList } from "./task-list" +import { TaskItem } from "./task-item" +import { HorizontalRule } from "./horizontal-rule" +import { Blockquote } from "./blockquote/blockquote" +import { SlashCommand } from "./slash-command" +import { Heading } from "./heading" +import { Link } from "./link" +import { CodeBlockLowlight } from "./code-block-lowlight" +import { Selection } from "./selection" +import { Code } from "./code" +import { Paragraph } from "./paragraph" +import { BulletList } from "./bullet-list" +import { OrderedList } from "./ordered-list" +import { Dropcursor } from "./dropcursor" + +export interface ExtensionOptions { + placeholder?: string +} + +export const createExtensions = ({ placeholder = "Start typing..." }: ExtensionOptions) => [ + Heading, + Code, + Link, + TaskList, + TaskItem, + Selection, + Paragraph, + Dropcursor, + Blockquote, + BulletList, + OrderedList, + SlashCommand, + HorizontalRule, + CodeBlockLowlight, + StarterKit.configure({ + placeholder: { + placeholder: () => placeholder + } + }) +] + +export default createExtensions diff --git a/web/components/la-editor/extensions/link/index.ts b/web/components/la-editor/extensions/link/index.ts new file mode 100644 index 00000000..6ada303c --- /dev/null +++ b/web/components/la-editor/extensions/link/index.ts @@ -0,0 +1 @@ +export * from "./link" diff --git a/web/components/la-editor/extensions/link/link.ts b/web/components/la-editor/extensions/link/link.ts new file mode 100644 index 00000000..26eef782 --- /dev/null +++ b/web/components/la-editor/extensions/link/link.ts @@ -0,0 +1,90 @@ +import { mergeAttributes } from "@tiptap/core" +import TiptapLink from "@tiptap/extension-link" +import { EditorView } from "@tiptap/pm/view" +import { getMarkRange } from "@tiptap/core" +import { Plugin, TextSelection } from "@tiptap/pm/state" + +export const Link = TiptapLink.extend({ + /* + * Determines whether typing next to a link automatically becomes part of the link. + * In this case, we dont want any characters to be included as part of the link. + */ + inclusive: false, + + /* + * Match all elements that have an href attribute, except for: + * - elements with a data-type attribute set to button + * - elements with an href attribute that contains 'javascript:' + */ + parseHTML() { + return [{ tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])' }] + }, + + renderHTML({ HTMLAttributes }) { + return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + addOptions() { + return { + ...this.parent?.(), + openOnClick: false, + HTMLAttributes: { + class: "link" + } + } + }, + + addProseMirrorPlugins() { + const { editor } = this + + return [ + ...(this.parent?.() || []), + new Plugin({ + props: { + handleKeyDown: (view: EditorView, event: KeyboardEvent) => { + const { selection } = editor.state + + /* + * Handles the 'Escape' key press when there's a selection within the link. + * This will move the cursor to the end of the link. + */ + if (event.key === "Escape" && selection.empty !== true) { + console.log("Link handleKeyDown") + editor.commands.focus(selection.to, { scrollIntoView: false }) + } + + return false + }, + handleClick(view, pos) { + /* + * Marks the entire link when the user clicks on it. + */ + + const { schema, doc, tr } = view.state + const range = getMarkRange(doc.resolve(pos), schema.marks.link) + + if (!range) { + return + } + + const { from, to } = range + const start = Math.min(from, to) + const end = Math.max(from, to) + + if (pos < start || pos > end) { + return + } + + const $start = doc.resolve(start) + const $end = doc.resolve(end) + const transaction = tr.setSelection(new TextSelection($start, $end)) + + view.dispatch(transaction) + } + } + }) + ] + } +}) + +export default Link diff --git a/web/components/la-editor/extensions/ordered-list/index.ts b/web/components/la-editor/extensions/ordered-list/index.ts new file mode 100644 index 00000000..1bf163b5 --- /dev/null +++ b/web/components/la-editor/extensions/ordered-list/index.ts @@ -0,0 +1 @@ +export * from "./ordered-list" diff --git a/web/components/la-editor/extensions/ordered-list/ordered-list.ts b/web/components/la-editor/extensions/ordered-list/ordered-list.ts new file mode 100644 index 00000000..3bc55a58 --- /dev/null +++ b/web/components/la-editor/extensions/ordered-list/ordered-list.ts @@ -0,0 +1,14 @@ +import { OrderedList as TiptapOrderedList } from "@tiptap/extension-ordered-list" + +export const OrderedList = TiptapOrderedList.extend({ + addOptions() { + return { + ...this.parent?.(), + HTMLAttributes: { + class: "list-node" + } + } + } +}) + +export default OrderedList diff --git a/web/components/la-editor/extensions/paragraph/index.ts b/web/components/la-editor/extensions/paragraph/index.ts new file mode 100644 index 00000000..04a77c68 --- /dev/null +++ b/web/components/la-editor/extensions/paragraph/index.ts @@ -0,0 +1 @@ +export * from "./paragraph" diff --git a/web/components/la-editor/extensions/paragraph/paragraph.ts b/web/components/la-editor/extensions/paragraph/paragraph.ts new file mode 100644 index 00000000..f5aa9947 --- /dev/null +++ b/web/components/la-editor/extensions/paragraph/paragraph.ts @@ -0,0 +1,14 @@ +import { Paragraph as TiptapParagraph } from "@tiptap/extension-paragraph" + +export const Paragraph = TiptapParagraph.extend({ + addOptions() { + return { + ...this.parent?.(), + HTMLAttributes: { + class: "text-node" + } + } + } +}) + +export default Paragraph diff --git a/web/components/la-editor/extensions/selection/index.ts b/web/components/la-editor/extensions/selection/index.ts new file mode 100644 index 00000000..c8f61024 --- /dev/null +++ b/web/components/la-editor/extensions/selection/index.ts @@ -0,0 +1 @@ +export * from "./selection" diff --git a/web/components/la-editor/extensions/selection/selection.ts b/web/components/la-editor/extensions/selection/selection.ts new file mode 100644 index 00000000..8d62b26c --- /dev/null +++ b/web/components/la-editor/extensions/selection/selection.ts @@ -0,0 +1,36 @@ +import { Extension } from "@tiptap/core" +import { Plugin, PluginKey } from "@tiptap/pm/state" +import { Decoration, DecorationSet } from "@tiptap/pm/view" + +export const Selection = Extension.create({ + name: "selection", + + addProseMirrorPlugins() { + const { editor } = this + + return [ + new Plugin({ + key: new PluginKey("selection"), + props: { + decorations(state) { + if (state.selection.empty) { + return null + } + + if (editor.isFocused === true) { + return null + } + + return DecorationSet.create(state.doc, [ + Decoration.inline(state.selection.from, state.selection.to, { + class: "selection" + }) + ]) + } + } + }) + ] + } +}) + +export default Selection diff --git a/web/components/la-editor/extensions/slash-command/groups.ts b/web/components/la-editor/extensions/slash-command/groups.ts new file mode 100644 index 00000000..d509c59f --- /dev/null +++ b/web/components/la-editor/extensions/slash-command/groups.ts @@ -0,0 +1,122 @@ +import { Group } from "./types" + +export const GROUPS: Group[] = [ + { + name: "format", + title: "Format", + commands: [ + { + name: "heading1", + label: "Heading 1", + iconName: "Heading1", + description: "High priority section title", + aliases: ["h1"], + shortcuts: ["mod", "alt", "1"], + action: editor => { + editor.chain().focus().setHeading({ level: 1 }).run() + } + }, + { + name: "heading2", + label: "Heading 2", + iconName: "Heading2", + description: "Medium priority section title", + aliases: ["h2"], + shortcuts: ["mod", "alt", "2"], + action: editor => { + editor.chain().focus().setHeading({ level: 2 }).run() + } + }, + { + name: "heading3", + label: "Heading 3", + iconName: "Heading3", + description: "Low priority section title", + aliases: ["h3"], + shortcuts: ["mod", "alt", "3"], + action: editor => { + editor.chain().focus().setHeading({ level: 3 }).run() + } + } + ] + }, + { + name: "list", + title: "List", + commands: [ + { + name: "bulletList", + label: "Bullet List", + iconName: "List", + description: "Unordered list of items", + aliases: ["ul"], + shortcuts: ["mod", "shift", "8"], + action: editor => { + editor.chain().focus().toggleBulletList().run() + } + }, + { + name: "numberedList", + label: "Numbered List", + iconName: "ListOrdered", + description: "Ordered list of items", + aliases: ["ol"], + shortcuts: ["mod", "shift", "7"], + action: editor => { + editor.chain().focus().toggleOrderedList().run() + } + }, + { + name: "taskList", + label: "Task List", + iconName: "ListTodo", + description: "Task list with todo items", + aliases: ["todo"], + shortcuts: ["mod", "shift", "8"], + action: editor => { + editor.chain().focus().toggleTaskList().run() + } + } + ] + }, + { + name: "insert", + title: "Insert", + commands: [ + { + name: "codeBlock", + label: "Code Block", + iconName: "SquareCode", + description: "Code block with syntax highlighting", + shortcuts: ["mod", "alt", "c"], + shouldBeHidden: editor => editor.isActive("columns"), + action: editor => { + editor.chain().focus().setCodeBlock().run() + } + }, + { + name: "horizontalRule", + label: "Divider", + iconName: "Divide", + description: "Insert a horizontal divider", + aliases: ["hr"], + shortcuts: ["mod", "shift", "-"], + action: editor => { + editor.chain().focus().setHorizontalRule().run() + } + }, + { + name: "blockquote", + label: "Blockquote", + iconName: "Quote", + description: "Element for quoting", + shortcuts: ["mod", "shift", "b"], + action: editor => { + editor.chain().focus().setBlockquote().run() + } + } + ] + } +] + +export default GROUPS diff --git a/web/components/la-editor/extensions/slash-command/index.ts b/web/components/la-editor/extensions/slash-command/index.ts new file mode 100644 index 00000000..a7ccb685 --- /dev/null +++ b/web/components/la-editor/extensions/slash-command/index.ts @@ -0,0 +1 @@ +export * from "./slash-command" diff --git a/web/components/la-editor/extensions/slash-command/menu-list.tsx b/web/components/la-editor/extensions/slash-command/menu-list.tsx new file mode 100644 index 00000000..10377d18 --- /dev/null +++ b/web/components/la-editor/extensions/slash-command/menu-list.tsx @@ -0,0 +1,155 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" + +import { Command, MenuListProps } from "./types" +import { getShortcutKeys } from "../../lib/utils" +import { Icon } from "../../components/ui/icon" +import { PopoverWrapper } from "../../components/ui/popover-wrapper" +import { Shortcut } from "../../components/ui/shortcut" + +export const MenuList = React.forwardRef((props: MenuListProps, ref) => { + const scrollContainer = React.useRef(null) + const activeItem = React.useRef(null) + const [selectedGroupIndex, setSelectedGroupIndex] = React.useState(0) + const [selectedCommandIndex, setSelectedCommandIndex] = React.useState(0) + + // Anytime the groups change, i.e. the user types to narrow it down, we want to + // reset the current selection to the first menu item + React.useEffect(() => { + setSelectedGroupIndex(0) + setSelectedCommandIndex(0) + }, [props.items]) + + const selectItem = React.useCallback( + (groupIndex: number, commandIndex: number) => { + const command = props.items[groupIndex].commands[commandIndex] + props.command(command) + }, + [props] + ) + + React.useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: React.KeyboardEvent }) => { + if (event.key === "ArrowDown") { + if (!props.items.length) { + return false + } + + const commands = props.items[selectedGroupIndex].commands + + let newCommandIndex = selectedCommandIndex + 1 + let newGroupIndex = selectedGroupIndex + + if (commands.length - 1 < newCommandIndex) { + newCommandIndex = 0 + newGroupIndex = selectedGroupIndex + 1 + } + + if (props.items.length - 1 < newGroupIndex) { + newGroupIndex = 0 + } + + setSelectedCommandIndex(newCommandIndex) + setSelectedGroupIndex(newGroupIndex) + + return true + } + + if (event.key === "ArrowUp") { + if (!props.items.length) { + return false + } + + let newCommandIndex = selectedCommandIndex - 1 + let newGroupIndex = selectedGroupIndex + + if (newCommandIndex < 0) { + newGroupIndex = selectedGroupIndex - 1 + newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0 + } + + if (newGroupIndex < 0) { + newGroupIndex = props.items.length - 1 + newCommandIndex = props.items[newGroupIndex].commands.length - 1 + } + + setSelectedCommandIndex(newCommandIndex) + setSelectedGroupIndex(newGroupIndex) + + return true + } + + if (event.key === "Enter") { + if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) { + return false + } + + selectItem(selectedGroupIndex, selectedCommandIndex) + + return true + } + + return false + } + })) + + React.useEffect(() => { + if (activeItem.current && scrollContainer.current) { + const offsetTop = activeItem.current.offsetTop + const offsetHeight = activeItem.current.offsetHeight + + scrollContainer.current.scrollTop = offsetTop - offsetHeight + } + }, [selectedCommandIndex, selectedGroupIndex]) + + const createCommandClickHandler = React.useCallback( + (groupIndex: number, commandIndex: number) => { + return () => { + selectItem(groupIndex, commandIndex) + } + }, + [selectItem] + ) + + if (!props.items.length) { + return null + } + + return ( + + {props.items.map((group, groupIndex: number) => ( + + {group.commands.map((command: Command, commandIndex: number) => ( + + ))} + {groupIndex !== props.items.length - 1 && } + + ))} + + ) +}) + +MenuList.displayName = "MenuList" + +export default MenuList diff --git a/web/components/la-editor/extensions/slash-command/slash-command.ts b/web/components/la-editor/extensions/slash-command/slash-command.ts new file mode 100644 index 00000000..f5524ac6 --- /dev/null +++ b/web/components/la-editor/extensions/slash-command/slash-command.ts @@ -0,0 +1,234 @@ +import { Editor, Extension } from "@tiptap/core" +import { ReactRenderer } from "@tiptap/react" +import Suggestion, { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion" +import { PluginKey } from "@tiptap/pm/state" +import tippy from "tippy.js" + +import { GROUPS } from "./groups" +import { MenuList } from "./menu-list" + +const EXTENSION_NAME = "slashCommand" + +let popup: any + +export const SlashCommand = Extension.create({ + name: EXTENSION_NAME, + priority: 200, + + onCreate() { + popup = tippy("body", { + interactive: true, + trigger: "manual", + placement: "bottom-start", + theme: "slash-command", + maxWidth: "16rem", + offset: [16, 8], + popperOptions: { + strategy: "fixed", + modifiers: [{ name: "flip", enabled: false }] + } + }) + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: "/", + allowSpaces: true, + startOfLine: true, + pluginKey: new PluginKey(EXTENSION_NAME), + + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from) + const isRootDepth = $from.depth === 1 + const isParagraph = $from.parent.type.name === "paragraph" + const isStartOfNode = $from.parent.textContent?.charAt(0) === "/" + const isInColumn = this.editor.isActive("column") + + const afterContent = $from.parent.textContent?.substring($from.parent.textContent?.indexOf("/")) + const isValidAfterContent = !afterContent?.endsWith(" ") + + return ( + ((isRootDepth && isParagraph && isStartOfNode) || (isInColumn && isParagraph && isStartOfNode)) && + isValidAfterContent + ) + }, + + command: ({ editor, props }: { editor: Editor; props: any }) => { + const { view, state } = editor + const { $head, $from } = view.state.selection + + const end = $from.pos + const from = $head?.nodeBefore + ? end - ($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf("/")).length ?? 0) + : $from.start() + + const tr = state.tr.deleteRange(from, end) + view.dispatch(tr) + + props.action(editor) + view.focus() + }, + + items: ({ query }: { query: string }) => { + return GROUPS.map(group => ({ + ...group, + commands: group.commands + .filter(item => { + const labelNormalized = item.label.toLowerCase().trim() + const queryNormalized = query.toLowerCase().trim() + + if (item.aliases) { + const aliases = item.aliases.map(alias => alias.toLowerCase().trim()) + return labelNormalized.includes(queryNormalized) || aliases.includes(queryNormalized) + } + + return labelNormalized.includes(queryNormalized) + }) + .filter(command => (command.shouldBeHidden ? !command.shouldBeHidden(this.editor) : true)) + .map(command => ({ + ...command, + isEnabled: true + })) + })).filter(group => group.commands.length > 0) + }, + + render: () => { + let component: any + let scrollHandler: (() => void) | null = null + + return { + onStart: (props: SuggestionProps) => { + component = new ReactRenderer(MenuList, { + props, + editor: props.editor + }) + + const { view } = props.editor + const editorNode = view.dom as HTMLElement + + const getReferenceClientRect = () => { + if (!props.clientRect) { + return props.editor.storage[EXTENSION_NAME].rect + } + + const rect = props.clientRect() + + if (!rect) { + return props.editor.storage[EXTENSION_NAME].rect + } + + let yPos = rect.y + + if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) { + const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40 + yPos = rect.y - diff + } + + const editorXOffset = editorNode.getBoundingClientRect().x + return new DOMRect(rect.x, yPos, rect.width, rect.height) + } + + scrollHandler = () => { + popup?.[0].setProps({ + getReferenceClientRect + }) + } + + view.dom.parentElement?.addEventListener("scroll", scrollHandler) + + popup?.[0].setProps({ + getReferenceClientRect, + appendTo: () => document.body, + content: component.element + }) + + popup?.[0].show() + }, + + onUpdate(props: SuggestionProps) { + component.updateProps(props) + + const { view } = props.editor + const editorNode = view.dom as HTMLElement + + const getReferenceClientRect = () => { + if (!props.clientRect) { + return props.editor.storage[EXTENSION_NAME].rect + } + + const rect = props.clientRect() + + if (!rect) { + return props.editor.storage[EXTENSION_NAME].rect + } + + return new DOMRect(rect.x, rect.y, rect.width, rect.height) + } + + let scrollHandler = () => { + popup?.[0].setProps({ + getReferenceClientRect + }) + } + + view.dom.parentElement?.addEventListener("scroll", scrollHandler) + + props.editor.storage[EXTENSION_NAME].rect = props.clientRect + ? getReferenceClientRect() + : { + width: 0, + height: 0, + left: 0, + top: 0, + right: 0, + bottom: 0 + } + popup?.[0].setProps({ + getReferenceClientRect + }) + }, + + onKeyDown(props: SuggestionKeyDownProps) { + if (props.event.key === "Escape") { + popup?.[0].hide() + return true + } + + if (!popup?.[0].state.isShown) { + popup?.[0].show() + } + + return component.ref?.onKeyDown(props) + }, + + onExit(props) { + popup?.[0].hide() + if (scrollHandler) { + const { view } = props.editor + view.dom.parentElement?.removeEventListener("scroll", scrollHandler) + } + component.destroy() + } + } + } + }) + ] + }, + + addStorage() { + return { + rect: { + width: 0, + height: 0, + left: 0, + top: 0, + right: 0, + bottom: 0 + } + } + } +}) + +export default SlashCommand diff --git a/web/components/la-editor/extensions/slash-command/types.ts b/web/components/la-editor/extensions/slash-command/types.ts new file mode 100644 index 00000000..8745ac0c --- /dev/null +++ b/web/components/la-editor/extensions/slash-command/types.ts @@ -0,0 +1,26 @@ +import { Editor } from "@tiptap/core" + +import { icons } from "lucide-react" + +export interface Group { + name: string + title: string + commands: Command[] +} + +export interface Command { + name: string + label: string + description: string + aliases?: string[] + shortcuts: string[] + iconName: keyof typeof icons + action: (editor: Editor) => void + shouldBeHidden?: (editor: Editor) => boolean +} + +export interface MenuListProps { + editor: Editor + items: Group[] + command: (command: Command) => void +} diff --git a/web/components/la-editor/extensions/starter-kit.ts b/web/components/la-editor/extensions/starter-kit.ts new file mode 100644 index 00000000..9d55e508 --- /dev/null +++ b/web/components/la-editor/extensions/starter-kit.ts @@ -0,0 +1,153 @@ +import { Extension } from "@tiptap/core" +import { Bold, BoldOptions } from "@tiptap/extension-bold" +import { Document } from "@tiptap/extension-document" +import { Gapcursor } from "@tiptap/extension-gapcursor" +import { HardBreak, HardBreakOptions } from "@tiptap/extension-hard-break" +import { Italic, ItalicOptions } from "@tiptap/extension-italic" +import { ListItem, ListItemOptions } from "@tiptap/extension-list-item" +import { Strike, StrikeOptions } from "@tiptap/extension-strike" +import { Text } from "@tiptap/extension-text" +import { FocusClasses, FocusOptions } from "@tiptap/extension-focus" +import { Typography, TypographyOptions } from "@tiptap/extension-typography" +import { Placeholder, PlaceholderOptions } from "@tiptap/extension-placeholder" +import { History, HistoryOptions } from "@tiptap/extension-history" + +export interface StarterKitOptions { + /** + * If set to false, the bold extension will not be registered + * @example bold: false + */ + bold: Partial | false + + /** + * If set to false, the document extension will not be registered + * @example document: false + */ + document: false + + /** + * If set to false, the gapcursor extension will not be registered + * @example gapcursor: false + */ + gapcursor: false + + /** + * If set to false, the hardBreak extension will not be registered + * @example hardBreak: false + */ + hardBreak: Partial | false + + /** + * If set to false, the history extension will not be registered + * @example history: false + */ + history: Partial | false + + /** + * If set to false, the italic extension will not be registered + * @example italic: false + */ + italic: Partial | false + + /** + * If set to false, the listItem extension will not be registered + * @example listItem: false + */ + listItem: Partial | false + + /** + * If set to false, the strike extension will not be registered + * @example strike: false + */ + strike: Partial | false + + /** + * If set to false, the text extension will not be registered + * @example text: false + */ + text: false + + /** + * If set to false, the typography extension will not be registered + * @example typography: false + */ + typography: Partial | false + + /** + * If set to false, the placeholder extension will not be registered + * @example placeholder: false + */ + + placeholder: Partial | false + + /** + * If set to false, the focus extension will not be registered + * @example focus: false + */ + focus: Partial | false +} + +/** + * The starter kit is a collection of essential editor extensions. + * + * It’s a good starting point for building your own editor. + */ +export const StarterKit = Extension.create({ + name: "starterKit", + + addExtensions() { + const extensions = [] + + if (this.options.bold !== false) { + extensions.push(Bold.configure(this.options?.bold)) + } + + if (this.options.document !== false) { + extensions.push(Document.configure(this.options?.document)) + } + + if (this.options.gapcursor !== false) { + extensions.push(Gapcursor.configure(this.options?.gapcursor)) + } + + if (this.options.hardBreak !== false) { + extensions.push(HardBreak.configure(this.options?.hardBreak)) + } + + if (this.options.history !== false) { + extensions.push(History.configure(this.options?.history)) + } + + if (this.options.italic !== false) { + extensions.push(Italic.configure(this.options?.italic)) + } + + if (this.options.listItem !== false) { + extensions.push(ListItem.configure(this.options?.listItem)) + } + + if (this.options.strike !== false) { + extensions.push(Strike.configure(this.options?.strike)) + } + + if (this.options.text !== false) { + extensions.push(Text.configure(this.options?.text)) + } + + if (this.options.typography !== false) { + extensions.push(Typography.configure(this.options?.typography)) + } + + if (this.options.placeholder !== false) { + extensions.push(Placeholder.configure(this.options?.placeholder)) + } + + if (this.options.focus !== false) { + extensions.push(FocusClasses.configure(this.options?.focus)) + } + + return extensions + } +}) + +export default StarterKit diff --git a/web/components/la-editor/extensions/task-item/components/task-item-view.tsx b/web/components/la-editor/extensions/task-item/components/task-item-view.tsx new file mode 100644 index 00000000..b5deba27 --- /dev/null +++ b/web/components/la-editor/extensions/task-item/components/task-item-view.tsx @@ -0,0 +1,50 @@ +import { NodeViewContent, Editor, NodeViewWrapper } from "@tiptap/react" +import { Icon } from "../../../components/ui/icon" +import { useCallback } from "react" +import { Node as ProseMirrorNode } from "@tiptap/pm/model" +import { Node } from "@tiptap/core" + +interface TaskItemProps { + editor: Editor + node: ProseMirrorNode + updateAttributes: (attrs: Record) => void + extension: Node +} + +export const TaskItemView: React.FC = ({ node, updateAttributes, editor, extension }) => { + const handleChange = useCallback( + (event: React.ChangeEvent) => { + const checked = event.target.checked + + if (!editor.isEditable && !extension.options.onReadOnlyChecked) { + return + } + + if (editor.isEditable) { + updateAttributes({ checked }) + } else if (extension.options.onReadOnlyChecked) { + if (!extension.options.onReadOnlyChecked(node, checked)) { + event.target.checked = !checked + } + } + }, + [editor.isEditable, extension.options, node, updateAttributes] + ) + + return ( + +
+ + + +
+
+ +
+
+ ) +} + +export default TaskItemView diff --git a/web/components/la-editor/extensions/task-item/index.ts b/web/components/la-editor/extensions/task-item/index.ts new file mode 100644 index 00000000..489fdbc2 --- /dev/null +++ b/web/components/la-editor/extensions/task-item/index.ts @@ -0,0 +1 @@ +export * from "./task-item" diff --git a/web/components/la-editor/extensions/task-item/task-item.ts b/web/components/la-editor/extensions/task-item/task-item.ts new file mode 100644 index 00000000..089751b6 --- /dev/null +++ b/web/components/la-editor/extensions/task-item/task-item.ts @@ -0,0 +1,64 @@ +import { ReactNodeViewRenderer } from "@tiptap/react" +import { mergeAttributes } from "@tiptap/core" +import { TaskItemView } from "./components/task-item-view" +import { TaskItem as TiptapTaskItem } from "@tiptap/extension-task-item" + +export const TaskItem = TiptapTaskItem.extend({ + name: "taskItem", + + draggable: true, + + addOptions() { + return { + ...this.parent?.(), + nested: true + } + }, + + addAttributes() { + return { + checked: { + default: false, + keepOnSplit: false, + parseHTML: element => { + const dataChecked = element.getAttribute("data-checked") + return dataChecked === "" || dataChecked === "true" + }, + renderHTML: attributes => ({ + "data-checked": attributes.checked + }) + } + } + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + "li", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-type": this.name + }), + [ + "div", + { class: "taskItem-checkbox-container" }, + [ + "label", + [ + "input", + { + type: "checkbox", + checked: node.attrs.checked ? "checked" : null, + class: "taskItem-checkbox" + } + ] + ] + ], + ["div", { class: "taskItem-content" }, 0] + ] + }, + + addNodeView() { + return ReactNodeViewRenderer(TaskItemView, { + as: "span" + }) + } +}) diff --git a/web/components/la-editor/extensions/task-list/index.ts b/web/components/la-editor/extensions/task-list/index.ts new file mode 100644 index 00000000..5fb119d2 --- /dev/null +++ b/web/components/la-editor/extensions/task-list/index.ts @@ -0,0 +1 @@ +export * from "./task-list" diff --git a/web/components/la-editor/extensions/task-list/task-list.ts b/web/components/la-editor/extensions/task-list/task-list.ts new file mode 100644 index 00000000..423d74e6 --- /dev/null +++ b/web/components/la-editor/extensions/task-list/task-list.ts @@ -0,0 +1,12 @@ +import { TaskList as TiptapTaskList } from "@tiptap/extension-task-list" + +export const TaskList = TiptapTaskList.extend({ + addOptions() { + return { + ...this.parent?.(), + HTMLAttributes: { + class: "list-node" + } + } + } +}) diff --git a/web/components/la-editor/hooks/use-text-menu-commands.ts b/web/components/la-editor/hooks/use-text-menu-commands.ts new file mode 100644 index 00000000..21bddce9 --- /dev/null +++ b/web/components/la-editor/hooks/use-text-menu-commands.ts @@ -0,0 +1,30 @@ +import { Editor } from "@tiptap/react" +import { useCallback } from "react" + +export const useTextmenuCommands = (editor: Editor) => { + const onBold = useCallback(() => editor.chain().focus().toggleBold().run(), [editor]) + const onItalic = useCallback(() => editor.chain().focus().toggleItalic().run(), [editor]) + const onStrike = useCallback(() => editor.chain().focus().toggleStrike().run(), [editor]) + const onCode = useCallback(() => editor.chain().focus().toggleCode().run(), [editor]) + const onCodeBlock = useCallback(() => editor.chain().focus().toggleCodeBlock().run(), [editor]) + const onQuote = useCallback(() => editor.chain().focus().toggleBlockquote().run(), [editor]) + const onLink = useCallback( + (url: string, inNewTab?: boolean) => + editor + .chain() + .focus() + .setLink({ href: url, target: inNewTab ? "_blank" : "" }) + .run(), + [editor] + ) + + return { + onBold, + onItalic, + onStrike, + onCode, + onCodeBlock, + onQuote, + onLink + } +} diff --git a/web/components/la-editor/hooks/use-text-menu-states.ts b/web/components/la-editor/hooks/use-text-menu-states.ts new file mode 100644 index 00000000..69454d12 --- /dev/null +++ b/web/components/la-editor/hooks/use-text-menu-states.ts @@ -0,0 +1,34 @@ +import { Editor } from "@tiptap/react" +import { useCallback } from "react" +import { ShouldShowProps } from "../types" +import { isCustomNodeSelected, isTextSelected } from "../lib/utils" + +export const useTextmenuStates = (editor: Editor) => { + const shouldShow = useCallback( + ({ view, from }: ShouldShowProps) => { + if (!view) { + return false + } + + const domAtPos = view.domAtPos(from || 0).node as HTMLElement + const nodeDOM = view.nodeDOM(from || 0) as HTMLElement + const node = nodeDOM || domAtPos + + if (isCustomNodeSelected(editor, node)) { + return false + } + + return isTextSelected({ editor }) + }, + [editor] + ) + + return { + isBold: editor.isActive("bold"), + isItalic: editor.isActive("italic"), + isStrike: editor.isActive("strike"), + isUnderline: editor.isActive("underline"), + isCode: editor.isActive("code"), + shouldShow + } +} diff --git a/web/components/la-editor/index.ts b/web/components/la-editor/index.ts new file mode 100644 index 00000000..3da90ddd --- /dev/null +++ b/web/components/la-editor/index.ts @@ -0,0 +1 @@ +export * from "./la-editor" diff --git a/web/components/la-editor/la-editor.tsx b/web/components/la-editor/la-editor.tsx new file mode 100644 index 00000000..f55f4162 --- /dev/null +++ b/web/components/la-editor/la-editor.tsx @@ -0,0 +1,146 @@ +"use client" + +import * as React from "react" +import { EditorContent, useEditor } from "@tiptap/react" +import { Editor, Content } from "@tiptap/core" +import { useThrottleFn } from "react-use" +import { BubbleMenu } from "./components/bubble-menu" +import { createExtensions } from "./extensions" +import "./styles/index.css" +import { cn } from "@/lib/utils" +import { getOutput } from "./lib/utils" + +export interface LAEditorProps extends Omit, "value"> { + initialContent?: any + output?: "html" | "json" | "text" + placeholder?: string + editorClassName?: string + onUpdate?: (content: Content) => void + onBlur?: (content: Content) => void + onNewBlock?: (content: Content) => void + value?: Content + throttleDelay?: number +} + +export interface LAEditorRef { + focus: () => void +} + +interface CustomEditor extends Editor { + previousBlockCount?: number +} + +export const LAEditor = React.forwardRef( + ( + { + initialContent, + value, + placeholder, + output = "html", + editorClassName, + className, + onUpdate, + onBlur, + onNewBlock, + throttleDelay = 1000, + ...props + }, + ref + ) => { + const [content, setContent] = React.useState(value) + const throttledContent = useThrottleFn(defaultContent => defaultContent, throttleDelay, [content]) + const [lastThrottledContent, setLastThrottledContent] = React.useState(throttledContent) + + const handleUpdate = React.useCallback( + (editor: Editor) => { + const newContent = getOutput(editor, output) + setContent(newContent) + + const customEditor = editor as CustomEditor + const json = customEditor.getJSON() + + if (json.content && Array.isArray(json.content)) { + const currentBlockCount = json.content.length + + if ( + typeof customEditor.previousBlockCount === "number" && + currentBlockCount > customEditor.previousBlockCount + ) { + requestAnimationFrame(() => { + onNewBlock?.(newContent) + }) + } + + customEditor.previousBlockCount = currentBlockCount + } + }, + [output, onNewBlock] + ) + + const editor = useEditor({ + autofocus: false, + extensions: createExtensions({ placeholder }), + editorProps: { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + class: editorClassName || "" + } + }, + onCreate: ({ editor }) => { + if (editor.isEmpty && value) { + editor.commands.setContent(value) + } + }, + onUpdate: ({ editor }) => handleUpdate(editor), + onBlur: ({ editor }) => { + requestAnimationFrame(() => { + onBlur?.(getOutput(editor, output)) + }) + } + }) + + React.useEffect(() => { + if (editor && initialContent) { + // https://github.com/ueberdosis/tiptap/issues/3764 + setTimeout(() => { + editor.commands.setContent(initialContent) + }) + } + }, [editor, initialContent]) + + React.useEffect(() => { + if (lastThrottledContent !== throttledContent) { + setLastThrottledContent(throttledContent) + + requestAnimationFrame(() => { + onUpdate?.(throttledContent!) + }) + } + }, [throttledContent, lastThrottledContent, onUpdate]) + + React.useImperativeHandle( + ref, + () => ({ + focus: () => editor?.commands.focus() + }), + [editor] + ) + + if (!editor) { + return null + } + + return ( +
+ + +
+ ) + } +) + +LAEditor.displayName = "LAEditor" + +export default LAEditor diff --git a/web/components/la-editor/lib/utils/index.ts b/web/components/la-editor/lib/utils/index.ts new file mode 100644 index 00000000..885f4fa4 --- /dev/null +++ b/web/components/la-editor/lib/utils/index.ts @@ -0,0 +1,14 @@ +import { Editor } from "@tiptap/core" +import { LAEditorProps } from "../../la-editor" + +export function getOutput(editor: Editor, output: LAEditorProps["output"]) { + if (output === "html") return editor.getHTML() + if (output === "json") return editor.getJSON() + if (output === "text") return editor.getText() + return "" +} + +export * from "./keyboard" +export * from "./platform" +export * from "./isCustomNodeSelected" +export * from "./isTextSelected" diff --git a/web/components/la-editor/lib/utils/isCustomNodeSelected.ts b/web/components/la-editor/lib/utils/isCustomNodeSelected.ts new file mode 100644 index 00000000..7209049f --- /dev/null +++ b/web/components/la-editor/lib/utils/isCustomNodeSelected.ts @@ -0,0 +1,28 @@ +import { Editor } from "@tiptap/react" +import { Link } from "@/components/la-editor/extensions/link" +import { HorizontalRule } from "@/components/la-editor/extensions/horizontal-rule" + +export const isTableGripSelected = (node: HTMLElement) => { + let container = node + + while (container && !["TD", "TH"].includes(container.tagName)) { + container = container.parentElement! + } + + const gripColumn = container && container.querySelector && container.querySelector("a.grip-column.selected") + const gripRow = container && container.querySelector && container.querySelector("a.grip-row.selected") + + if (gripColumn || gripRow) { + return true + } + + return false +} + +export const isCustomNodeSelected = (editor: Editor, node: HTMLElement) => { + const customNodes = [HorizontalRule.name, Link.name] + + return customNodes.some(type => editor.isActive(type)) || isTableGripSelected(node) +} + +export default isCustomNodeSelected diff --git a/web/components/la-editor/lib/utils/isTextSelected.ts b/web/components/la-editor/lib/utils/isTextSelected.ts new file mode 100644 index 00000000..d4b17955 --- /dev/null +++ b/web/components/la-editor/lib/utils/isTextSelected.ts @@ -0,0 +1,25 @@ +import { isTextSelection } from "@tiptap/core" +import { Editor } from "@tiptap/react" + +export const isTextSelected = ({ editor }: { editor: Editor }) => { + const { + state: { + doc, + selection, + selection: { empty, from, to } + } + } = editor + + // Sometime check for `empty` is not enough. + // Doubleclick an empty paragraph returns a node size of 2. + // So we check also for an empty text size. + const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection) + + if (empty || isEmptyTextBlock || !editor.isEditable) { + return false + } + + return true +} + +export default isTextSelected diff --git a/web/components/la-editor/lib/utils/keyboard.ts b/web/components/la-editor/lib/utils/keyboard.ts new file mode 100644 index 00000000..09739fc2 --- /dev/null +++ b/web/components/la-editor/lib/utils/keyboard.ts @@ -0,0 +1,25 @@ +import { isMacOS } from "./platform" + +export const getShortcutKey = (key: string) => { + const lowercaseKey = key.toLowerCase() + const macOS = isMacOS() + + switch (lowercaseKey) { + case "mod": + return macOS ? "⌘" : "Ctrl" + case "alt": + return macOS ? "⌥" : "Alt" + case "shift": + return macOS ? "⇧" : "Shift" + default: + return key + } +} + +export const getShortcutKeys = (keys: string | string[], separator: string = "") => { + const keyArray = Array.isArray(keys) ? keys : keys.split(/\s+/) + const shortcutKeys = keyArray.map(getShortcutKey) + return shortcutKeys.join(separator) +} + +export default { getShortcutKey, getShortcutKeys } diff --git a/web/components/la-editor/lib/utils/platform.ts b/web/components/la-editor/lib/utils/platform.ts new file mode 100644 index 00000000..2dffa98a --- /dev/null +++ b/web/components/la-editor/lib/utils/platform.ts @@ -0,0 +1,46 @@ +export interface NavigatorWithUserAgentData extends Navigator { + userAgentData?: { + brands: { brand: string; version: string }[] + mobile: boolean + platform: string + getHighEntropyValues: (hints: string[]) => Promise<{ + platform: string + platformVersion: string + uaFullVersion: string + }> + } +} + +let isMac: boolean | undefined + +const getPlatform = () => { + const nav = navigator as NavigatorWithUserAgentData + if (nav.userAgentData) { + if (nav.userAgentData.platform) { + return nav.userAgentData.platform + } + + nav.userAgentData + .getHighEntropyValues(["platform"]) + .then(highEntropyValues => { + if (highEntropyValues.platform) { + return highEntropyValues.platform + } + }) + .catch(() => { + return navigator.platform || "" + }) + } + + return navigator.platform || "" +} + +export const isMacOS = () => { + if (isMac === undefined) { + isMac = getPlatform().toLowerCase().includes("mac") + } + + return isMac +} + +export default isMacOS diff --git a/web/components/la-editor/styles/index.css b/web/components/la-editor/styles/index.css new file mode 100644 index 00000000..451af6bd --- /dev/null +++ b/web/components/la-editor/styles/index.css @@ -0,0 +1,140 @@ +:root { + --la-font-size-regular: 0.9375rem; + + --la-code-background: rgba(8, 43, 120, 0.047); + --la-code-color: rgb(212, 212, 212); + --la-secondary: rgb(157, 157, 159); + --la-pre-background: rgb(236, 236, 236); + --la-pre-border: rgb(224, 224, 224); + --la-pre-color: rgb(47, 47, 49); + --la-hr: rgb(220, 220, 220); + --la-drag-handle-hover: rgb(92, 92, 94); + + --hljs-string: rgb(170, 67, 15); + --hljs-title: rgb(176, 136, 54); + --hljs-comment: rgb(153, 153, 153); + --hljs-keyword: rgb(12, 94, 177); + --hljs-attr: rgb(58, 146, 188); + --hljs-literal: rgb(200, 43, 15); + --hljs-name: rgb(37, 151, 146); + --hljs-selector-tag: rgb(200, 80, 15); + --hljs-number: rgb(61, 160, 103); +} + +.dark .ProseMirror { + --la-code-background: rgba(255, 255, 255, 0.075); + --la-code-color: rgb(44, 46, 51); + --la-secondary: rgb(89, 90, 92); + --la-pre-background: rgb(8, 8, 8); + --la-pre-border: rgb(35, 37, 42); + --la-pre-color: rgb(227, 228, 230); + --la-hr: rgb(38, 40, 45); + --la-drag-handle-hover: rgb(150, 151, 153); + + --hljs-string: rgb(218, 147, 107); + --hljs-title: rgb(241, 213, 157); + --hljs-comment: rgb(170, 170, 170); + --hljs-keyword: rgb(102, 153, 204); + --hljs-attr: rgb(144, 202, 232); + --hljs-literal: rgb(242, 119, 122); + --hljs-name: rgb(95, 192, 160); + --hljs-selector-tag: rgb(232, 199, 133); + --hljs-number: rgb(182, 231, 182); +} + +.la-editor .ProseMirror { + @apply flex max-w-full flex-1 cursor-text flex-col; + @apply z-0 outline-0; +} + +.la-editor .ProseMirror > div.editor { + @apply block flex-1 whitespace-pre-wrap; +} + +.la-editor .ProseMirror .block-node:not(:last-child), +.la-editor .ProseMirror .list-node:not(:last-child), +.la-editor .ProseMirror .text-node:not(:last-child) { + @apply mb-2.5; +} + +.la-editor .ProseMirror ol, +.la-editor .ProseMirror ul { + @apply pl-6; +} + +.la-editor .ProseMirror blockquote, +.la-editor .ProseMirror dl, +.la-editor .ProseMirror ol, +.la-editor .ProseMirror p, +.la-editor .ProseMirror pre, +.la-editor .ProseMirror ul { + @apply m-0; +} + +.la-editor .ProseMirror li { + @apply leading-7; +} + +.la-editor .ProseMirror p { + @apply break-words; +} + +.la-editor .ProseMirror li .text-node:has(+ .list-node), +.la-editor .ProseMirror li > .list-node, +.la-editor .ProseMirror li > .text-node, +.la-editor .ProseMirror li p { + @apply mb-0; +} + +.la-editor .ProseMirror blockquote { + @apply relative pl-3.5; +} + +.la-editor .ProseMirror blockquote::before, +.la-editor .ProseMirror blockquote.is-empty::before { + @apply bg-accent absolute bottom-0 left-0 top-0 h-full w-1 rounded-sm content-['']; +} + +.la-editor .ProseMirror hr { + @apply my-3 h-0.5 w-full border-none bg-[var(--la-hr)]; +} + +.la-editor .ProseMirror-focused hr.ProseMirror-selectednode { + @apply outline-muted-foreground rounded-full outline outline-2 outline-offset-1; +} + +.la-editor .ProseMirror .ProseMirror-gapcursor { + @apply pointer-events-none absolute hidden; +} + +.la-editor .ProseMirror .ProseMirror-hideselection { + @apply caret-transparent; +} + +.la-editor .ProseMirror.resize-cursor { + @apply cursor-col-resize; +} + +.la-editor .ProseMirror .selection { + @apply inline-block; +} + +.la-editor .ProseMirror .selection, +.la-editor .ProseMirror *::selection, +::selection { + @apply bg-primary/40; +} + +/* Override native selection when custom selection is present */ +.la-editor .ProseMirror .selection::selection { + background: transparent; +} + +[data-theme="slash-command"] { + width: 1000vw; +} + +@import "./partials/code.css"; +@import "./partials/placeholder.css"; +@import "./partials/lists.css"; +@import "./partials/typography.css"; diff --git a/web/components/la-editor/styles/partials/code.css b/web/components/la-editor/styles/partials/code.css new file mode 100644 index 00000000..62d349c7 --- /dev/null +++ b/web/components/la-editor/styles/partials/code.css @@ -0,0 +1,86 @@ +.la-editor .ProseMirror code.inline { + @apply rounded border border-[var(--la-code-color)] bg-[var(--la-code-background)] px-1 py-0.5 text-sm; +} + +.la-editor .ProseMirror pre { + @apply relative overflow-auto rounded border font-mono text-sm; + @apply border-[var(--la-pre-border)] bg-[var(--la-pre-background)] text-[var(--la-pre-color)]; + @apply hyphens-none whitespace-pre text-left; +} + +.la-editor .ProseMirror code { + @apply break-words leading-[1.7em]; +} + +.la-editor .ProseMirror pre code { + @apply block overflow-x-auto p-3.5; +} + +.la-editor .ProseMirror pre { + .hljs-keyword, + .hljs-operator, + .hljs-function, + .hljs-built_in, + .hljs-builtin-name { + color: var(--hljs-keyword); + } + + .hljs-attr, + .hljs-symbol, + .hljs-property, + .hljs-attribute, + .hljs-variable, + .hljs-template-variable, + .hljs-params { + color: var(--hljs-attr); + } + + .hljs-name, + .hljs-regexp, + .hljs-link, + .hljs-type, + .hljs-addition { + color: var(--hljs-name); + } + + .hljs-string, + .hljs-bullet { + color: var(--hljs-string); + } + + .hljs-title, + .hljs-subst, + .hljs-section { + color: var(--hljs-title); + } + + .hljs-literal, + .hljs-type, + .hljs-deletion { + color: var(--hljs-literal); + } + + .hljs-selector-tag, + .hljs-selector-id, + .hljs-selector-class { + color: var(--hljs-selector-tag); + } + + .hljs-number { + color: var(--hljs-number); + } + + .hljs-comment, + .hljs-meta, + .hljs-quote { + color: var(--hljs-comment); + } + + .hljs-emphasis { + @apply italic; + } + + .hljs-strong { + @apply font-bold; + } +} diff --git a/web/components/la-editor/styles/partials/lists.css b/web/components/la-editor/styles/partials/lists.css new file mode 100644 index 00000000..5daa99bf --- /dev/null +++ b/web/components/la-editor/styles/partials/lists.css @@ -0,0 +1,82 @@ +.la-editor div.tiptap p { + @apply text-[var(--la-font-size-regular)]; +} + +.la-editor .ProseMirror ol { + @apply list-decimal; +} + +.la-editor .ProseMirror ol ol { + list-style: lower-alpha; +} + +.la-editor .ProseMirror ol ol ol { + list-style: lower-roman; +} + +.la-editor .ProseMirror ul { + list-style: disc; +} + +.la-editor .ProseMirror ul ul { + list-style: circle; +} + +.la-editor .ProseMirror ul ul ul { + list-style: square; +} + +.la-editor .ProseMirror ul[data-type="taskList"] { + @apply list-none pl-1; +} + +.la-editor .ProseMirror ul[data-type="taskList"] p { + @apply m-0; +} + +.la-editor .ProseMirror ul[data-type="taskList"] li > label { + @apply mr-2 mt-0.5 flex-none select-none; +} + +.la-editor .ProseMirror li[data-type="taskItem"] { + @apply flex flex-row items-start; +} + +.la-editor .ProseMirror li[data-type="taskItem"] .taskItem-checkbox-container { + @apply relative pr-2; +} + +.la-editor .ProseMirror .taskItem-drag-handle { + @apply absolute -left-5 top-1.5 h-[18px] w-[18px] cursor-move pl-0.5 text-[var(--la-secondary)] opacity-0; +} + +.la-editor + .ProseMirror + li[data-type="taskItem"]:hover:not(:has(li:hover)) + > .taskItem-checkbox-container + > .taskItem-drag-handle { + @apply opacity-100; +} + +.la-editor .ProseMirror .taskItem-drag-handle:hover { + @apply text-[var(--la-drag-handle-hover)]; +} + +.la-editor .ProseMirror .taskItem-checkbox { + fill-opacity: 0; + @apply h-3.5 w-3.5 flex-shrink-0 cursor-pointer select-none appearance-none rounded border border-solid border-[var(--la-secondary)] bg-transparent bg-[1px_2px] p-0.5 align-middle transition-colors duration-75 ease-out; +} + +.la-editor .ProseMirror .taskItem-checkbox:checked { + @apply border-primary bg-primary bg-no-repeat; + background-image: url("data:image/svg+xml;utf8,%3Csvg%20width=%2210%22%20height=%229%22%20viewBox=%220%200%2010%208%22%20xmlns=%22http://www.w3.org/2000/svg%22%20fill=%22%23fbfbfb%22%3E%3Cpath%20d=%22M3.46975%205.70757L1.88358%204.1225C1.65832%203.8974%201.29423%203.8974%201.06897%204.1225C0.843675%204.34765%200.843675%204.7116%201.06897%204.93674L3.0648%206.93117C3.29006%207.15628%203.65414%207.15628%203.8794%206.93117L8.93103%201.88306C9.15633%201.65792%209.15633%201.29397%208.93103%201.06883C8.70578%200.843736%208.34172%200.843724%208.11646%201.06879C8.11645%201.0688%208.11643%201.06882%208.11642%201.06883L3.46975%205.70757Z%22%20stroke-width=%220.2%22%20/%3E%3C/svg%3E"); +} + +.la-editor .ProseMirror .taskItem-content { + @apply min-w-0 flex-1; +} + +.la-editor .ProseMirror li[data-checked="true"] .taskItem-content > :not([data-type="taskList"]), +.la-editor .ProseMirror li[data-checked="true"] .taskItem-content .taskItem-checkbox { + @apply opacity-75; +} diff --git a/web/components/la-editor/styles/partials/placeholder.css b/web/components/la-editor/styles/partials/placeholder.css new file mode 100644 index 00000000..30fb78b8 --- /dev/null +++ b/web/components/la-editor/styles/partials/placeholder.css @@ -0,0 +1,12 @@ +.la-editor .ProseMirror .is-empty::before { + @apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)]; +} + +.la-editor .ProseMirror.ProseMirror-focused > p.has-focus.is-empty::before { + content: "Type / for commands..."; +} + +.la-editor .ProseMirror > p.is-editor-empty::before { + content: attr(data-placeholder); + @apply pointer-events-none float-left h-0 text-[var(--la-secondary)]; +} diff --git a/web/components/la-editor/styles/partials/typography.css b/web/components/la-editor/styles/partials/typography.css new file mode 100644 index 00000000..2e9cffd9 --- /dev/null +++ b/web/components/la-editor/styles/partials/typography.css @@ -0,0 +1,27 @@ +.la-editor .ProseMirror .heading-node { + @apply relative font-semibold; +} + +.la-editor .ProseMirror .heading-node:first-child { + @apply mt-0; +} + +.la-editor .ProseMirror h1 { + @apply mb-4 mt-[46px] text-[1.375rem] leading-7 tracking-[-0.004375rem]; +} + +.la-editor .ProseMirror h2 { + @apply mb-3.5 mt-8 text-[1.1875rem] leading-7 tracking-[0.003125rem]; +} + +.la-editor .ProseMirror h3 { + @apply mb-3 mt-6 text-[1.0625rem] leading-6 tracking-[0.00625rem]; +} + +.la-editor .ProseMirror a.link { + @apply text-primary cursor-pointer; +} + +.la-editor .ProseMirror a.link:hover { + @apply underline; +} diff --git a/web/components/la-editor/types.ts b/web/components/la-editor/types.ts new file mode 100644 index 00000000..adcf7646 --- /dev/null +++ b/web/components/la-editor/types.ts @@ -0,0 +1,20 @@ +import React from "react" +import { Editor as CoreEditor } from "@tiptap/core" +import { Editor } from "@tiptap/react" +import { EditorState } from "@tiptap/pm/state" +import { EditorView } from "@tiptap/pm/view" + +export interface MenuProps { + editor: Editor + appendTo?: React.RefObject + shouldHide?: boolean +} + +export interface ShouldShowProps { + editor?: CoreEditor + view: EditorView + state?: EditorState + oldState?: EditorState + from?: number + to?: number +} diff --git a/web/components/routes/globalTopic/globalTopic.tsx b/web/components/routes/globalTopic/globalTopic.tsx new file mode 100644 index 00000000..f0a021c4 --- /dev/null +++ b/web/components/routes/globalTopic/globalTopic.tsx @@ -0,0 +1,158 @@ +"use client" +import React, { useState } from "react" +import { ContentHeader } from "@/components/custom/content-header" +import { PiLinkSimple } from "react-icons/pi" +import { Bookmark, GraduationCap, Check } from "lucide-react" + +interface LinkProps { + title: string + url: string +} + +const links = [ + { title: "JavaScript", url: "https://justjavascript.com" }, + { title: "TypeScript", url: "https://www.typescriptlang.org/" }, + { title: "React", url: "https://reactjs.org/" } +] + +const LinkItem: React.FC = ({ title, url }) => ( +
+) +interface ButtonProps { + children: React.ReactNode + onClick: () => void + className?: string + color?: string + icon?: React.ReactNode + fullWidth?: boolean +} + +const Button: React.FC = ({ children, onClick, className = "", color = "", icon, fullWidth = false }) => { + return ( + + ) +} + +export default function GlobalTopic({ topic }: { topic: string }) { + const [showOptions, setShowOptions] = useState(false) + const [selectedOption, setSelectedOption] = useState(null) + const [activeTab, setActiveTab] = useState("Guide") + + const decodedTopic = decodeURIComponent(topic) + + const learningOptions = [ + { text: "To Learn", icon: }, + { text: "Learning", icon: }, + { text: "Learned", icon: } + ] + + const learningStatusColor = (option: string) => { + switch (option) { + case "To Learn": + return "text-white/70" + case "Learning": + return "text-[#D29752]" + case "Learned": + return "text-[#708F51]" + default: + return "text-white/70" + } + } + + const selectedStatus = (option: string) => { + setSelectedOption(option) + setShowOptions(false) + } + + return ( +
+ +
+

{decodedTopic}

+
+
+ + +
+
+
+ +
+ + {showOptions && ( +
+ {learningOptions.map(option => ( + + ))} +
+ )} +
+
+
+

Intro

+ {links.map((link, index) => ( + + ))} +
+
+

Other

+ {links.map((link, index) => ( + + ))} +
+
+
+ ) +} diff --git a/web/components/routes/link/form/manage.tsx b/web/components/routes/link/form/manage.tsx new file mode 100644 index 00000000..1dc01fb9 --- /dev/null +++ b/web/components/routes/link/form/manage.tsx @@ -0,0 +1,438 @@ +"use client" + +import React, { useState, useEffect, useRef } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useDebounce } from "react-use" +import { toast } from "sonner" +import Image from "next/image" +import { z } from "zod" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Form, FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form" +import { BoxIcon, PlusIcon, Trash2Icon, PieChartIcon, Bookmark, GraduationCap, Check } from "lucide-react" +import { cn, ensureUrlProtocol, generateUniqueSlug, isUrl as LibIsUrl } from "@/lib/utils" +import { useAccount, useCoState } from "@/lib/providers/jazz-provider" +import { LinkMetadata, PersonalLink } from "@/lib/schema/personal-link" +import { createLinkSchema } from "./schema" +import { TopicSelector } from "./partial/topic-section" +import { useAtom } from "jotai" +import { linkEditIdAtom, linkShowCreateAtom } from "@/store/link" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu" +import { useKey } from "react-use" + +export type LinkFormValues = z.infer + +const DEFAULT_FORM_VALUES: Partial = { + title: "", + description: "", + topic: "", + isLink: false, + meta: null +} + +const LinkManage: React.FC = () => { + const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom) + const [, setEditId] = useAtom(linkEditIdAtom) + const formRef = useRef(null) + const buttonRef = useRef(null) + + const toggleForm = (event: React.MouseEvent) => { + event.stopPropagation() + setShowCreate(prev => !prev) + } + + useEffect(() => { + if (!showCreate) { + formRef.current?.reset() + setEditId(null) + } + }, [showCreate, setEditId]) + + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if ( + formRef.current && + !formRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setShowCreate(false) + } + } + + if (showCreate) { + document.addEventListener("mousedown", handleOutsideClick) + } + + return () => { + document.removeEventListener("mousedown", handleOutsideClick) + } + }, [showCreate, setShowCreate]) + + useKey("Escape", () => { + setShowCreate(false) + }) + + return ( + <> + {showCreate && ( +
+ setShowCreate(false)} onCancel={() => setShowCreate(false)} /> +
+ )} + + + ) +} + +const CreateButton = React.forwardRef< + HTMLButtonElement, + { + onClick: (event: React.MouseEvent) => void + isOpen: boolean + } +>(({ onClick, isOpen }, ref) => ( + +)) + +CreateButton.displayName = "CreateButton" + +interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> { + onSuccess?: () => void + onCancel?: () => void + personalLink?: PersonalLink +} + +const LinkForm = React.forwardRef(({ onSuccess, onCancel, personalLink }, ref) => { + const selectedLink = useCoState(PersonalLink, personalLink?.id) + const [isFetching, setIsFetching] = useState(false) + const { me } = useAccount() + const form = useForm({ + resolver: zodResolver(createLinkSchema), + defaultValues: DEFAULT_FORM_VALUES + }) + + const title = form.watch("title") + const [originalLink, setOriginalLink] = useState("") + const [linkEntered, setLinkEntered] = useState(false) + const [debouncedText, setDebouncedText] = useState("") + useDebounce(() => setDebouncedText(title), 300, [title]) + + const [showStatusOptions, setShowStatusOptions] = useState(false) + const [selectedStatus, setSelectedStatus] = useState(null) + + const statusOptions = [ + { + text: "To Learn", + icon: , + color: "text-white/70" + }, + { + text: "Learning", + icon: , + color: "text-[#D29752]" + }, + { text: "Learned", icon: , color: "text-[#708F51]" } + ] + + const statusSelect = (status: string) => { + setSelectedStatus(status === selectedStatus ? null : status) + setShowStatusOptions(false) + } + + useEffect(() => { + if (selectedLink) { + form.setValue("title", selectedLink.title) + form.setValue("description", selectedLink.description ?? "") + form.setValue("isLink", selectedLink.isLink) + form.setValue("meta", selectedLink.meta) + } + }, [selectedLink, form]) + + useEffect(() => { + const fetchMetadata = async (url: string) => { + setIsFetching(true) + try { + const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "no-store" }) + if (!res.ok) throw new Error("Failed to fetch metadata") + const data = await res.json() + form.setValue("isLink", true) + form.setValue("meta", data) + form.setValue("title", data.title) + form.setValue("description", data.description) + setOriginalLink(url) + } catch (err) { + form.setValue("isLink", false) + form.setValue("meta", null) + form.setValue("title", debouncedText) + form.setValue("description", "") + setOriginalLink("") + } finally { + setIsFetching(false) + } + } + + const lowerText = debouncedText.toLowerCase() + if (linkEntered && LibIsUrl(lowerText)) { + fetchMetadata(ensureUrlProtocol(lowerText)) + } + }, [debouncedText, form, linkEntered]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && LibIsUrl(e.currentTarget.value.toLowerCase())) { + e.preventDefault() + setLinkEntered(true) + } + } + + const onSubmit = (values: LinkFormValues) => { + if (isFetching) return + + try { + let linkMetadata: LinkMetadata | undefined + + const personalLinks = me.root?.personalLinks?.toJSON() || [] + const slug = generateUniqueSlug(personalLinks, values.title) + + if (values.isLink && values.meta) { + linkMetadata = LinkMetadata.create(values.meta, { owner: me._owner }) + } + + if (selectedLink) { + selectedLink.title = values.title + selectedLink.slug = slug + selectedLink.description = values.description ?? "" + selectedLink.isLink = values.isLink + + if (selectedLink.meta) { + Object.assign(selectedLink.meta, values.meta) + } + + // toast.success("Todo updated") + } else { + const newPersonalLink = PersonalLink.create( + { + title: values.title, + slug, + description: values.description, + sequence: me.root?.personalLinks?.length || 1, + completed: false, + isLink: values.isLink, + meta: linkMetadata + // topic: values.topic + }, + { owner: me._owner } + ) + + me.root?.personalLinks?.push(newPersonalLink) + } + + form.reset(DEFAULT_FORM_VALUES) + onSuccess?.() + } catch (error) { + console.error("Failed to create/update link", error) + toast.error(personalLink ? "Failed to update link" : "Failed to create link") + } + } + + const handleCancel: () => void = () => { + form.reset(DEFAULT_FORM_VALUES) + onCancel?.() + } + + return ( +
+
+
+ +
+
+
+
+ + + ( + + Text + + + + + )} + /> + + {linkEntered + ? originalLink + : LibIsUrl(form.watch("title").toLowerCase()) + ? 'Press "Enter" to confirm URL' + : ""} + +
+ +
+ + + {/* */} + + + Actions + + + Delete + + + +
+ + {showStatusOptions && ( +
+ {statusOptions.map(option => ( + + ))} +
+ )} +
+
+
+ +
+ ( + + Description + +