mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 22:40:32 +01:00
Setup (#112)
* 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:
49
.gitignore
vendored
49
.gitignore
vendored
@@ -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-*
|
||||
|
||||
12
cli/run.ts
Normal file
12
cli/run.ts
Normal 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
59
cli/seed.ts
Normal 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
7
lib/utils.ts
Normal 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
21
license
Normal 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.
|
||||
19
package.json
19
package.json
@@ -3,20 +3,29 @@
|
||||
"scripts": {
|
||||
"dev": "bun web",
|
||||
"web": "cd web && bun dev",
|
||||
"web:build": "bun run --filter '*' build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.1.18"
|
||||
"web:build": "bun run --filter '*' build",
|
||||
"cli": "bun run --watch cli/run.ts",
|
||||
"seed": "bun --watch cli/seed.ts"
|
||||
},
|
||||
"workspaces": [
|
||||
"web"
|
||||
],
|
||||
"dependencies": {
|
||||
"jazz-nodejs": "^0.7.23",
|
||||
"react-icons": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.1.21"
|
||||
},
|
||||
"prettier": {
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"useTabs": true,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 120,
|
||||
"arrowParens": "avoid"
|
||||
},
|
||||
"private": true
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
2
web/.env.example
Normal file
2
web/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_APP_NAME="Learn Anything"
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
15
web/app/(pages)/layout.tsx
Normal file
15
web/app/(pages)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
web/app/(pages)/links/page.tsx
Normal file
5
web/app/(pages)/links/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LinkWrapper } from "@/components/routes/link/wrapper"
|
||||
|
||||
export default function LinkPage() {
|
||||
return <LinkWrapper />
|
||||
}
|
||||
5
web/app/(pages)/pages/[id]/page.tsx
Normal file
5
web/app/(pages)/pages/[id]/page.tsx
Normal 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} />
|
||||
}
|
||||
14
web/app/(pages)/profile/_components/wrapper.tsx
Normal file
14
web/app/(pages)/profile/_components/wrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
web/app/(pages)/profile/page.tsx
Normal file
5
web/app/(pages)/profile/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProfileWrapper } from "./_components/wrapper"
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfileWrapper />
|
||||
}
|
||||
5
web/app/(pages)/search/page.tsx
Normal file
5
web/app/(pages)/search/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SearchWrapper } from "@/components/routes/search/wrapper"
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <SearchWrapper />
|
||||
}
|
||||
14
web/app/(topics)/[topic]/layout.tsx
Normal file
14
web/app/(topics)/[topic]/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
web/app/(topics)/[topic]/page.tsx
Normal file
5
web/app/(topics)/[topic]/page.tsx
Normal 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} />
|
||||
}
|
||||
101
web/app/api/metadata/route.test.ts
Normal file
101
web/app/api/metadata/route.test.ts
Normal 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"
|
||||
})
|
||||
})
|
||||
})
|
||||
63
web/app/api/metadata/route.ts
Normal file
63
web/app/api/metadata/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
43
web/app/api/search-stream/route.ts
Normal file
43
web/app/api/search-stream/route.ts
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
17
web/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
88
web/components/custom/ai-search.tsx
Normal file
88
web/components/custom/ai-search.tsx
Normal 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
|
||||
61
web/components/custom/content-header.tsx
Normal file
61
web/components/custom/content-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
web/components/custom/demo-auth.tsx
Normal file
166
web/components/custom/demo-auth.tsx
Normal 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 "{user}"
|
||||
</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
|
||||
}
|
||||
57
web/components/custom/logo.tsx
Normal file
57
web/components/custom/logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
web/components/custom/sidebar/partial/page-section.tsx
Normal file
114
web/components/custom/sidebar/partial/page-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
web/components/custom/sidebar/partial/topic-section.tsx
Normal file
100
web/components/custom/sidebar/partial/topic-section.tsx
Normal 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
|
||||
179
web/components/custom/sidebar/sidebar.tsx
Normal file
179
web/components/custom/sidebar/sidebar.tsx
Normal 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
|
||||
@@ -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
|
||||
1
web/components/la-editor/components/bubble-menu/index.ts
Normal file
1
web/components/la-editor/components/bubble-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./bubble-menu"
|
||||
22
web/components/la-editor/components/ui/icon.tsx
Normal file
22
web/components/la-editor/components/ui/icon.tsx
Normal 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"
|
||||
20
web/components/la-editor/components/ui/popover-wrapper.tsx
Normal file
20
web/components/la-editor/components/ui/popover-wrapper.tsx
Normal 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"
|
||||
45
web/components/la-editor/components/ui/shortcut.tsx
Normal file
45
web/components/la-editor/components/ui/shortcut.tsx
Normal 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
|
||||
}
|
||||
49
web/components/la-editor/components/ui/toolbar-button.tsx
Normal file
49
web/components/la-editor/components/ui/toolbar-button.tsx
Normal 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 }
|
||||
13
web/components/la-editor/extensions/blockquote/blockquote.ts
Normal file
13
web/components/la-editor/extensions/blockquote/blockquote.ts
Normal 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
|
||||
@@ -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
|
||||
1
web/components/la-editor/extensions/bullet-list/index.ts
Normal file
1
web/components/la-editor/extensions/bullet-list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./bullet-list"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./code-block-lowlight"
|
||||
15
web/components/la-editor/extensions/code/code.ts
Normal file
15
web/components/la-editor/extensions/code/code.ts
Normal 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
|
||||
1
web/components/la-editor/extensions/code/index.ts
Normal file
1
web/components/la-editor/extensions/code/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./code"
|
||||
13
web/components/la-editor/extensions/dropcursor/dropcursor.ts
Normal file
13
web/components/la-editor/extensions/dropcursor/dropcursor.ts
Normal 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
|
||||
1
web/components/la-editor/extensions/dropcursor/index.ts
Normal file
1
web/components/la-editor/extensions/dropcursor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./dropcursor"
|
||||
29
web/components/la-editor/extensions/heading/heading.ts
Normal file
29
web/components/la-editor/extensions/heading/heading.ts
Normal 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
|
||||
1
web/components/la-editor/extensions/heading/index.ts
Normal file
1
web/components/la-editor/extensions/heading/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./heading"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./horizontal-rule"
|
||||
43
web/components/la-editor/extensions/index.ts
Normal file
43
web/components/la-editor/extensions/index.ts
Normal 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
|
||||
1
web/components/la-editor/extensions/link/index.ts
Normal file
1
web/components/la-editor/extensions/link/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./link"
|
||||
90
web/components/la-editor/extensions/link/link.ts
Normal file
90
web/components/la-editor/extensions/link/link.ts
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ordered-list"
|
||||
@@ -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
|
||||
1
web/components/la-editor/extensions/paragraph/index.ts
Normal file
1
web/components/la-editor/extensions/paragraph/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./paragraph"
|
||||
14
web/components/la-editor/extensions/paragraph/paragraph.ts
Normal file
14
web/components/la-editor/extensions/paragraph/paragraph.ts
Normal 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
|
||||
1
web/components/la-editor/extensions/selection/index.ts
Normal file
1
web/components/la-editor/extensions/selection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./selection"
|
||||
36
web/components/la-editor/extensions/selection/selection.ts
Normal file
36
web/components/la-editor/extensions/selection/selection.ts
Normal 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
|
||||
122
web/components/la-editor/extensions/slash-command/groups.ts
Normal file
122
web/components/la-editor/extensions/slash-command/groups.ts
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./slash-command"
|
||||
155
web/components/la-editor/extensions/slash-command/menu-list.tsx
Normal file
155
web/components/la-editor/extensions/slash-command/menu-list.tsx
Normal 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
|
||||
@@ -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
|
||||
26
web/components/la-editor/extensions/slash-command/types.ts
Normal file
26
web/components/la-editor/extensions/slash-command/types.ts
Normal 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
|
||||
}
|
||||
153
web/components/la-editor/extensions/starter-kit.ts
Normal file
153
web/components/la-editor/extensions/starter-kit.ts
Normal 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.
|
||||
*
|
||||
* It’s 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
|
||||
@@ -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
|
||||
1
web/components/la-editor/extensions/task-item/index.ts
Normal file
1
web/components/la-editor/extensions/task-item/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./task-item"
|
||||
64
web/components/la-editor/extensions/task-item/task-item.ts
Normal file
64
web/components/la-editor/extensions/task-item/task-item.ts
Normal 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"
|
||||
})
|
||||
}
|
||||
})
|
||||
1
web/components/la-editor/extensions/task-list/index.ts
Normal file
1
web/components/la-editor/extensions/task-list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./task-list"
|
||||
12
web/components/la-editor/extensions/task-list/task-list.ts
Normal file
12
web/components/la-editor/extensions/task-list/task-list.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
30
web/components/la-editor/hooks/use-text-menu-commands.ts
Normal file
30
web/components/la-editor/hooks/use-text-menu-commands.ts
Normal 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
|
||||
}
|
||||
}
|
||||
34
web/components/la-editor/hooks/use-text-menu-states.ts
Normal file
34
web/components/la-editor/hooks/use-text-menu-states.ts
Normal 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
|
||||
}
|
||||
}
|
||||
1
web/components/la-editor/index.ts
Normal file
1
web/components/la-editor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./la-editor"
|
||||
146
web/components/la-editor/la-editor.tsx
Normal file
146
web/components/la-editor/la-editor.tsx
Normal 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
|
||||
14
web/components/la-editor/lib/utils/index.ts
Normal file
14
web/components/la-editor/lib/utils/index.ts
Normal 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"
|
||||
28
web/components/la-editor/lib/utils/isCustomNodeSelected.ts
Normal file
28
web/components/la-editor/lib/utils/isCustomNodeSelected.ts
Normal 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
|
||||
25
web/components/la-editor/lib/utils/isTextSelected.ts
Normal file
25
web/components/la-editor/lib/utils/isTextSelected.ts
Normal 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
|
||||
25
web/components/la-editor/lib/utils/keyboard.ts
Normal file
25
web/components/la-editor/lib/utils/keyboard.ts
Normal 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 }
|
||||
46
web/components/la-editor/lib/utils/platform.ts
Normal file
46
web/components/la-editor/lib/utils/platform.ts
Normal 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
|
||||
140
web/components/la-editor/styles/index.css
Normal file
140
web/components/la-editor/styles/index.css
Normal 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";
|
||||
86
web/components/la-editor/styles/partials/code.css
Normal file
86
web/components/la-editor/styles/partials/code.css
Normal 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;
|
||||
}
|
||||
}
|
||||
82
web/components/la-editor/styles/partials/lists.css
Normal file
82
web/components/la-editor/styles/partials/lists.css
Normal 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;
|
||||
}
|
||||
12
web/components/la-editor/styles/partials/placeholder.css
Normal file
12
web/components/la-editor/styles/partials/placeholder.css
Normal 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)];
|
||||
}
|
||||
27
web/components/la-editor/styles/partials/typography.css
Normal file
27
web/components/la-editor/styles/partials/typography.css
Normal 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;
|
||||
}
|
||||
20
web/components/la-editor/types.ts
Normal file
20
web/components/la-editor/types.ts
Normal 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
|
||||
}
|
||||
158
web/components/routes/globalTopic/globalTopic.tsx
Normal file
158
web/components/routes/globalTopic/globalTopic.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
438
web/components/routes/link/form/manage.tsx
Normal file
438
web/components/routes/link/form/manage.tsx
Normal 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 }
|
||||
74
web/components/routes/link/form/partial/topic-section.tsx
Normal file
74
web/components/routes/link/form/partial/topic-section.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
24
web/components/routes/link/form/schema.ts
Normal file
24
web/components/routes/link/form/schema.ts
Normal 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)
|
||||
})
|
||||
126
web/components/routes/link/header.tsx
Normal file
126
web/components/routes/link/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
191
web/components/routes/link/list-item.tsx
Normal file
191
web/components/routes/link/list-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
232
web/components/routes/link/list.tsx
Normal file
232
web/components/routes/link/list.tsx
Normal 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 }
|
||||
19
web/components/routes/link/wrapper.tsx
Normal file
19
web/components/routes/link/wrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
web/components/routes/page/detail/header.tsx
Normal file
34
web/components/routes/page/detail/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
web/components/routes/page/detail/wrapper.tsx
Normal file
139
web/components/routes/page/detail/wrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
web/components/routes/search/header.tsx
Normal file
10
web/components/routes/search/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
web/components/routes/search/wrapper.tsx
Normal file
131
web/components/routes/search/wrapper.tsx
Normal 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 "Enter" 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>
|
||||
)
|
||||
}
|
||||
70
web/components/ui/LearningTodoStatus.tsx
Normal file
70
web/components/ui/LearningTodoStatus.tsx
Normal 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 }
|
||||
28
web/components/ui/badge.tsx
Normal file
28
web/components/ui/badge.tsx
Normal 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 }
|
||||
90
web/components/ui/breadcrumb.tsx
Normal file
90
web/components/ui/breadcrumb.tsx
Normal 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
Reference in New Issue
Block a user