* wip

* wip

* wip3

* chore: utils

* feat: add command

* wip

* fix: key duplicate

* fix: move and check

* fix: use react-use instead

* fix: sidebar

* chore: make dynamic

* chore: tablet mode

* chore: fix padding

* chore: link instead of inbox

* fix: use dnd kit

* feat: add select component

* chore: use atom

* refactor: remove dnd provider

* feat: disabled drag when sort is not manual

* search route

* .

* feat: accessibility

* fix: search

* .

* .

* .

* fix: sidebar collapsed

* ai search layout

* .

* .

* .

* .

* ai responsible content

* .

* .

* .

* .

* .

* global topic route

* global topic correct route

* topic buttons

* sidebar search navigation

* ai

* Update jazz

* .

* .

* .

* .

* .

* learning status

* .

* .

* chore: content header

* fix: pointer none when dragging. prevent auto click after drag end

* fix: confirm

* fix: prevent drag when editing

* chore: remove unused fn

* fix: check propagation

* chore: list

* chore: tweak sonner

* chore: update stuff

* feat: add badge

* chore: close edit when create

* chore: escape on manage form

* refactor: remove learn path

* css: responsive item

* chore: separate pages and topic

* reafactor: remove new-schema

* feat(types): extend jazz type so it can be nullable

* chore: use new types

* fix: missing deps

* fix: link

* fix: sidebar in layout

* fix: quotes

* css: use medium instead semi

* Actual streaming and rendering markdown response

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* chore: metadata

* feat: la-editor

* .

* fix: editor and page

* .

* .

* .

* .

* .

* .

* fix: remove link

* chore: page sidebar

* fix: remove 'replace with learning status'

* fix: link

* fix: link

* chore: update schema

* chore: use new schema

* fix: instead of showing error, just do unique slug

* feat: create slug

* refactor apply

* update package json

* fix: schema personal page

* chore: editor

* feat: pages

* fix: metadata

* fix: jazz provider

* feat: handling data

* feat: page detail

* chore: server page to id

* chore: use id instead of slug

* chore: update content header

* chore: update link header implementation

* refactor: global.css

* fix: la editor use animation frame

* fix: editor export ref

* refactor: page detail

* chore: tidy up schema

* chore: adapt to new schema

* fix: wrap using settimeout

* fix: wrap using settimeout

* .

* .

---------

Co-authored-by: marshennikovaolga <marshennikova@gmail.com>
Co-authored-by: Nikita <github@nikiv.dev>
Co-authored-by: Anselm <anselm.eickhoff@gmail.com>
Co-authored-by: Damian Tarnawski <gthetarnav@gmail.com>
This commit is contained in:
Aslam
2024-08-08 00:57:22 +07:00
committed by GitHub
parent 228faf226a
commit 36e0e19212
143 changed files with 6967 additions and 101 deletions

49
.gitignore vendored
View File

@@ -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-*

BIN
bun.lockb

Binary file not shown.

12
cli/run.ts Normal file
View File

@@ -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()

59
cli/seed.ts Normal file
View File

@@ -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<Group>, worker, {})
if (!globalGroup) return // TODO: err
// TODO: complete full seed (connections, topics from old LA)
}
await seed()

7
lib/utils.ts Normal file
View File

@@ -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
}

21
license Normal file
View File

@@ -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.

View File

@@ -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"
}

2
web/.env.example Normal file
View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_APP_NAME="Learn Anything"
NEXT_PUBLIC_APP_URL=http://localhost:3000

View File

@@ -0,0 +1,15 @@
import { Sidebar } from "@/components/custom/sidebar/sidebar"
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<main className="bg-card relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { LinkWrapper } from "@/components/routes/link/wrapper"
export default function LinkPage() {
return <LinkWrapper />
}

View File

@@ -0,0 +1,5 @@
import { DetailPageWrapper } from "@/components/routes/page/detail/wrapper"
export default function DetailPage({ params }: { params: { id: string } }) {
return <DetailPageWrapper pageId={params.id} />
}

View File

@@ -0,0 +1,14 @@
"use client"
import { useAccount } from "@/lib/providers/jazz-provider"
export const ProfileWrapper = () => {
const account = useAccount()
return (
<div>
<h2>{account.me.profile?.name}</h2>
<p>Profile Page</p>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { ProfileWrapper } from "./_components/wrapper"
export default function ProfilePage() {
return <ProfileWrapper />
}

View File

@@ -0,0 +1,5 @@
import { SearchWrapper } from "@/components/routes/search/wrapper"
export default function ProfilePage() {
return <SearchWrapper />
}

View File

@@ -0,0 +1,14 @@
import { Sidebar } from "@/components/custom/sidebar/sidebar"
export default function TopicsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<main className="bg-card relative flex flex-auto flex-col place-items-stretch overflow-auto rounded-md border lg:my-2 lg:mr-2">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import GlobalTopic from "@/components/routes/globalTopic/globalTopic"
export default function GlobalTopicPage({ params }: { params: { topic: string } }) {
return <GlobalTopic topic={params.topic} />
}

View File

@@ -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<typeof axios>
describe("Metadata Fetcher", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("should return metadata when URL is valid", async () => {
const mockHtml = `
<html>
<head>
<title>Test Title</title>
<meta name="description" content="Test Description">
<link rel="icon" href="/favicon.ico">
</head>
</html>
`
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 = `
<html>
<head>
</head>
</html>
`
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"
})
})
})

View File

@@ -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)
}
}

View File

@@ -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<string>({
async start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk)
await new Promise(resolve => setTimeout(resolve, 1000))
}
controller.close()
}
})
return new NextResponse(stream)
}

View File

@@ -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;
}
}

View File

@@ -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 (
<html lang="en">
<body className={inter.className}>{children}</body>
<html lang="en" className="h-full w-full">
<body className={cn("h-full w-full font-sans antialiased", inter.variable)}>
<JazzProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<JotaiProvider>
<ConfirmProvider>
{children}
<Toaster />
</ConfirmProvider>
</JotaiProvider>
</ThemeProvider>
</JazzProvider>
</body>
</html>
)
}

View File

@@ -1,5 +1,12 @@
"use client"
import { Button } from "@/components/ui/button"
import Link from "next/link"
export default function Home() {
return <div></div>
export default function HomePage() {
return (
<div className="flex min-h-full items-center justify-center">
<Link href="/links">
<Button>Go to main page</Button>
</Link>
</div>
)
}

17
web/components.json Normal file
View File

@@ -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"
}
}

View File

@@ -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<AiSearchProps> = (props: { searchQuery: string }) => {
const [error, setError] = useState<string>("")
let root_el = React.useRef<HTMLDivElement | null>(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 (
<div className="mx-auto flex max-w-3xl flex-col items-center">
<div className="w-full rounded-lg bg-inherit p-6 text-white">
<div className="mb-6 rounded-lg bg-blue-700 p-4">
<h2 className="text-lg font-medium"> This is what I have found:</h2>
</div>
<div className="rounded-xl bg-[#121212] p-4" ref={root_el}></div>
</div>
<p className="text-md pb-5 font-semibold opacity-50">{error}</p>
<button className="text-md rounded-2xl bg-neutral-800 px-6 py-3 font-semibold text-opacity-50 shadow-inner shadow-neutral-700/50 transition-colors hover:bg-neutral-700">
Ask Community
</button>
</div>
)
}
export default AiSearch

View File

@@ -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<React.HTMLAttributes<HTMLDivElement>, "title">
export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps>(
({ children, className, ...props }, ref) => {
return (
<header
className={cn(
"flex min-h-10 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 border border-b pl-8 pr-6 transition-opacity max-lg:pl-4 max-lg:pr-5",
className
)}
ref={ref}
{...props}
>
{children}
</header>
)
}
)
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<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
toggle()
}
return (
<div className="flex items-center gap-1">
<Button
type="button"
size="icon"
variant="ghost"
aria-label="Menu"
className="text-primary/60 z-50"
onClick={handleClick}
>
<PanelLeftIcon size={16} />
</Button>
<Separator orientation="vertical" />
</div>
)
}

View File

@@ -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<Acc extends Account> = (setJazzAuthState: (state: AuthState) => void) => {
auth: AuthProvider<Acc>
AuthUI: React.ReactNode
logOut?: () => void
}
type DemoAuthProps<Acc extends Account = Account> = {
accountSchema?: CoValueClass<Acc> & typeof Account
appName: string
appHostname?: string
Component?: DemoAuth.Component
seedAccounts?: {
[name: string]: { accountID: ID<Account>; accountSecret: AgentSecret }
}
}
type AuthComponentProps = {
appName: string
loading: boolean
existingUsers: string[]
logInAs: (existingUser: string) => void
signUp: (username: string) => void
}
// Main DemoAuth function
export function DemoAuth<Acc extends Account = Account>({
accountSchema = Account as CoValueClass<Acc> & typeof Account,
appName,
appHostname,
Component = DemoAuth.BasicUI,
seedAccounts
}: DemoAuthProps<Acc>): ReactAuthHook<Acc> {
return function useLocalAuth(setJazzAuthState) {
const [authState, setAuthState] = useState<AuthState>("loading")
const [existingUsers, setExistingUsers] = useState<string[]>([])
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<Acc>(
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 = (
<Component
appName={appName}
loading={authState === "loading"}
existingUsers={existingUsers}
logInAs={logInAs}
signUp={signUp}
/>
)
return { auth, AuthUI, logOut }
}
}
const DemoAuthBasicUI: React.FC<AuthComponentProps> = ({ appName, existingUsers, logInAs, signUp }) => {
const [username, setUsername] = useState<string>("")
const darkMode = useDarkMode()
return (
<div className="relative flex min-h-full flex-col justify-center">
<div className="mx-auto h-full w-full max-w-sm space-y-6 p-4">
<h1 className="text-center font-semibold">{appName}</h1>
<SignUpForm username={username} setUsername={setUsername} signUp={signUp} darkMode={darkMode} />
<ExistingUsersList existingUsers={existingUsers} logInAs={logInAs} darkMode={darkMode} />
</div>
</div>
)
}
// Helper components
const SignUpForm: React.FC<{
username: string
setUsername: (value: string) => void
signUp: (username: string) => void
darkMode: boolean
}> = ({ username, setUsername, signUp, darkMode }) => (
<form
onSubmit={e => {
e.preventDefault()
signUp(username)
}}
className="flex flex-col gap-y-4"
>
<Input
placeholder="Display name"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="webauthn"
/>
<Button type="submit">Sign Up as new account</Button>
</form>
)
const ExistingUsersList: React.FC<{
existingUsers: string[]
logInAs: (user: string) => void
darkMode: boolean
}> = ({ existingUsers, logInAs, darkMode }) => (
<div className="flex flex-col gap-y-2">
{existingUsers.map(user => (
<Button key={user} onClick={() => logInAs(user)}>
Log In as &quot;{user}&quot;
</Button>
))}
</div>
)
// 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<AuthComponentProps>
export const BasicUI = DemoAuthBasicUI
}

View File

@@ -0,0 +1,57 @@
import * as React from "react"
interface Logo extends React.SVGProps<SVGSVGElement> {}
export const Logo = ({ className, ...props }: Logo) => {
return (
<svg width="35" height="35" viewBox="0 0 30 30" fill="none" className={className} {...props}>
<g clipPath="url(#clip0_7502_1806)">
<g opacity="0.7">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21.0784 28.966C22.2824 28.4786 23.4001 27.8248 24.4023 27.0309C23.3266 27.824 22.8358 28.1863 21.4672 28.855C21.1737 28.9845 20.7834 29.1589 20.4862 29.2776C20.7374 29.1817 20.9384 29.0775 21.0784 28.966ZM21.0784 28.966C21.9873 28.2464 20.0201 27.5006 16.6827 27.3016C13.3458 27.1024 9.90278 27.5248 8.99303 28.2455C8.53799 28.6055 8.80403 28.9727 9.60135 29.2735C8.65457 28.8758 8.5333 28.8244 7.8472 28.4597C6.75696 27.8168 6.58962 27.7185 5.73927 27.0742L4.92993 26.3942C4.52809 26.0366 4.52726 25.6534 5.00506 25.274C6.5144 24.0787 12.2291 23.3778 17.7679 23.708C23.2115 24.0331 26.4595 25.2334 25.1377 26.4094L24.4023 27.0309C23.4001 27.8248 22.2824 28.4786 21.0784 28.966ZM28.3512 22.3353C29.1155 20.9354 25.0453 19.584 18.5582 19.1967C11.4141 18.7709 4.0449 19.6752 2.09828 21.2168C1.63169 21.5863 1.51866 21.9584 1.71466 22.3174L1.24738 21.3808C0.661456 19.9547 0.637998 19.8993 0.411012 19.0759C0.290928 18.5604 0.132822 17.8708 0.0436785 17.3489C-0.00522774 17.0334 0.161581 16.7104 0.566459 16.3893C2.74386 14.6655 10.9842 13.6538 18.9722 14.1302C25.8065 14.5389 30.2415 15.9033 30.0181 17.3685C29.9229 17.8609 29.799 18.5172 29.6776 19.0027C29.2111 20.51 29.2018 20.5387 28.8566 21.3131L28.3512 22.3353ZM29.8832 11.9702C29.6058 10.6126 25.3295 9.38692 18.9372 9.00544C11.0164 8.53218 2.84438 9.53532 0.686174 11.2447C0.388347 11.4802 0.22062 11.7166 0.173528 11.951C0.310001 11.3893 0.502756 10.6417 0.675563 10.0903C1.23679 8.62642 1.24754 8.59884 1.64202 7.8504L2.07443 7.08959C2.15058 6.96518 2.26721 6.83897 2.42498 6.71374C4.32178 5.21178 11.5008 4.33054 18.4599 4.74618C23.6915 5.05808 27.3098 6.0137 27.9778 7.10736L28.4113 7.86864C29.076 9.24824 29.102 9.30198 29.3797 10.1094C29.5436 10.6635 29.7539 11.4062 29.8832 11.9702ZM24.5623 3.1821C23.6812 2.47343 21.1317 1.88047 17.6199 1.66987C12.3597 1.35668 6.93276 2.02235 5.49908 3.15763C5.49209 3.16281 5.48681 3.16755 5.48041 3.17257L5.65732 3.03037C6.60122 2.33439 7.22384 1.87498 8.5921 1.20633C9.52394 0.795491 9.62105 0.752916 10.3408 0.509223C11.6398 0.0845342 14.0986 -0.130655 16.4976 0.0123293C17.8074 0.0906479 18.8815 0.262207 19.6062 0.485844C20.3846 0.756101 20.569 0.819981 21.3403 1.16385C22.38 1.68628 22.5964 1.79488 23.5716 2.43791C23.8701 2.65971 24.2735 2.94884 24.5623 3.1821Z"
fill="white"
fillOpacity="0.5"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21.0784 28.966C22.2824 28.4786 23.4001 27.8248 24.4023 27.0309C23.3266 27.824 22.8358 28.1863 21.4672 28.855C21.1737 28.9845 20.7834 29.1589 20.4862 29.2776C20.7374 29.1817 20.9384 29.0775 21.0784 28.966ZM21.0784 28.966C21.9873 28.2464 20.0201 27.5006 16.6827 27.3016C13.3458 27.1024 9.90278 27.5248 8.99303 28.2455C8.53799 28.6055 8.80403 28.9727 9.60135 29.2735C8.65457 28.8758 8.5333 28.8244 7.8472 28.4597C6.75696 27.8168 6.58962 27.7185 5.73927 27.0742L4.92993 26.3942C4.52809 26.0366 4.52726 25.6534 5.00506 25.274C6.5144 24.0787 12.2291 23.3778 17.7679 23.708C23.2115 24.0331 26.4595 25.2334 25.1377 26.4094L24.4023 27.0309C23.4001 27.8248 22.2824 28.4786 21.0784 28.966ZM28.3512 22.3353C29.1155 20.9354 25.0453 19.584 18.5582 19.1967C11.4141 18.7709 4.0449 19.6752 2.09828 21.2168C1.63169 21.5863 1.51866 21.9584 1.71466 22.3174L1.24738 21.3808C0.661456 19.9547 0.637998 19.8993 0.411012 19.0759C0.290928 18.5604 0.132822 17.8708 0.0436785 17.3489C-0.00522774 17.0334 0.161581 16.7104 0.566459 16.3893C2.74386 14.6655 10.9842 13.6538 18.9722 14.1302C25.8065 14.5389 30.2415 15.9033 30.0181 17.3685C29.9229 17.8609 29.799 18.5172 29.6776 19.0027C29.2111 20.51 29.2018 20.5387 28.8566 21.3131L28.3512 22.3353ZM29.8832 11.9702C29.6058 10.6126 25.3295 9.38692 18.9372 9.00544C11.0164 8.53218 2.84438 9.53532 0.686174 11.2447C0.388347 11.4802 0.22062 11.7166 0.173528 11.951C0.310001 11.3893 0.502756 10.6417 0.675563 10.0903C1.23679 8.62642 1.24754 8.59884 1.64202 7.8504L2.07443 7.08959C2.15058 6.96518 2.26721 6.83897 2.42498 6.71374C4.32178 5.21178 11.5008 4.33054 18.4599 4.74618C23.6915 5.05808 27.3098 6.0137 27.9778 7.10736L28.4113 7.86864C29.076 9.24824 29.102 9.30198 29.3797 10.1094C29.5436 10.6635 29.7539 11.4062 29.8832 11.9702ZM24.5623 3.1821C23.6812 2.47343 21.1317 1.88047 17.6199 1.66987C12.3597 1.35668 6.93276 2.02235 5.49908 3.15763C5.49209 3.16281 5.48681 3.16755 5.48041 3.17257L5.65732 3.03037C6.60122 2.33439 7.22384 1.87498 8.5921 1.20633C9.52394 0.795491 9.62105 0.752916 10.3408 0.509223C11.6398 0.0845342 14.0986 -0.130655 16.4976 0.0123293C17.8074 0.0906479 18.8815 0.262207 19.6062 0.485844C20.3846 0.756101 20.569 0.819981 21.3403 1.16385C22.38 1.68628 22.5964 1.79488 23.5716 2.43791C23.8701 2.65971 24.2735 2.94884 24.5623 3.1821Z"
fill="#2358E0"
fillOpacity="0.23"
/>
</g>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M27.9987 7.21638L27.9694 7.16485C27.9799 7.18199 27.9897 7.19915 27.9987 7.21638ZM2.03707 7.19796C1.32209 8.55782 5.28261 9.86753 11.5833 10.243C18.5427 10.6589 25.7223 9.7775 27.6181 8.27546C28.0664 7.91991 28.1802 7.56156 27.9987 7.21638L28.4028 7.92612C28.7627 8.58433 28.844 8.79507 29.3713 10.1669C29.5443 10.7186 29.737 11.4658 29.8748 12.0277C29.9414 12.3524 29.779 12.6843 29.3622 13.0144C27.2039 14.7239 19.032 15.7269 11.1114 15.254C4.27975 14.8461 -0.133951 13.4745 0.165092 12.0085C0.292434 11.4448 0.502892 10.7026 0.667127 10.1478C0.942429 9.34203 0.955541 9.31502 1.63358 7.90789L2.03707 7.19796ZM2.03707 7.19796C2.04614 7.18077 2.0557 7.16395 2.066 7.14708L2.03707 7.19796ZM0.045561 17.4609C0.361533 18.8224 4.66336 20.0491 11.0801 20.4323C19.0685 20.9088 27.3093 19.8977 29.4861 18.1735C29.7914 17.932 29.9613 17.6896 30.0058 17.4492C29.9224 17.9404 29.7763 18.5793 29.6692 19.0601C29.495 19.766 29.3836 20.0424 28.8482 21.3706L28.3427 22.3928C28.2652 22.5344 28.1382 22.6762 27.9592 22.8181C26.0121 24.3604 18.6437 25.2641 11.4993 24.8381C6.06715 24.5138 2.32874 23.5136 1.70622 22.3749L1.23894 21.4383C0.887668 20.6653 0.878487 20.6365 0.402577 19.1333C0.276244 18.6383 0.144853 17.9746 0.045561 17.4609ZM0.045561 17.4609C0.0414181 17.4428 0.0379656 17.4246 0.0352439 17.4064C0.0385814 17.4245 0.0422712 17.4423 0.045561 17.4609ZM30.0058 17.4492C30.0071 17.4415 30.0084 17.4337 30.0097 17.426C30.0085 17.4337 30.0072 17.4415 30.0058 17.4492ZM4.99103 26.51C4.96674 26.4905 4.94348 26.4712 4.92149 26.4517L4.99103 26.51ZM4.99103 26.51C5.925 27.2536 8.60587 27.8751 12.2925 28.0956C17.8319 28.4256 23.5463 27.7251 25.0556 26.5286C25.0583 26.5265 25.061 26.5244 25.0636 26.5223L24.3938 27.0884C23.2187 28.0069 22.4421 28.4062 21.4587 28.9124C21.1678 29.0473 20.7729 29.2108 20.4778 29.3351C19.1007 29.8594 16.2024 30.1364 13.3806 29.9677C11.7136 29.8677 10.3882 29.632 9.59291 29.331C8.87794 29.0455 8.79946 29.0057 7.83876 28.5171C6.91988 27.9982 6.7843 27.8992 5.73083 27.1317L4.99103 26.51ZM25.0636 26.5223L25.1293 26.4669C25.109 26.4851 25.0867 26.5043 25.0636 26.5223ZM24.5539 3.23958C24.9932 3.59312 25.018 3.97504 24.5411 4.35241C23.1081 5.48738 17.6806 6.15388 12.4195 5.83999C7.18209 5.5271 4.08325 4.3611 5.47197 3.23005L5.64889 3.08786C6.87177 2.14553 7.51627 1.81302 8.58366 1.26382C9.22483 0.968483 9.40031 0.900301 10.1065 0.647417C9.89518 0.730196 9.72456 0.819108 9.605 0.915035C8.79062 1.55997 10.5522 2.22662 13.54 2.40516C16.5276 2.58319 19.6101 2.20556 20.4252 1.56034C20.8352 1.23533 20.589 0.904881 19.8634 0.633557C20.4348 0.830615 20.6321 0.916665 21.3318 1.22133C22.2102 1.62645 22.7484 1.97233 23.5631 2.49539C23.8679 2.70793 24.2581 3.01474 24.5539 3.23958Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M27.9987 7.21638L27.9694 7.16485C27.9799 7.18199 27.9897 7.19915 27.9987 7.21638ZM2.03707 7.19796C1.32209 8.55782 5.28261 9.86753 11.5833 10.243C18.5427 10.6589 25.7223 9.7775 27.6181 8.27546C28.0664 7.91991 28.1802 7.56156 27.9987 7.21638L28.4028 7.92612C28.7627 8.58433 28.844 8.79507 29.3713 10.1669C29.5443 10.7186 29.737 11.4658 29.8748 12.0277C29.9414 12.3524 29.779 12.6843 29.3622 13.0144C27.2039 14.7239 19.032 15.7269 11.1114 15.254C4.27975 14.8461 -0.133951 13.4745 0.165092 12.0085C0.292434 11.4448 0.502892 10.7026 0.667127 10.1478C0.942429 9.34203 0.955541 9.31502 1.63358 7.90789L2.03707 7.19796ZM2.03707 7.19796C2.04614 7.18077 2.0557 7.16395 2.066 7.14708L2.03707 7.19796ZM0.045561 17.4609C0.361533 18.8224 4.66336 20.0491 11.0801 20.4323C19.0685 20.9088 27.3093 19.8977 29.4861 18.1735C29.7914 17.932 29.9613 17.6896 30.0058 17.4492C29.9224 17.9404 29.7763 18.5793 29.6692 19.0601C29.495 19.766 29.3836 20.0424 28.8482 21.3706L28.3427 22.3928C28.2652 22.5344 28.1382 22.6762 27.9592 22.8181C26.0121 24.3604 18.6437 25.2641 11.4993 24.8381C6.06715 24.5138 2.32874 23.5136 1.70622 22.3749L1.23894 21.4383C0.887668 20.6653 0.878487 20.6365 0.402577 19.1333C0.276244 18.6383 0.144853 17.9746 0.045561 17.4609ZM0.045561 17.4609C0.0414181 17.4428 0.0379656 17.4246 0.0352439 17.4064C0.0385814 17.4245 0.0422712 17.4423 0.045561 17.4609ZM30.0058 17.4492C30.0071 17.4415 30.0084 17.4337 30.0097 17.426C30.0085 17.4337 30.0072 17.4415 30.0058 17.4492ZM4.99103 26.51C4.96674 26.4905 4.94348 26.4712 4.92149 26.4517L4.99103 26.51ZM4.99103 26.51C5.925 27.2536 8.60587 27.8751 12.2925 28.0956C17.8319 28.4256 23.5463 27.7251 25.0556 26.5286C25.0583 26.5265 25.061 26.5244 25.0636 26.5223L24.3938 27.0884C23.2187 28.0069 22.4421 28.4062 21.4587 28.9124C21.1678 29.0473 20.7729 29.2108 20.4778 29.3351C19.1007 29.8594 16.2024 30.1364 13.3806 29.9677C11.7136 29.8677 10.3882 29.632 9.59291 29.331C8.87794 29.0455 8.79946 29.0057 7.83876 28.5171C6.91988 27.9982 6.7843 27.8992 5.73083 27.1317L4.99103 26.51ZM25.0636 26.5223L25.1293 26.4669C25.109 26.4851 25.0867 26.5043 25.0636 26.5223ZM24.5539 3.23958C24.9932 3.59312 25.018 3.97504 24.5411 4.35241C23.1081 5.48738 17.6806 6.15388 12.4195 5.83999C7.18209 5.5271 4.08325 4.3611 5.47197 3.23005L5.64889 3.08786C6.87177 2.14553 7.51627 1.81302 8.58366 1.26382C9.22483 0.968483 9.40031 0.900301 10.1065 0.647417C9.89518 0.730196 9.72456 0.819108 9.605 0.915035C8.79062 1.55997 10.5522 2.22662 13.54 2.40516C16.5276 2.58319 19.6101 2.20556 20.4252 1.56034C20.8352 1.23533 20.589 0.904881 19.8634 0.633557C20.4348 0.830615 20.6321 0.916665 21.3318 1.22133C22.2102 1.62645 22.7484 1.97233 23.5631 2.49539C23.8679 2.70793 24.2581 3.01474 24.5539 3.23958Z"
fill="url(#paint0_linear_7502_1806)"
fillOpacity="0.32"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_7502_1806"
x1="23.9069"
y1="2.74376"
x2="5.97898"
y2="27.3127"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" stopOpacity="0" />
<stop offset="1" stopColor="#2358E0" />
</linearGradient>
<clipPath id="clip0_7502_1806">
<rect width="30" height="30" fill="white" />
</clipPath>
</defs>
</svg>
)
}

View File

@@ -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<typeof createPageSchema>
export const PageSection: React.FC = () => {
const { me } = useAccount()
const personalPages = me.root?.personalPages || []
return (
<div className="-ml-2">
<div className="group mb-0.5 ml-2 mt-2 flex flex-row items-center justify-between rounded-md">
<div
role="button"
tabIndex={0}
className="text-muted-foreground hover:bg-muted/50 flex h-6 grow cursor-default items-center justify-between gap-x-0.5 self-start rounded-md px-1 text-xs font-medium"
>
<span className="group-hover:text-muted-foreground">Pages</span>
<CreatePageForm />
</div>
</div>
<div className="relative shrink-0">
<div aria-hidden="false" className="ml-2 shrink-0 pb-2">
{personalPages.map(
page => page && <SidebarItem key={page.id} url={`/pages/${page.id}`} label={page.title} />
)}
</div>
</div>
</div>
)
}
const CreatePageForm: React.FC = () => {
const { me } = useAccount()
const form = useForm<PageFormValues>({
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 (
<Popover>
<PopoverTrigger asChild>
<Button type="button" size="icon" variant="ghost" aria-label="New Page" className="size-6">
<PlusIcon size={16} />
</Button>
</PopoverTrigger>
<PopoverContent align="start">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>New page</FormLabel>
<FormControl>
<Input placeholder="Enter a title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" size="sm" className="w-full">
Create page
</Button>
</form>
</Form>
</PopoverContent>
</Popover>
)
}

View File

@@ -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<string | null>(null)
const sectionRef = useRef<HTMLDivElement>(null)
const learningOptions = [
{ text: "To Learn", icon: <Bookmark size={16} />, color: "text-white/70" },
{
text: "Learning",
icon: <GraduationCap size={16} />,
color: "text-[#D29752]"
},
{
text: "Learned",
icon: <Check size={16} />,
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: <BookOpen size={16} />,
color: "text-white"
},
...learningOptions.filter(option => option.text !== selectedStatus)
]
: learningOptions
// const topicClick = (topic: string) => {
// router.push(`/${topic.toLowerCase()}`)
// }
return (
<div className="space-y-1 overflow-hidden" ref={sectionRef}>
<Button
onClick={() => setShowOptions(!showOptions)}
className="bg-accent text-foreground hover:bg-accent/50 flex w-full items-center justify-between rounded-md px-3 py-2 text-sm font-medium"
>
<span>{selectedStatus ? `Topics: ${selectedStatus}` : "Topics"}</span>
<ChevronDown
size={16}
className={`transform transition-transform duration-200 ease-in-out ${
showOptions ? "rotate-0" : "rotate-[-90deg]"
}`}
/>
</Button>
{showOptions && (
<div className="rounded-md bg-neutral-800">
{availableOptions.map(option => (
<Button
key={option.text}
onClick={() => statusSelect(option.text)}
className={`flex w-full items-center justify-start space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-neutral-700 ${option.color} bg-inherit`}
>
{option.icon && <span className={option.color}>{option.icon}</span>}
<span>{option.text}</span>
</Button>
))}
</div>
)}
<div className="scrollbar-hide space-y-1 overflow-y-auto" style={{ maxHeight: "calc(100vh - 200px)" }}>
{TOPICS.map(topic => (
<SidebarItem key={topic} label={topic} url={`/${topic}`} />
))}
</div>
</div>
)
}
export default TopicSection

View File

@@ -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<React.SetStateAction<boolean>>
}
const SidebarContext = React.createContext<SidebarContextType>({
isCollapsed: false,
setIsCollapsed: () => {}
})
const useSidebarCollapse = (isTablet: boolean): [boolean, React.Dispatch<React.SetStateAction<boolean>>] => {
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<SidebarItemProps> = React.memo(({ label, url, icon, onClick, children }) => {
const pathname = usePathname()
const isActive = pathname === url
return (
<div className={cn("group relative my-0.5 rounded-md", isActive ? "bg-secondary/80" : "hover:bg-secondary/40")}>
<Link
className="text-secondary-foreground flex h-8 grow items-center truncate rounded-md pl-1.5 pr-1 text-sm font-medium"
href={url}
onClick={onClick}
>
{icon && (
<span className={cn("text-primary/60 group-hover:text-primary mr-2 size-4", { "text-primary": isActive })}>
{icon}
</span>
)}
<span>{label}</span>
{children}
</Link>
</div>
)
})
const LogoAndSearch: React.FC = React.memo(() => {
const pathname = usePathname()
return (
<div className="px-3.5">
<div className="mb-1 mt-2 flex h-10 max-w-full items-center">
<Link href="/links" className="px-2">
<Logo className="size-7" />
</Link>
<div className="flex min-w-2 grow flex-row" />
{pathname === "/search" ? (
<Link href="/links">
<Button size="sm" variant="secondary" type="button" className="text-md text-primary/60 font-medium">
Back
</Button>
</Link>
) : (
<Link href="/search">
<Button
size="sm"
variant="secondary"
aria-label="Search"
type="button"
className="text-primary/60 flex w-20 items-center justify-start py-4 pl-2"
>
<SearchIcon size={16} className="mr-2" />
</Button>
</Link>
)}
</div>
</div>
)
})
const SidebarContent: React.FC = React.memo(() => {
const { isCollapsed } = React.useContext(SidebarContext)
const isTablet = useMedia("(max-width: 1024px)")
return (
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
<div className={cn({ "pt-12": !isCollapsed && isTablet })}>
<LogoAndSearch />
</div>
<div tabIndex={-1} className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3.5">
<SidebarItem url="/links" label="Links" icon={<LinkIcon size={16} />} />
<div className="h-2 shrink-0" />
<PageSection />
<TopicSection />
</div>
</nav>
)
})
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 (
<>
<div
className={cn(
"fixed inset-0 z-30 bg-black/40 transition-opacity duration-300",
isCollapsed ? "pointer-events-none opacity-0" : "opacity-100"
)}
onClick={() => setIsCollapsed(true)}
/>
<div
className={cn(
"fixed left-0 top-0 z-40 h-full",
sidebarClasses,
!isCollapsed && "shadow-[4px_0px_16px_rgba(0,0,0,0.1)] transition-all"
)}
>
<div className={cn(sidebarInnerClasses, "border-r-primary/5 border-r")}>
<SidebarContext.Provider value={contextValue}>
<SidebarContent />
</SidebarContext.Provider>
</div>
</div>
</>
)
}
return (
<div className={sidebarClasses}>
<div className={sidebarInnerClasses}>
<SidebarContext.Provider value={contextValue}>
<SidebarContent />
</SidebarContext.Provider>
</div>
</div>
)
}
export default Sidebar

View File

@@ -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 (
<TiptapBubbleMenu
tippyOptions={{
// duration: [0, 999999],
popperOptions: { placement: "top-start" }
}}
editor={editor}
pluginKey="textMenu"
shouldShow={states.shouldShow}
updateDelay={100}
>
<PopoverWrapper className="flex items-center overflow-x-auto p-1">
<div className="space-x-1">
<ToolbarButton value="bold" aria-label="Bold" onPressedChange={commands.onBold} isActive={states.isBold}>
<Icon name="Bold" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="italic" aria-label="Italic" onClick={commands.onItalic}>
<Icon name="Italic" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="strikethrough" aria-label="Strikethrough" onClick={commands.onStrike}>
<Icon name="Strikethrough" strokeWidth={2.5} />
</ToolbarButton>
{/* <ToolbarButton value="link" aria-label="Link">
<Icon name="Link" strokeWidth={2.5} />
</ToolbarButton> */}
<ToolbarButton value="quote" aria-label="Quote" onClick={commands.onCode}>
<Icon name="Quote" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="inline code" aria-label="Inline code" onClick={commands.onCode}>
<Icon name="Braces" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="code block" aria-label="Code block" onClick={commands.onCodeBlock}>
<Icon name="Code" strokeWidth={2.5} />
</ToolbarButton>
{/* <ToolbarButton value="list" aria-label="List">
<Icon name="List" strokeWidth={2.5} />
</ToolbarButton> */}
</div>
</PopoverWrapper>
</TiptapBubbleMenu>
)
}
export default BubbleMenu

View File

@@ -0,0 +1 @@
export * from "./bubble-menu"

View File

@@ -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 <IconComponent className={cn(!size ? "size-4" : size, className)} strokeWidth={strokeWidth || 2} {...props} />
})
Icon.displayName = "Icon"

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type PopoverWrapperProps = React.HTMLProps<HTMLDivElement>
export const PopoverWrapper = React.forwardRef<HTMLDivElement, PopoverWrapperProps>(
({ children, className, ...props }, ref) => {
return (
<div
className={cn("bg-popover text-popover-foreground rounded-lg border shadow-sm", className)}
{...props}
ref={ref}
>
{children}
</div>
)
}
)
PopoverWrapper.displayName = "PopoverWrapper"

View File

@@ -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<HTMLSpanElement> {
ariaLabel: string
}
const ShortcutKeyWrapper = React.forwardRef<HTMLSpanElement, ShortcutKeyWrapperProps>(
({ className, ariaLabel, children, ...props }, ref) => {
return (
<span aria-label={ariaLabel} className={cn("inline-flex items-center gap-0.5", className)} {...props} ref={ref}>
{children}
</span>
)
}
)
ShortcutKeyWrapper.displayName = "ShortcutKeyWrapper"
export interface ShortcutKeyProps extends React.HTMLAttributes<HTMLSpanElement> {
shortcut: string
}
const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ className, shortcut, ...props }, ref) => {
return (
<kbd
className={cn(
"inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]",
className
)}
{...props}
ref={ref}
>
{getShortcutKey(shortcut)}
</kbd>
)
})
ShortcutKey.displayName = "ShortcutKey"
export const Shortcut = {
Wrapper: ShortcutKeyWrapper,
Key: ShortcutKey
}

View File

@@ -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<typeof Toggle> {
isActive?: boolean
tooltip?: string
tooltipOptions?: TooltipContentProps
}
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(function ToolbarButton(
{ isActive, children, tooltip, className, tooltipOptions, ...props },
ref
) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
size="sm"
ref={ref}
className={cn(
"size-7 rounded-md p-0",
{
"bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary": isActive
},
className
)}
{...props}
>
{children}
</Toggle>
</TooltipTrigger>
{tooltip && (
<TooltipContent {...tooltipOptions}>
<div className="flex flex-col items-center text-center">{tooltip}</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
})
ToolbarButton.displayName = "ToolbarButton"
export { ToolbarButton }

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from "./bullet-list"

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from "./code-block-lowlight"

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from "./horizontal-rule"

View File

@@ -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

View File

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

View File

@@ -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 <a> elements that have an href attribute, except for:
* - <a> elements with a data-type attribute set to button
* - <a> 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

View File

@@ -0,0 +1 @@
export * from "./ordered-list"

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from "./slash-command"

View File

@@ -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<HTMLDivElement>(null)
const activeItem = React.useRef<HTMLButtonElement>(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 (
<PopoverWrapper ref={scrollContainer} className="flex max-h-[min(80vh,24rem)] flex-col overflow-auto p-1">
{props.items.map((group, groupIndex: number) => (
<React.Fragment key={group.title}>
{group.commands.map((command: Command, commandIndex: number) => (
<Button
key={command.label}
variant="ghost"
onClick={createCommandClickHandler(groupIndex, commandIndex)}
className={cn("relative w-full justify-between gap-2 px-3.5 py-1.5 font-normal", {
"bg-accent text-accent-foreground":
selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex
})}
>
<Icon name={command.iconName} />
<span className="truncate text-sm">{command.label}</span>
<div className="flex flex-auto flex-row"></div>
<Shortcut.Wrapper ariaLabel={getShortcutKeys(command.shortcuts)}>
{command.shortcuts.map(shortcut => (
<Shortcut.Key shortcut={shortcut} key={shortcut} />
))}
</Shortcut.Wrapper>
</Button>
))}
{groupIndex !== props.items.length - 1 && <Separator className="my-1.5" />}
</React.Fragment>
))}
</PopoverWrapper>
)
})
MenuList.displayName = "MenuList"
export default MenuList

View File

@@ -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

View File

@@ -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
}

View File

@@ -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<BoldOptions> | 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<HardBreakOptions> | false
/**
* If set to false, the history extension will not be registered
* @example history: false
*/
history: Partial<HistoryOptions> | false
/**
* If set to false, the italic extension will not be registered
* @example italic: false
*/
italic: Partial<ItalicOptions> | false
/**
* If set to false, the listItem extension will not be registered
* @example listItem: false
*/
listItem: Partial<ListItemOptions> | false
/**
* If set to false, the strike extension will not be registered
* @example strike: false
*/
strike: Partial<StrikeOptions> | 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<TypographyOptions> | false
/**
* If set to false, the placeholder extension will not be registered
* @example placeholder: false
*/
placeholder: Partial<PlaceholderOptions> | false
/**
* If set to false, the focus extension will not be registered
* @example focus: false
*/
focus: Partial<FocusOptions> | false
}
/**
* The starter kit is a collection of essential editor extensions.
*
* Its a good starting point for building your own editor.
*/
export const StarterKit = Extension.create<StarterKitOptions>({
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

View File

@@ -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<string, any>) => void
extension: Node
}
export const TaskItemView: React.FC<TaskItemProps> = ({ node, updateAttributes, editor, extension }) => {
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<NodeViewWrapper as="li" data-type="taskItem" data-checked={node.attrs.checked}>
<div className="taskItem-checkbox-container">
<Icon name="GripVertical" data-drag-handle className="taskItem-drag-handle" />
<label>
<input type="checkbox" checked={node.attrs.checked} onChange={handleChange} className="taskItem-checkbox" />
</label>
</div>
<div className="taskItem-content">
<NodeViewContent />
</div>
</NodeViewWrapper>
)
}
export default TaskItemView

View File

@@ -0,0 +1 @@
export * from "./task-item"

View File

@@ -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"
})
}
})

View File

@@ -0,0 +1 @@
export * from "./task-list"

View File

@@ -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"
}
}
}
})

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1 @@
export * from "./la-editor"

View File

@@ -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<React.HTMLProps<HTMLDivElement>, "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<LAEditorRef, LAEditorProps>(
(
{
initialContent,
value,
placeholder,
output = "html",
editorClassName,
className,
onUpdate,
onBlur,
onNewBlock,
throttleDelay = 1000,
...props
},
ref
) => {
const [content, setContent] = React.useState<Content | undefined>(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 (
<div className={cn("la-editor relative flex h-full w-full grow flex-col", className)} {...props}>
<EditorContent editor={editor} />
<BubbleMenu editor={editor} />
</div>
)
}
)
LAEditor.displayName = "LAEditor"
export default LAEditor

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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)];
}

View File

@@ -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;
}

View File

@@ -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<any>
shouldHide?: boolean
}
export interface ShouldShowProps {
editor?: CoreEditor
view: EditorView
state?: EditorState
oldState?: EditorState
from?: number
to?: number
}

View File

@@ -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<LinkProps> = ({ title, url }) => (
<div className="mb-1 flex flex-row items-center justify-between rounded-xl bg-[#121212] px-2 py-4 hover:cursor-pointer">
<div className="flex items-center space-x-4">
<p>{title}</p>
<span className="text-md flex flex-row items-center space-x-1 font-medium tracking-wide text-white/20 hover:opacity-50">
<PiLinkSimple size={20} className="text-white/20" />
<a href={url} target="_blank" rel="noopener noreferrer">
{new URL(url).hostname}
</a>
</span>
</div>
</div>
)
interface ButtonProps {
children: React.ReactNode
onClick: () => void
className?: string
color?: string
icon?: React.ReactNode
fullWidth?: boolean
}
const Button: React.FC<ButtonProps> = ({ children, onClick, className = "", color = "", icon, fullWidth = false }) => {
return (
<button
className={`flex items-center justify-start rounded px-3 py-1 text-sm font-medium ${
fullWidth ? "w-full" : ""
} ${className} ${color}`}
onClick={onClick}
>
{icon && <span className="mr-2 flex items-center">{icon}</span>}
<span>{children}</span>
</button>
)
}
export default function GlobalTopic({ topic }: { topic: string }) {
const [showOptions, setShowOptions] = useState(false)
const [selectedOption, setSelectedOption] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState("Guide")
const decodedTopic = decodeURIComponent(topic)
const learningOptions = [
{ text: "To Learn", icon: <Bookmark size={18} /> },
{ text: "Learning", icon: <GraduationCap size={18} /> },
{ text: "Learned", icon: <Check size={18} /> }
]
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 (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<ContentHeader>
<div className="flex w-full items-center justify-between">
<h1 className="text-2xl font-bold">{decodedTopic}</h1>
<div className="flex items-center space-x-4">
<div className="flex rounded-lg bg-neutral-800 bg-opacity-60">
<button
onClick={() => setActiveTab("Guide")}
className={`px-4 py-2 text-[16px] font-semibold transition-colors ${
activeTab === "Guide"
? "rounded-lg bg-neutral-800 shadow-inner shadow-neutral-700/70"
: "text-white/70"
}`}
>
Guide
</button>
<button
onClick={() => setActiveTab("All links")}
className={`px-4 py-2 text-[16px] font-semibold transition-colors ${
activeTab === "All links"
? "rounded-lg bg-neutral-800 shadow-inner shadow-neutral-700/70"
: "text-white/70"
}`}
>
All links
</button>
</div>
</div>
</div>
<div className="relative">
<Button
onClick={() => setShowOptions(!showOptions)}
className="w-[150px] whitespace-nowrap rounded-[7px] bg-neutral-800 px-4 py-2 text-[17px] font-semibold shadow-inner shadow-neutral-700/50 transition-colors hover:bg-neutral-700"
color={learningStatusColor(selectedOption || "")}
icon={selectedOption && learningOptions.find(opt => opt.text === selectedOption)?.icon}
>
{selectedOption || "Add to my profile"}
</Button>
{showOptions && (
<div className="absolute left-1/2 mt-1 w-40 -translate-x-1/2 rounded-lg bg-neutral-800 shadow-lg">
{learningOptions.map(option => (
<Button
key={option.text}
onClick={() => selectedStatus(option.text)}
className="space-x-1 px-2 py-2 text-left text-[14px] font-semibold hover:bg-neutral-700"
color={learningStatusColor(option.text)}
icon={option.icon}
fullWidth
>
{option.text}
</Button>
))}
</div>
)}
</div>
</ContentHeader>
<div className="px-5 py-3">
<h2 className="mb-3 text-white/60">Intro</h2>
{links.map((link, index) => (
<LinkItem key={index} title={link.title} url={link.url} />
))}
</div>
<div className="px-5 py-3">
<h2 className="mb-3 text-opacity-60">Other</h2>
{links.map((link, index) => (
<LinkItem key={index} title={link.title} url={link.url} />
))}
</div>
<div className="flex-1 overflow-auto p-4"></div>
</div>
)
}

View File

@@ -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<typeof createLinkSchema>
const DEFAULT_FORM_VALUES: Partial<LinkFormValues> = {
title: "",
description: "",
topic: "",
isLink: false,
meta: null
}
const LinkManage: React.FC = () => {
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
const [, setEditId] = useAtom(linkEditIdAtom)
const formRef = useRef<HTMLFormElement>(null)
const buttonRef = useRef<HTMLButtonElement>(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 && (
<div className="z-50">
<LinkForm ref={formRef} onSuccess={() => setShowCreate(false)} onCancel={() => setShowCreate(false)} />
</div>
)}
<CreateButton ref={buttonRef} onClick={toggleForm} isOpen={showCreate} />
</>
)
}
const CreateButton = React.forwardRef<
HTMLButtonElement,
{
onClick: (event: React.MouseEvent) => void
isOpen: boolean
}
>(({ onClick, isOpen }, ref) => (
<Button
ref={ref}
className={cn(
"absolute bottom-4 right-4 size-12 rounded-full bg-[#274079] p-0 text-white transition-transform hover:bg-[#274079]/90",
{ "rotate-45 transform": isOpen }
)}
onClick={onClick}
>
<PlusIcon className="size-6" />
</Button>
))
CreateButton.displayName = "CreateButton"
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
onSuccess?: () => void
onCancel?: () => void
personalLink?: PersonalLink
}
const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess, onCancel, personalLink }, ref) => {
const selectedLink = useCoState(PersonalLink, personalLink?.id)
const [isFetching, setIsFetching] = useState(false)
const { me } = useAccount()
const form = useForm<LinkFormValues>({
resolver: zodResolver(createLinkSchema),
defaultValues: DEFAULT_FORM_VALUES
})
const title = form.watch("title")
const [originalLink, setOriginalLink] = useState<string>("")
const [linkEntered, setLinkEntered] = useState(false)
const [debouncedText, setDebouncedText] = useState<string>("")
useDebounce(() => setDebouncedText(title), 300, [title])
const [showStatusOptions, setShowStatusOptions] = useState(false)
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
const statusOptions = [
{
text: "To Learn",
icon: <Bookmark size={16} />,
color: "text-white/70"
},
{
text: "Learning",
icon: <GraduationCap size={16} />,
color: "text-[#D29752]"
},
{ text: "Learned", icon: <Check size={16} />, 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<HTMLInputElement>) => {
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 (
<div className="p-3 transition-all">
<div className="bg-muted/50 rounded-md border">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1" ref={ref}>
<div className="flex flex-row p-3">
<div className="flex flex-auto flex-col gap-1.5">
<div className="flex flex-row items-start justify-between">
<div className="flex grow flex-row items-center gap-1.5">
<Button
type="button"
variant="secondary"
size="icon"
aria-label="Choose icon"
className="text-primary/60 size-7"
>
{form.watch("isLink") ? (
<Image
src={form.watch("meta")?.favicon || ""}
alt={form.watch("meta")?.title || ""}
className="size-5 rounded-md"
width={16}
height={16}
/>
) : (
<BoxIcon size={16} />
)}
</Button>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="grow space-y-0">
<FormLabel className="sr-only">Text</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
maxLength={100}
autoFocus
placeholder="Paste a link or write a link"
className="placeholder:text-primary/40 h-6 border-none p-1.5 font-medium focus-visible:outline-none focus-visible:ring-0"
onKeyDown={handleKeyDown}
/>
</FormControl>
</FormItem>
)}
/>
<span className="mr-5 max-w-[200px] truncate text-xs text-white/60">
{linkEntered
? originalLink
: LibIsUrl(form.watch("title").toLowerCase())
? 'Press "Enter" to confirm URL'
: ""}
</span>
</div>
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* <Button
size="icon"
type="button"
variant="ghost"
className="size-7 gap-x-2 text-sm"
>
<EllipsisIcon
size={16}
className="text-primary/60"
/>
</Button> */}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem className="group">
<Trash2Icon size={16} className="text-destructive mr-2 group-hover:text-red-500" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="relative">
<Button
size="icon"
type="button"
variant="ghost"
className="size-7 gap-x-2 text-sm"
onClick={() => setShowStatusOptions(!showStatusOptions)}
>
{selectedStatus ? (
(() => {
const option = statusOptions.find(opt => opt.text === selectedStatus)
return option
? React.cloneElement(option.icon, {
size: 16,
className: option.color
})
: null
})()
) : (
<PieChartIcon size={16} className="text-primary/60" />
)}
</Button>
{showStatusOptions && (
<div className="absolute right-0 mt-1 w-40 rounded-md bg-neutral-800 shadow-lg">
{statusOptions.map(option => (
<Button
key={option.text}
onClick={() => statusSelect(option.text)}
className={`flex w-full items-center justify-start space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-neutral-700 ${option.color} bg-inherit`}
>
{React.cloneElement(option.icon, {
size: 16,
className: option.color
})}
<span>{option.text}</span>
</Button>
))}
</div>
)}
</div>
</div>
</div>
<div className="flex flex-row items-center gap-1.5 pl-8">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="grow space-y-0">
<FormLabel className="sr-only">Description</FormLabel>
<FormControl>
<Textarea
{...field}
autoComplete="off"
placeholder="Description (optional)"
className="placeholder:text-primary/40 min-h-[24px] resize-none overflow-y-auto border-none p-1.5 text-xs font-medium shadow-none focus-visible:outline-none focus-visible:ring-0"
onInput={e => {
const target = e.target as HTMLTextAreaElement
target.style.height = "auto"
target.style.height = `${target.scrollHeight}px`
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</div>
<div className="flex flex-auto flex-row items-center justify-between gap-2 rounded-b-md border border-t px-3 py-2">
<div className="flex flex-row items-center gap-0.5">
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
<TopicSelector />
</div>
</div>
<div className="flex w-auto items-center justify-end">
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row gap-x-2">
<Button size="sm" type="button" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" disabled={isFetching}>
Save
</Button>
</div>
</div>
</div>
</form>
</Form>
</div>
</div>
)
})
LinkManage.displayName = "LinkManage"
LinkForm.displayName = "LinkForm"
export { LinkManage, LinkForm }

View File

@@ -0,0 +1,74 @@
import { Button } from "@/components/ui/button"
import { Command, CommandInput, CommandList, CommandItem } from "@/components/ui/command"
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { useState } from "react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
import { useFormContext } from "react-hook-form"
import { LinkFormValues } from "../manage"
import { cn } from "@/lib/utils"
const TOPICS = [
{ id: "1", name: "Work" },
{ id: "2", name: "Personal" }
]
export const TopicSelector: React.FC = () => {
const form = useFormContext<LinkFormValues>()
const [open, setOpen] = useState(false)
const { setValue } = useFormContext()
return (
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel className="sr-only">Topic</FormLabel>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button size="sm" type="button" role="combobox" variant="secondary" className="!mt-0 gap-x-2 text-sm">
<span className="truncate">
{field.value ? TOPICS.find(topic => topic.name === field.value)?.name : "Select topic"}
</span>
<ChevronDownIcon size={16} />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-52 rounded-lg p-0" side="right" align="start">
<Command>
<CommandInput placeholder="Search topic..." className="h-9" />
<CommandList>
<ScrollArea>
{TOPICS.map(topic => (
<CommandItem
className="cursor-pointer"
key={topic.id}
value={topic.name}
onSelect={value => {
setValue("topic", value)
setOpen(false)
}}
>
{topic.name}
<CheckIcon
size={16}
className={cn(
"absolute right-3",
topic.name === field.value ? "text-primary" : "text-transparent"
)}
/>
</CommandItem>
))}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,24 @@
import { z } from "zod"
export const createLinkSchema = z.object({
title: z
.string({
message: "Please enter a valid title"
})
.min(1, {
message: "Please enter a valid title"
}),
description: z.string().optional(),
topic: z.string().optional(),
isLink: z.boolean().default(false),
meta: z
.object({
url: z.string(),
title: z.string(),
favicon: z.string(),
description: z.string().optional().nullable()
})
.optional()
.nullable(),
completed: z.boolean().default(false)
})

View File

@@ -0,0 +1,126 @@
"use client"
import * as React from "react"
import { ListFilterIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { useMedia } from "react-use"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useAtom } from "jotai"
import { linkSortAtom } from "@/store/link"
interface TabItemProps {
url: string
label: string
}
const TABS = ["All", "Learning", "To Learn", "Learned"]
export const LinkHeader = () => {
const isTablet = useMedia("(max-width: 1024px)")
return (
<>
<ContentHeader className="p-4">
{/* Toggle and Title */}
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">
<span className="truncate text-left text-xl font-bold">Links</span>
</div>
</div>
{!isTablet && <Tabs />}
<div className="flex flex-auto"></div>
<FilterAndSort />
</ContentHeader>
{isTablet && (
<div className="border-b-primary/5 flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
<Tabs />
</div>
)}
</>
)
}
const Tabs = () => {
return (
<div className="bg-secondary/50 flex items-baseline overflow-x-hidden rounded-md">
{TABS.map(tab => (
<TabItem key={tab} url="#" label={tab} />
))}
</div>
)
}
const TabItem = ({ url, label }: TabItemProps) => {
const [isActive, setIsActive] = React.useState(false)
return (
<div tabIndex={-1} className="rounded-md">
<div className="flex flex-row">
<div aria-label={label}>
<Link href={url}>
<Button
size="sm"
type="button"
variant="ghost"
className={`gap-x-2 truncate text-sm ${isActive ? "bg-accent text-accent-foreground" : ""}`}
onClick={() => setIsActive(true)}
onBlur={() => setIsActive(false)}
>
{label}
</Button>
</Link>
</div>
</div>
</div>
)
}
const FilterAndSort = () => {
const [sort, setSort] = useAtom(linkSortAtom)
const getFilterText = () => {
return sort.charAt(0).toUpperCase() + sort.slice(1)
}
return (
<div className="flex w-auto items-center justify-end">
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button size="sm" type="button" variant="secondary" className="gap-x-2 text-sm">
<ListFilterIcon size={16} className="text-primary/60" />
<span className="hidden md:block">Filter: {getFilterText()}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="flex flex-col">
<div className="flex min-w-8 flex-row items-center">
<Label>Sort by</Label>
<div className="flex flex-auto flex-row items-center justify-end">
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="h-6 w-auto">
<SelectValue placeholder="Select"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="title">Title</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)
}

View File

@@ -0,0 +1,191 @@
"use client"
import * as React from "react"
import { Checkbox } from "@/components/ui/checkbox"
import { LinkIcon, Trash2Icon } from "lucide-react"
import Link from "next/link"
import Image from "next/image"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { PersonalLink } from "@/lib/schema/personal-link"
import { cn } from "@/lib/utils"
import { LinkForm } from "./form/manage"
import { Button } from "@/components/ui/button"
import { ConfirmOptions } from "@omit/react-confirm-dialog"
import { Badge } from "@/components/ui/badge"
interface ListItemProps {
confirm: (options: ConfirmOptions) => Promise<boolean>
personalLink: PersonalLink
disabled?: boolean
isEditing: boolean
setEditId: (id: string | null) => void
isDragging: boolean
isFocused: boolean
setFocusedId: (id: string | null) => void
registerRef: (id: string, ref: HTMLLIElement | null) => void
onDelete?: (personalLink: PersonalLink) => void
}
export const ListItem: React.FC<ListItemProps> = ({
confirm,
isEditing,
setEditId,
personalLink,
disabled = false,
isDragging,
isFocused,
setFocusedId,
registerRef,
onDelete
}) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
const formRef = React.useRef<HTMLFormElement>(null)
const style = {
transform: CSS.Transform.toString(transform),
transition,
pointerEvents: isDragging ? "none" : "auto"
}
React.useEffect(() => {
if (isEditing) {
formRef.current?.focus()
}
}, [isEditing])
const refCallback = React.useCallback(
(node: HTMLLIElement | null) => {
setNodeRef(node)
registerRef(personalLink.id, node)
},
[setNodeRef, registerRef, personalLink.id]
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
setEditId(personalLink.id)
}
}
const handleSuccess = () => {
setEditId(null)
}
const handleCancel = () => {
setEditId(null)
}
const handleRowClick = () => {
console.log("Row clicked", personalLink.id)
setEditId(personalLink.id)
}
const handleDelete = async (e: React.MouseEvent, personalLink: PersonalLink) => {
e.stopPropagation()
const result = await confirm({
title: `Delete "${personalLink.title}"?`,
description: "This action cannot be undone.",
alertDialogTitle: {
className: "text-base"
},
customActions: (onConfirm, onCancel) => (
<>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete
</Button>
</>
)
})
if (result) {
onDelete?.(personalLink)
}
}
if (isEditing) {
return <LinkForm ref={formRef} personalLink={personalLink} onSuccess={handleSuccess} onCancel={handleCancel} />
}
return (
<li
ref={refCallback}
style={style as React.CSSProperties}
{...attributes}
{...listeners}
tabIndex={0}
onFocus={() => setFocusedId(personalLink.id)}
onBlur={() => setFocusedId(null)}
onKeyDown={handleKeyDown}
className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11", {
"bg-muted/50": isFocused
})}
onClick={handleRowClick}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
<Checkbox
checked={personalLink.completed}
onClick={e => e.stopPropagation()}
onCheckedChange={() => {
personalLink.completed = !personalLink.completed
}}
className="border-muted-foreground border"
/>
{personalLink.isLink && personalLink.meta && (
<Image
src={personalLink.meta.favicon}
alt={personalLink.title}
className="size-5 rounded-full"
width={16}
height={16}
/>
)}
<div className="w-full min-w-0 flex-auto">
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate">
{personalLink.title}
</p>
{personalLink.isLink && personalLink.meta && (
<div className="group flex items-center gap-x-1">
<LinkIcon
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary size-3 flex-none"
/>
<Link
href={personalLink.meta.url}
passHref
prefetch={false}
target="_blank"
onClick={e => {
e.stopPropagation()
}}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="xl:truncate">{personalLink.meta.url}</span>
</Link>
</div>
)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-x-4">
<Badge variant="secondary">Topic Name</Badge>
<Button
size="icon"
className="text-destructive h-auto w-auto bg-transparent hover:bg-transparent hover:text-red-500"
onClick={e => handleDelete(e, personalLink)}
>
<Trash2Icon size={16} />
</Button>
</div>
</div>
</li>
)
}

View File

@@ -0,0 +1,232 @@
"use client"
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent
} from "@dnd-kit/core"
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { useAccount } from "@/lib/providers/jazz-provider"
import { PersonalLinkLists } from "@/lib/schema/personal-link"
import { PersonalLink } from "@/lib/schema/personal-link"
import { useAtom } from "jotai"
import { linkEditIdAtom, linkSortAtom } from "@/store/link"
import { useKey } from "react-use"
import { useConfirm } from "@omit/react-confirm-dialog"
import { ListItem } from "./list-item"
import { useRef, useState, useCallback, useEffect } from "react"
const LinkList = () => {
const confirm = useConfirm()
const { me } = useAccount({
root: { personalLinks: [] }
})
const personalLinks = me?.root?.personalLinks || []
const [editId, setEditId] = useAtom(linkEditIdAtom)
const [sort] = useAtom(linkSortAtom)
const [focusedId, setFocusedId] = useState<string | null>(null)
const [draggingId, setDraggingId] = useState<string | null>(null)
const linkRefs = useRef<{ [key: string]: HTMLLIElement | null }>({})
let sortedLinks =
sort === "title" && personalLinks
? [...personalLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
: personalLinks
sortedLinks = sortedLinks || []
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8
}
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
)
const overlayClick = () => {
setEditId(null)
}
const registerRef = useCallback((id: string, ref: HTMLLIElement | null) => {
linkRefs.current[id] = ref
}, [])
useKey("Escape", () => {
if (editId) {
setEditId(null)
}
})
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
const currentIndex = sortedLinks.findIndex(link => link?.id === focusedId)
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
const newIndex =
e.key === "ArrowUp" ? Math.max(0, currentIndex - 1) : Math.min(sortedLinks.length - 1, currentIndex + 1)
if (e.metaKey && sort === "manual") {
const currentLink = me.root.personalLinks[currentIndex]
if (!currentLink) return
const linksArray = [...me.root.personalLinks]
const newLinks = arrayMove(linksArray, currentIndex, newIndex)
while (me.root.personalLinks.length > 0) {
me.root.personalLinks.pop()
}
newLinks.forEach(link => {
if (link) {
me.root.personalLinks.push(link)
}
})
updateSequences(me.root.personalLinks)
const newFocusedLink = me.root.personalLinks[newIndex]
if (newFocusedLink) {
setFocusedId(newFocusedLink.id)
requestAnimationFrame(() => {
linkRefs.current[newFocusedLink.id]?.focus()
})
}
} else {
const newFocusedLink = sortedLinks[newIndex]
if (newFocusedLink) {
setFocusedId(newFocusedLink.id)
requestAnimationFrame(() => {
linkRefs.current[newFocusedLink.id]?.focus()
})
}
}
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [me?.root?.personalLinks, sortedLinks, focusedId, editId, sort])
const updateSequences = (links: PersonalLinkLists) => {
links.forEach((link, index) => {
if (link) {
link.sequence = index
}
})
}
const handleDragStart = (event: any) => {
if (sort !== "manual") return
const { active } = event
setDraggingId(active.id)
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!active || !over || !me?.root?.personalLinks) {
console.error("Drag operation fail", { active, over })
return
}
const oldIndex = me.root.personalLinks.findIndex(item => item?.id === active.id)
const newIndex = me.root.personalLinks.findIndex(item => item?.id === over.id)
if (oldIndex === -1 || newIndex === -1) {
console.error("Drag operation fail", {
oldIndex,
newIndex,
activeId: active.id,
overId: over.id
})
return
}
if (oldIndex !== newIndex) {
try {
const personalLinksArray = [...me.root.personalLinks]
const updatedLinks = arrayMove(personalLinksArray, oldIndex, newIndex)
while (me.root.personalLinks.length > 0) {
me.root.personalLinks.pop()
}
updatedLinks.forEach(link => {
if (link) {
me.root.personalLinks.push(link)
}
})
updateSequences(me.root.personalLinks)
} catch (error) {
console.error("Error during link reordering:", error)
}
}
setDraggingId(null)
}
const handleDelete = (linkItem: PersonalLink) => {
if (!me?.root?.personalLinks) return
const index = me.root.personalLinks.findIndex(item => item?.id === linkItem.id)
if (index === -1) {
console.error("Delete operation fail", { index, linkItem })
return
}
me.root.personalLinks.splice(index, 1)
}
return (
<>
{editId && <div className="fixed inset-0 z-10" onClick={overlayClick} />}
<div className="relative z-20">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
<ul role="list" className="divide-primary/5 divide-y">
{sortedLinks.map(
linkItem =>
linkItem && (
<ListItem
key={linkItem.id}
confirm={confirm}
isEditing={editId === linkItem.id}
setEditId={setEditId}
personalLink={linkItem}
disabled={sort !== "manual" || editId !== null}
registerRef={registerRef}
isDragging={draggingId === linkItem.id}
isFocused={focusedId === linkItem.id}
setFocusedId={setFocusedId}
onDelete={handleDelete}
/>
)
)}
</ul>
</SortableContext>
</DndContext>
</div>
</>
)
}
LinkList.displayName = "LinkList"
export { LinkList }

View File

@@ -0,0 +1,19 @@
"use client"
import { LinkHeader } from "@/components/routes/link/header"
import { LinkList } from "@/components/routes/link/list"
import { LinkManage } from "@/components/routes/link/form/manage"
import { useAtom } from "jotai"
import { linkEditIdAtom } from "@/store/link"
export function LinkWrapper() {
const [editId] = useAtom(linkEditIdAtom)
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<LinkHeader />
<LinkManage />
<LinkList key={editId} />
</div>
)
}

View File

@@ -0,0 +1,34 @@
"use client"
import * as React from "react"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb"
import { useCoState } from "@/lib/providers/jazz-provider"
import { PersonalPage } from "@/lib/schema/personal-page"
import { ID } from "jazz-tools"
export const DetailPageHeader = ({ pageId }: { pageId: ID<PersonalPage> }) => {
const page = useCoState(PersonalPage, pageId)
return (
<ContentHeader>
<div className="flex min-w-0 gap-2">
<SidebarToggleButton />
<Breadcrumb className="flex flex-row items-center">
<BreadcrumbList className="sm:gap-2">
<BreadcrumbItem>
<BreadcrumbPage className="text-foreground font-medium">Pages</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</ContentHeader>
)
}

View File

@@ -0,0 +1,139 @@
"use client"
import React, { useEffect, useRef } from "react"
import { LAEditor, LAEditorRef } from "@/components/la-editor"
import { DetailPageHeader } from "./header"
import { ID } from "jazz-tools"
import { PersonalPage } from "@/lib/schema/personal-page"
import { Content, EditorContent, useEditor } from "@tiptap/react"
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
import { Paragraph } from "@/components/la-editor/extensions/paragraph"
import { useCoState } from "@/lib/providers/jazz-provider"
import { toast } from "sonner"
import { EditorView } from "prosemirror-view"
const configureStarterKit = () =>
StarterKit.configure({
bold: false,
italic: false,
typography: false,
hardBreak: false,
listItem: false,
strike: false,
focus: false,
gapcursor: false,
history: false,
placeholder: {
placeholder: "Page title"
}
})
const editorProps = {
attributes: {
spellcheck: "true",
role: "textbox",
"aria-readonly": "false",
"aria-multiline": "false",
"aria-label": "Page title",
translate: "no"
}
}
export function DetailPageWrapper({ pageId }: { pageId: string }) {
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
const contentEditorRef = useRef<LAEditorRef>(null)
const handleKeyDown = (view: EditorView, event: KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
contentEditorRef.current?.focus()
return true
}
return false
}
const handleTitleBlur = (title: string) => {
if (page && editor) {
if (!title) {
toast.error("Update failed", {
description: "Title must be longer than or equal to 1 character"
})
// https://github.com/ueberdosis/tiptap/issues/3764
setTimeout(() => {
editor.commands.setContent(`<p>${page.title}</p>`)
})
} else {
page.title = title
}
}
}
const editor = useEditor({
extensions: [configureStarterKit(), Paragraph],
editorProps: {
...editorProps,
handleKeyDown: handleKeyDown as unknown as (view: EditorView, event: KeyboardEvent) => boolean | void
},
onBlur: ({ editor }) => handleTitleBlur(editor.getText())
})
const handleContentUpdate = (content: Content) => {
console.log("content", content)
}
const updatePageContent = (content: Content) => {
if (page) {
page.content = content
}
}
useEffect(() => {
if (page && editor) {
setTimeout(() => {
editor.commands.setContent(`<p>${page.title}</p>`)
})
}
}, [page, editor])
if (!editor) {
return null
}
return (
<div className="flex flex-row">
<div className="flex h-full w-full">
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
<DetailPageHeader pageId={pageId as ID<PersonalPage>} />
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
<form className="flex shrink-0 flex-col">
<div className="mb-2 mt-8 py-1.5">
<EditorContent
editor={editor}
className="la-editor cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
/>
</div>
<div className="flex flex-auto flex-col">
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
<LAEditor
ref={contentEditorRef}
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto"
initialContent={page?.content}
placeholder="Add content..."
output="json"
throttleDelay={3000}
onUpdate={handleContentUpdate}
onBlur={updatePageContent}
onNewBlock={updatePageContent}
/>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { ContentHeader } from "@/components/custom/content-header"
import { Input } from "@/components/ui/input"
export const SearchHeader = () => {
return (
<ContentHeader title="Search">
<Input placeholder="Search something..." />
</ContentHeader>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import { useState } from "react"
// import { useAccount } from "@/lib/providers/jazz-provider"
import { IoSearch, IoCloseOutline, IoChevronForward } from "react-icons/io5"
import AiSearch from "../../custom/ai-search"
interface ProfileTopicsProps {
topic: string
}
const ProfileTopics: React.FC<ProfileTopicsProps> = ({ topic }) => {
return (
<div className="flex cursor-pointer flex-row items-center justify-between rounded-lg bg-[#121212] p-3">
<p>{topic}</p>
<IoChevronForward className="text-white" size={20} />
</div>
)
}
interface ProfileLinksProps {
linklabel: string
link: string
topic: string
}
interface ProfileTitleProps {
topicTitle: string
spanNumber: number
}
const ProfileTitle: React.FC<ProfileTitleProps> = ({ topicTitle, spanNumber }) => {
return (
<p className="pb-3 pl-2 text-base font-light text-white/50">
{topicTitle} <span className="text-white">{spanNumber}</span>
</p>
)
}
const ProfileLinks: React.FC<ProfileLinksProps> = ({ linklabel, link, topic }) => {
return (
<div className="flex flex-row items-center justify-between rounded-lg bg-[#121212] p-3 text-white">
<div className="flex flex-row items-center space-x-3">
<p className="text-base text-white">{linklabel}</p>
<div className="flex cursor-pointer flex-row items-center gap-1">
<p className="text-md text-white/10 transition-colors duration-300 hover:text-white/30">{link}</p>
</div>
</div>
<div className="cursor-default rounded-lg bg-[#1a1a1a] p-2 text-white/60">{topic}</div>
</div>
)
}
export const SearchWrapper = () => {
// const account = useAccount()
const [searchText, setSearchText] = useState("")
const [aiSearch, setAiSearch] = useState("")
const [showAiSearch, setShowAiSearch] = useState(false)
const [showAiPlaceholder, setShowAiPlaceholder] = useState(false)
const inputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value)
if (e.target.value.trim() !== "") {
setShowAiPlaceholder(false)
setTimeout(() => setShowAiPlaceholder(true), 1000)
} else {
setShowAiPlaceholder(false)
setShowAiSearch(false)
}
}
const clearSearch = () => {
setSearchText("")
setShowAiSearch(false)
setShowAiPlaceholder(false)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && searchText.trim() !== "") {
setShowAiSearch(true)
setAiSearch(searchText)
}
}
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<div className="flex h-full w-full justify-center overflow-hidden">
<div className="w-full max-w-3xl px-4 sm:px-6 lg:px-8">
<div className="relative mb-2 mt-5 flex w-full flex-row items-center transition-colors duration-300 hover:text-white/60">
<IoSearch className="absolute left-3 text-white/30" size={20} />
<input
type="text"
autoFocus
value={searchText}
onChange={inputChange}
onKeyDown={handleKeyDown}
className="w-full rounded-[10px] bg-[#16181d] p-10 py-3 pl-10 pr-3 font-semibold tracking-wider text-white outline-none placeholder:font-light placeholder:text-white/30"
placeholder="Search..."
/>
{showAiPlaceholder && searchText && !showAiSearch && (
<div className="absolute right-10 text-sm text-white/30">press &quot;Enter&quot; for AI search</div>
)}
{searchText && (
<IoCloseOutline className="absolute right-3 cursor-pointer opacity-30" size={20} onClick={clearSearch} />
)}
</div>
{showAiSearch ? (
<div className="relative w-full">
<div className="absolute left-1/2 w-[110%] -translate-x-1/2">
<AiSearch searchQuery={searchText} />
</div>
</div>
) : (
<>
<div className="my-5 space-y-1">
<ProfileTitle topicTitle="Topics" spanNumber={1} />
<ProfileTopics topic="Figma" />
</div>
<div className="my-5 space-y-1">
<ProfileTitle topicTitle="Links" spanNumber={3} />
<ProfileLinks linklabel="Figma" link="https://figma.com" topic="Figma" />
<ProfileLinks linklabel="Figma" link="https://figma.com" topic="Figma" />
<ProfileLinks linklabel="Figma" link="https://figma.com" topic="Figma" />
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
"use client"
import * as React from "react"
import { Button } from "./button"
import { PieChartIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const LearningTodoStatus = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
statusOptions: Array<{
text: string
icon: React.ReactElement
color: string
}>
selectedStatus: string | null
setSelectedStatus: (status: string) => void
}
>(({ className, statusOptions, selectedStatus, setSelectedStatus, ...props }, ref) => {
const [showStatusOptions, setShowStatusOptions] = React.useState(false)
return (
<div ref={ref} className={cn("relative", className)} {...props}>
<Button
size="icon"
type="button"
variant="ghost"
className="size-7 gap-x-2 text-sm"
onClick={() => setShowStatusOptions(!showStatusOptions)}
>
{selectedStatus ? (
(() => {
const option = statusOptions.find(opt => opt.text === selectedStatus)
return option
? React.cloneElement(option.icon, {
size: 16,
className: option.color
})
: null
})()
) : (
<PieChartIcon size={16} className="text-primary/60" />
)}
</Button>
{showStatusOptions && (
<div className="absolute right-0 mt-1 w-40 rounded-md bg-neutral-800 shadow-lg">
{statusOptions.map(option => (
<Button
key={option.text}
onClick={() => {
setSelectedStatus(option.text)
setShowStatusOptions(false)
}}
className={`flex w-full items-center justify-start space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-neutral-700 ${option.color} bg-inherit`}
>
{React.cloneElement(option.icon, {
size: 16,
className: option.color
})}
<span>{option.text}</span>
</Button>
))}
</div>
)}
</div>
)
})
LearningTodoStatus.displayName = "LearningTodo"
export { LearningTodoStatus }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground"
}
},
defaultVariants: {
variant: "default"
}
}
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,90 @@
import * as React from "react"
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5",
className
)}
{...props}
/>
)
)
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
)
)
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return <Comp ref={ref} className={cn("hover:text-foreground transition-colors", className)} {...props} />
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
)
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
{children ?? <ChevronRightIcon />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis
}

Some files were not shown because too many files have changed in this diff Show More