* wip

* wip

* wip3

* chore: utils

* feat: add command

* wip

* fix: key duplicate

* fix: move and check

* fix: use react-use instead

* fix: sidebar

* chore: make dynamic

* chore: tablet mode

* chore: fix padding

* chore: link instead of inbox

* fix: use dnd kit

* feat: add select component

* chore: use atom

* refactor: remove dnd provider

* feat: disabled drag when sort is not manual

* search route

* .

* feat: accessibility

* fix: search

* .

* .

* .

* fix: sidebar collapsed

* ai search layout

* .

* .

* .

* .

* ai responsible content

* .

* .

* .

* .

* .

* global topic route

* global topic correct route

* topic buttons

* sidebar search navigation

* ai

* Update jazz

* .

* .

* .

* .

* .

* learning status

* .

* .

* chore: content header

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

* fix: confirm

* fix: prevent drag when editing

* chore: remove unused fn

* fix: check propagation

* chore: list

* chore: tweak sonner

* chore: update stuff

* feat: add badge

* chore: close edit when create

* chore: escape on manage form

* refactor: remove learn path

* css: responsive item

* chore: separate pages and topic

* reafactor: remove new-schema

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

* chore: use new types

* fix: missing deps

* fix: link

* fix: sidebar in layout

* fix: quotes

* css: use medium instead semi

* Actual streaming and rendering markdown response

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* .

* chore: metadata

* feat: la-editor

* .

* fix: editor and page

* .

* .

* .

* .

* .

* .

* fix: remove link

* chore: page sidebar

* fix: remove 'replace with learning status'

* fix: link

* fix: link

* chore: update schema

* chore: use new schema

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

* feat: create slug

* refactor apply

* update package json

* fix: schema personal page

* chore: editor

* feat: pages

* fix: metadata

* fix: jazz provider

* feat: handling data

* feat: page detail

* chore: server page to id

* chore: use id instead of slug

* chore: update content header

* chore: update link header implementation

* refactor: global.css

* fix: la editor use animation frame

* fix: editor export ref

* refactor: page detail

* chore: tidy up schema

* chore: adapt to new schema

* fix: wrap using settimeout

* fix: wrap using settimeout

* .

* .

---------

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

View File

@@ -0,0 +1,88 @@
"use client"
import React, { useEffect, useState } from "react"
import * as smd from "streaming-markdown"
interface AiSearchProps {
searchQuery: string
}
const AiSearch: React.FC<AiSearchProps> = (props: { searchQuery: string }) => {
const [error, setError] = useState<string>("")
let root_el = React.useRef<HTMLDivElement | null>(null)
let [parser, md_el] = React.useMemo(() => {
let md_el = document.createElement("div")
let renderer = smd.default_renderer(md_el)
let parser = smd.parser(renderer)
return [parser, md_el]
}, [])
useEffect(() => {
if (root_el.current) {
root_el.current.appendChild(md_el)
}
}, [root_el.current, md_el])
useEffect(() => {
let question = props.searchQuery
fetchData()
async function fetchData() {
let response: Response
try {
response = await fetch("/api/search-stream", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ question: question })
})
} catch (error) {
console.error("Error fetching data:", error)
setError("Error fetching data")
return
}
if (!response.body) {
console.error("Response has no body")
setError("Response has no body")
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
let res = await reader.read()
if (res.value) {
let text = decoder.decode(res.value)
smd.parser_write(parser, text)
}
if (res.done) {
smd.parser_end(parser)
break
}
}
}
}, [props.searchQuery, parser])
return (
<div className="mx-auto flex max-w-3xl flex-col items-center">
<div className="w-full rounded-lg bg-inherit p-6 text-white">
<div className="mb-6 rounded-lg bg-blue-700 p-4">
<h2 className="text-lg font-medium"> This is what I have found:</h2>
</div>
<div className="rounded-xl bg-[#121212] p-4" ref={root_el}></div>
</div>
<p className="text-md pb-5 font-semibold opacity-50">{error}</p>
<button className="text-md rounded-2xl bg-neutral-800 px-6 py-3 font-semibold text-opacity-50 shadow-inner shadow-neutral-700/50 transition-colors hover:bg-neutral-700">
Ask Community
</button>
</div>
)
}
export default AiSearch

View File

@@ -0,0 +1,61 @@
"use client"
import React from "react"
import { Separator } from "@/components/ui/separator"
import { Button } from "../ui/button"
import { PanelLeftIcon } from "lucide-react"
import { useAtom } from "jotai"
import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar"
import { useMedia } from "react-use"
import { cn } from "@/lib/utils"
type ContentHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "title">
export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps>(
({ children, className, ...props }, ref) => {
return (
<header
className={cn(
"flex min-h-10 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 border border-b pl-8 pr-6 transition-opacity max-lg:pl-4 max-lg:pr-5",
className
)}
ref={ref}
{...props}
>
{children}
</header>
)
}
)
ContentHeader.displayName = "ContentHeader"
export const SidebarToggleButton: React.FC = () => {
const [isCollapse] = useAtom(isCollapseAtom)
const [, toggle] = useAtom(toggleCollapseAtom)
const isTablet = useMedia("(max-width: 1024px)")
if (!isCollapse && !isTablet) return null
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
toggle()
}
return (
<div className="flex items-center gap-1">
<Button
type="button"
size="icon"
variant="ghost"
aria-label="Menu"
className="text-primary/60 z-50"
onClick={handleClick}
>
<PanelLeftIcon size={16} />
</Button>
<Separator orientation="vertical" />
</div>
)
}

View File

@@ -0,0 +1,166 @@
import React, { useEffect, useMemo, useState } from "react"
import { BrowserDemoAuth, AuthProvider } from "jazz-browser"
import { Account, CoValueClass, ID } from "jazz-tools"
import { AgentSecret } from "cojson"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
// Types
export type AuthState = "loading" | "ready" | "signedIn"
export type ReactAuthHook<Acc extends Account> = (setJazzAuthState: (state: AuthState) => void) => {
auth: AuthProvider<Acc>
AuthUI: React.ReactNode
logOut?: () => void
}
type DemoAuthProps<Acc extends Account = Account> = {
accountSchema?: CoValueClass<Acc> & typeof Account
appName: string
appHostname?: string
Component?: DemoAuth.Component
seedAccounts?: {
[name: string]: { accountID: ID<Account>; accountSecret: AgentSecret }
}
}
type AuthComponentProps = {
appName: string
loading: boolean
existingUsers: string[]
logInAs: (existingUser: string) => void
signUp: (username: string) => void
}
// Main DemoAuth function
export function DemoAuth<Acc extends Account = Account>({
accountSchema = Account as CoValueClass<Acc> & typeof Account,
appName,
appHostname,
Component = DemoAuth.BasicUI,
seedAccounts
}: DemoAuthProps<Acc>): ReactAuthHook<Acc> {
return function useLocalAuth(setJazzAuthState) {
const [authState, setAuthState] = useState<AuthState>("loading")
const [existingUsers, setExistingUsers] = useState<string[]>([])
const [logInAs, setLogInAs] = useState<(existingUser: string) => void>(() => () => {})
const [signUp, setSignUp] = useState<(username: string) => void>(() => () => {})
const [logOut, setLogOut] = useState<(() => void) | undefined>(undefined)
const [logOutCounter, setLogOutCounter] = useState(0)
useEffect(() => {
setJazzAuthState(authState)
}, [authState, setJazzAuthState])
const auth = useMemo(() => {
return new BrowserDemoAuth<Acc>(
accountSchema,
{
onReady(next) {
setAuthState("ready")
setExistingUsers(next.existingUsers)
setLogInAs(() => next.logInAs)
setSignUp(() => next.signUp)
},
onSignedIn(next) {
setAuthState("signedIn")
setLogOut(() => () => {
next.logOut()
setAuthState("loading")
setLogOutCounter(c => c + 1)
})
}
},
appName,
seedAccounts
)
}, [])
const AuthUI = (
<Component
appName={appName}
loading={authState === "loading"}
existingUsers={existingUsers}
logInAs={logInAs}
signUp={signUp}
/>
)
return { auth, AuthUI, logOut }
}
}
const DemoAuthBasicUI: React.FC<AuthComponentProps> = ({ appName, existingUsers, logInAs, signUp }) => {
const [username, setUsername] = useState<string>("")
const darkMode = useDarkMode()
return (
<div className="relative flex min-h-full flex-col justify-center">
<div className="mx-auto h-full w-full max-w-sm space-y-6 p-4">
<h1 className="text-center font-semibold">{appName}</h1>
<SignUpForm username={username} setUsername={setUsername} signUp={signUp} darkMode={darkMode} />
<ExistingUsersList existingUsers={existingUsers} logInAs={logInAs} darkMode={darkMode} />
</div>
</div>
)
}
// Helper components
const SignUpForm: React.FC<{
username: string
setUsername: (value: string) => void
signUp: (username: string) => void
darkMode: boolean
}> = ({ username, setUsername, signUp, darkMode }) => (
<form
onSubmit={e => {
e.preventDefault()
signUp(username)
}}
className="flex flex-col gap-y-4"
>
<Input
placeholder="Display name"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="webauthn"
/>
<Button type="submit">Sign Up as new account</Button>
</form>
)
const ExistingUsersList: React.FC<{
existingUsers: string[]
logInAs: (user: string) => void
darkMode: boolean
}> = ({ existingUsers, logInAs, darkMode }) => (
<div className="flex flex-col gap-y-2">
{existingUsers.map(user => (
<Button key={user} onClick={() => logInAs(user)}>
Log In as &quot;{user}&quot;
</Button>
))}
</div>
)
// Hooks
const useDarkMode = () => {
const [darkMode, setDarkMode] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
setDarkMode(mediaQuery.matches)
const handler = (e: MediaQueryListEvent) => setDarkMode(e.matches)
mediaQuery.addEventListener("change", handler)
return () => mediaQuery.removeEventListener("change", handler)
}, [])
return darkMode
}
// DemoAuth namespace
export namespace DemoAuth {
export type Component = React.FC<AuthComponentProps>
export const BasicUI = DemoAuthBasicUI
}

View File

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

View File

@@ -0,0 +1,114 @@
import { SidebarItem } from "../sidebar"
import { z } from "zod"
import { useAccount } from "@/lib/providers/jazz-provider"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
import { PlusIcon } from "lucide-react"
import { generateUniqueSlug } from "@/lib/utils"
import { PersonalPage } from "@/lib/schema/personal-page"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
const createPageSchema = z.object({
title: z.string({ message: "Please enter a valid title" }).min(1, { message: "Please enter a valid title" })
})
type PageFormValues = z.infer<typeof createPageSchema>
export const PageSection: React.FC = () => {
const { me } = useAccount()
const personalPages = me.root?.personalPages || []
return (
<div className="-ml-2">
<div className="group mb-0.5 ml-2 mt-2 flex flex-row items-center justify-between rounded-md">
<div
role="button"
tabIndex={0}
className="text-muted-foreground hover:bg-muted/50 flex h-6 grow cursor-default items-center justify-between gap-x-0.5 self-start rounded-md px-1 text-xs font-medium"
>
<span className="group-hover:text-muted-foreground">Pages</span>
<CreatePageForm />
</div>
</div>
<div className="relative shrink-0">
<div aria-hidden="false" className="ml-2 shrink-0 pb-2">
{personalPages.map(
page => page && <SidebarItem key={page.id} url={`/pages/${page.id}`} label={page.title} />
)}
</div>
</div>
</div>
)
}
const CreatePageForm: React.FC = () => {
const { me } = useAccount()
const form = useForm<PageFormValues>({
resolver: zodResolver(createPageSchema),
defaultValues: {
title: ""
}
})
const onSubmit = (values: PageFormValues) => {
try {
const personalPages = me?.root?.personalPages?.toJSON() || []
const slug = generateUniqueSlug(personalPages, values.title)
const newPersonalPage = PersonalPage.create(
{
title: values.title,
slug: slug,
content: ""
},
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
toast.success("Page created successfully")
} catch (error) {
console.error(error)
toast.error("Failed to create page")
}
}
return (
<Popover>
<PopoverTrigger asChild>
<Button type="button" size="icon" variant="ghost" aria-label="New Page" className="size-6">
<PlusIcon size={16} />
</Button>
</PopoverTrigger>
<PopoverContent align="start">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>New page</FormLabel>
<FormControl>
<Input placeholder="Enter a title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" size="sm" className="w-full">
Create page
</Button>
</form>
</Form>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,100 @@
import { useState, useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { ChevronDown, BookOpen, Bookmark, GraduationCap, Check } from "lucide-react"
import { SidebarItem } from "../sidebar"
const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"]
export const TopicSection = () => {
const [showOptions, setShowOptions] = useState(false)
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
const sectionRef = useRef<HTMLDivElement>(null)
const learningOptions = [
{ text: "To Learn", icon: <Bookmark size={16} />, color: "text-white/70" },
{
text: "Learning",
icon: <GraduationCap size={16} />,
color: "text-[#D29752]"
},
{
text: "Learned",
icon: <Check size={16} />,
color: "text-[#708F51]"
}
]
const statusSelect = (status: string) => {
setSelectedStatus(status === "Show All" ? null : status)
setShowOptions(false)
}
useEffect(() => {
const overlayClick = (event: MouseEvent) => {
if (sectionRef.current && !sectionRef.current.contains(event.target as Node)) {
setShowOptions(false)
}
}
document.addEventListener("mousedown", overlayClick)
return () => {
document.removeEventListener("mousedown", overlayClick)
}
}, [])
const availableOptions = selectedStatus
? [
{
text: "Show All",
icon: <BookOpen size={16} />,
color: "text-white"
},
...learningOptions.filter(option => option.text !== selectedStatus)
]
: learningOptions
// const topicClick = (topic: string) => {
// router.push(`/${topic.toLowerCase()}`)
// }
return (
<div className="space-y-1 overflow-hidden" ref={sectionRef}>
<Button
onClick={() => setShowOptions(!showOptions)}
className="bg-accent text-foreground hover:bg-accent/50 flex w-full items-center justify-between rounded-md px-3 py-2 text-sm font-medium"
>
<span>{selectedStatus ? `Topics: ${selectedStatus}` : "Topics"}</span>
<ChevronDown
size={16}
className={`transform transition-transform duration-200 ease-in-out ${
showOptions ? "rotate-0" : "rotate-[-90deg]"
}`}
/>
</Button>
{showOptions && (
<div className="rounded-md bg-neutral-800">
{availableOptions.map(option => (
<Button
key={option.text}
onClick={() => statusSelect(option.text)}
className={`flex w-full items-center justify-start space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-neutral-700 ${option.color} bg-inherit`}
>
{option.icon && <span className={option.color}>{option.icon}</span>}
<span>{option.text}</span>
</Button>
))}
</div>
)}
<div className="scrollbar-hide space-y-1 overflow-y-auto" style={{ maxHeight: "calc(100vh - 200px)" }}>
{TOPICS.map(topic => (
<SidebarItem key={topic} label={topic} url={`/${topic}`} />
))}
</div>
</div>
)
}
export default TopicSection

View File

@@ -0,0 +1,179 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useMedia } from "react-use"
import { useAtom } from "jotai"
import { LinkIcon, SearchIcon } from "lucide-react"
import { Logo } from "@/components/custom/logo"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { isCollapseAtom } from "@/store/sidebar"
import { PageSection } from "./partial/page-section"
import { TopicSection } from "./partial/topic-section"
interface SidebarContextType {
isCollapsed: boolean
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>
}
const SidebarContext = React.createContext<SidebarContextType>({
isCollapsed: false,
setIsCollapsed: () => {}
})
const useSidebarCollapse = (isTablet: boolean): [boolean, React.Dispatch<React.SetStateAction<boolean>>] => {
const [isCollapsed, setIsCollapsed] = useAtom(isCollapseAtom)
const pathname = usePathname()
React.useEffect(() => {
if (isTablet) setIsCollapsed(true)
}, [pathname, setIsCollapsed, isTablet])
React.useEffect(() => {
setIsCollapsed(isTablet)
}, [isTablet, setIsCollapsed])
return [isCollapsed, setIsCollapsed]
}
interface SidebarItemProps {
label: string
url: string
icon?: React.ReactNode
onClick?: () => void
children?: React.ReactNode
}
export const SidebarItem: React.FC<SidebarItemProps> = React.memo(({ label, url, icon, onClick, children }) => {
const pathname = usePathname()
const isActive = pathname === url
return (
<div className={cn("group relative my-0.5 rounded-md", isActive ? "bg-secondary/80" : "hover:bg-secondary/40")}>
<Link
className="text-secondary-foreground flex h-8 grow items-center truncate rounded-md pl-1.5 pr-1 text-sm font-medium"
href={url}
onClick={onClick}
>
{icon && (
<span className={cn("text-primary/60 group-hover:text-primary mr-2 size-4", { "text-primary": isActive })}>
{icon}
</span>
)}
<span>{label}</span>
{children}
</Link>
</div>
)
})
const LogoAndSearch: React.FC = React.memo(() => {
const pathname = usePathname()
return (
<div className="px-3.5">
<div className="mb-1 mt-2 flex h-10 max-w-full items-center">
<Link href="/links" className="px-2">
<Logo className="size-7" />
</Link>
<div className="flex min-w-2 grow flex-row" />
{pathname === "/search" ? (
<Link href="/links">
<Button size="sm" variant="secondary" type="button" className="text-md text-primary/60 font-medium">
Back
</Button>
</Link>
) : (
<Link href="/search">
<Button
size="sm"
variant="secondary"
aria-label="Search"
type="button"
className="text-primary/60 flex w-20 items-center justify-start py-4 pl-2"
>
<SearchIcon size={16} className="mr-2" />
</Button>
</Link>
)}
</div>
</div>
)
})
const SidebarContent: React.FC = React.memo(() => {
const { isCollapsed } = React.useContext(SidebarContext)
const isTablet = useMedia("(max-width: 1024px)")
return (
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
<div className={cn({ "pt-12": !isCollapsed && isTablet })}>
<LogoAndSearch />
</div>
<div tabIndex={-1} className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3.5">
<SidebarItem url="/links" label="Links" icon={<LinkIcon size={16} />} />
<div className="h-2 shrink-0" />
<PageSection />
<TopicSection />
</div>
</nav>
)
})
export const Sidebar: React.FC = () => {
const isTablet = useMedia("(max-width: 1024px)")
const [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet)
const sidebarClasses = cn(
"h-full overflow-hidden transition-all duration-300 ease-in-out",
isCollapsed ? "w-0" : "w-auto min-w-56"
)
const sidebarInnerClasses = cn(
"h-full w-auto min-w-56 transition-transform duration-300 ease-in-out",
isCollapsed ? "-translate-x-full" : "translate-x-0"
)
const contextValue = React.useMemo(() => ({ isCollapsed, setIsCollapsed }), [isCollapsed, setIsCollapsed])
if (isTablet) {
return (
<>
<div
className={cn(
"fixed inset-0 z-30 bg-black/40 transition-opacity duration-300",
isCollapsed ? "pointer-events-none opacity-0" : "opacity-100"
)}
onClick={() => setIsCollapsed(true)}
/>
<div
className={cn(
"fixed left-0 top-0 z-40 h-full",
sidebarClasses,
!isCollapsed && "shadow-[4px_0px_16px_rgba(0,0,0,0.1)] transition-all"
)}
>
<div className={cn(sidebarInnerClasses, "border-r-primary/5 border-r")}>
<SidebarContext.Provider value={contextValue}>
<SidebarContent />
</SidebarContext.Provider>
</div>
</div>
</>
)
}
return (
<div className={sidebarClasses}>
<div className={sidebarInnerClasses}>
<SidebarContext.Provider value={contextValue}>
<SidebarContent />
</SidebarContext.Provider>
</div>
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,60 @@
import { useTextmenuCommands } from "../../hooks/use-text-menu-commands"
import { PopoverWrapper } from "../ui/popover-wrapper"
import { useTextmenuStates } from "../../hooks/use-text-menu-states"
import { BubbleMenu as TiptapBubbleMenu, Editor } from "@tiptap/react"
import { ToolbarButton } from "../ui/toolbar-button"
import { Icon } from "../ui/icon"
import * as React from "react"
export type BubbleMenuProps = {
editor: Editor
}
export const BubbleMenu = ({ editor }: BubbleMenuProps) => {
const commands = useTextmenuCommands(editor)
const states = useTextmenuStates(editor)
return (
<TiptapBubbleMenu
tippyOptions={{
// duration: [0, 999999],
popperOptions: { placement: "top-start" }
}}
editor={editor}
pluginKey="textMenu"
shouldShow={states.shouldShow}
updateDelay={100}
>
<PopoverWrapper className="flex items-center overflow-x-auto p-1">
<div className="space-x-1">
<ToolbarButton value="bold" aria-label="Bold" onPressedChange={commands.onBold} isActive={states.isBold}>
<Icon name="Bold" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="italic" aria-label="Italic" onClick={commands.onItalic}>
<Icon name="Italic" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="strikethrough" aria-label="Strikethrough" onClick={commands.onStrike}>
<Icon name="Strikethrough" strokeWidth={2.5} />
</ToolbarButton>
{/* <ToolbarButton value="link" aria-label="Link">
<Icon name="Link" strokeWidth={2.5} />
</ToolbarButton> */}
<ToolbarButton value="quote" aria-label="Quote" onClick={commands.onCode}>
<Icon name="Quote" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="inline code" aria-label="Inline code" onClick={commands.onCode}>
<Icon name="Braces" strokeWidth={2.5} />
</ToolbarButton>
<ToolbarButton value="code block" aria-label="Code block" onClick={commands.onCodeBlock}>
<Icon name="Code" strokeWidth={2.5} />
</ToolbarButton>
{/* <ToolbarButton value="list" aria-label="List">
<Icon name="List" strokeWidth={2.5} />
</ToolbarButton> */}
</div>
</PopoverWrapper>
</TiptapBubbleMenu>
)
}
export default BubbleMenu

View File

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

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { icons } from "lucide-react"
export type IconProps = {
name: keyof typeof icons
className?: string
strokeWidth?: number
[key: string]: any
}
export const Icon = React.memo(({ name, className, size, strokeWidth, ...props }: IconProps) => {
const IconComponent = icons[name]
if (!IconComponent) {
return null
}
return <IconComponent className={cn(!size ? "size-4" : size, className)} strokeWidth={strokeWidth || 2} {...props} />
})
Icon.displayName = "Icon"

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Toggle } from "@/components/ui/toggle"
import * as React from "react"
import { cn } from "@/lib/utils"
import type { TooltipContentProps } from "@radix-ui/react-tooltip"
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Toggle> {
isActive?: boolean
tooltip?: string
tooltipOptions?: TooltipContentProps
}
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(function ToolbarButton(
{ isActive, children, tooltip, className, tooltipOptions, ...props },
ref
) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
size="sm"
ref={ref}
className={cn(
"size-7 rounded-md p-0",
{
"bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary": isActive
},
className
)}
{...props}
>
{children}
</Toggle>
</TooltipTrigger>
{tooltip && (
<TooltipContent {...tooltipOptions}>
<div className="flex flex-col items-center text-center">{tooltip}</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
})
ToolbarButton.displayName = "ToolbarButton"
export { ToolbarButton }

View File

@@ -0,0 +1,13 @@
/*
* Add block-node class to blockquote element
*/
import { mergeAttributes } from "@tiptap/core"
import { Blockquote as TiptapBlockquote } from "@tiptap/extension-blockquote"
export const Blockquote = TiptapBlockquote.extend({
renderHTML({ HTMLAttributes }) {
return ["blockquote", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: "block-node" }), 0]
}
})
export default Blockquote

View File

@@ -0,0 +1,14 @@
import { BulletList as TiptapBulletList } from "@tiptap/extension-bullet-list"
export const BulletList = TiptapBulletList.extend({
addOptions() {
return {
...this.parent?.(),
HTMLAttributes: {
class: "list-node"
}
}
}
})
export default BulletList

View File

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

View File

@@ -0,0 +1,17 @@
import { CodeBlockLowlight as TiptapCodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"
import { common, createLowlight } from "lowlight"
export const CodeBlockLowlight = TiptapCodeBlockLowlight.extend({
addOptions() {
return {
...this.parent?.(),
lowlight: createLowlight(common),
defaultLanguage: null,
HTMLAttributes: {
class: "block-node"
}
}
}
})
export default CodeBlockLowlight

View File

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

View File

@@ -0,0 +1,15 @@
import { Code as TiptapCode } from "@tiptap/extension-code"
export const Code = TiptapCode.extend({
addOptions() {
return {
...this.parent?.(),
HTMLAttributes: {
class: "inline",
spellCheck: "false"
}
}
}
})
export default Code

View File

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

View File

@@ -0,0 +1,13 @@
import { Dropcursor as TiptapDropcursor } from "@tiptap/extension-dropcursor"
export const Dropcursor = TiptapDropcursor.extend({
addOptions() {
return {
...this.parent?.(),
width: 2,
class: "ProseMirror-dropcursor border"
}
}
})
export default Dropcursor

View File

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

View File

@@ -0,0 +1,29 @@
/*
* Add heading level validation. decimal (0-9)
* Add heading class to heading element
*/
import { mergeAttributes } from "@tiptap/core"
import TiptapHeading from "@tiptap/extension-heading"
import type { Level } from "@tiptap/extension-heading"
export const Heading = TiptapHeading.extend({
addOptions() {
return {
...this.parent?.(),
levels: [1, 2, 3] as Level[],
HTMLAttributes: {
class: "heading-node"
}
}
},
renderHTML({ node, HTMLAttributes }) {
const nodeLevel = parseInt(node.attrs.level, 10) as Level
const hasLevel = this.options.levels.includes(nodeLevel)
const level = hasLevel ? nodeLevel : this.options.levels[0]
return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}
})
export default Heading

View File

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

View File

@@ -0,0 +1,18 @@
/*
* Wrap the horizontal rule in a div element.
* Also add a keyboard shortcut to insert a horizontal rule.
*/
import { HorizontalRule as TiptapHorizontalRule } from "@tiptap/extension-horizontal-rule"
export const HorizontalRule = TiptapHorizontalRule.extend({
addKeyboardShortcuts() {
return {
"Mod-Alt--": () =>
this.editor.commands.insertContent({
type: this.name
})
}
}
})
export default HorizontalRule

View File

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

View File

@@ -0,0 +1,43 @@
import { StarterKit } from "./starter-kit"
import { TaskList } from "./task-list"
import { TaskItem } from "./task-item"
import { HorizontalRule } from "./horizontal-rule"
import { Blockquote } from "./blockquote/blockquote"
import { SlashCommand } from "./slash-command"
import { Heading } from "./heading"
import { Link } from "./link"
import { CodeBlockLowlight } from "./code-block-lowlight"
import { Selection } from "./selection"
import { Code } from "./code"
import { Paragraph } from "./paragraph"
import { BulletList } from "./bullet-list"
import { OrderedList } from "./ordered-list"
import { Dropcursor } from "./dropcursor"
export interface ExtensionOptions {
placeholder?: string
}
export const createExtensions = ({ placeholder = "Start typing..." }: ExtensionOptions) => [
Heading,
Code,
Link,
TaskList,
TaskItem,
Selection,
Paragraph,
Dropcursor,
Blockquote,
BulletList,
OrderedList,
SlashCommand,
HorizontalRule,
CodeBlockLowlight,
StarterKit.configure({
placeholder: {
placeholder: () => placeholder
}
})
]
export default createExtensions

View File

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

View File

@@ -0,0 +1,90 @@
import { mergeAttributes } from "@tiptap/core"
import TiptapLink from "@tiptap/extension-link"
import { EditorView } from "@tiptap/pm/view"
import { getMarkRange } from "@tiptap/core"
import { Plugin, TextSelection } from "@tiptap/pm/state"
export const Link = TiptapLink.extend({
/*
* Determines whether typing next to a link automatically becomes part of the link.
* In this case, we dont want any characters to be included as part of the link.
*/
inclusive: false,
/*
* Match all <a> elements that have an href attribute, except for:
* - <a> elements with a data-type attribute set to button
* - <a> elements with an href attribute that contains 'javascript:'
*/
parseHTML() {
return [{ tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])' }]
},
renderHTML({ HTMLAttributes }) {
return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addOptions() {
return {
...this.parent?.(),
openOnClick: false,
HTMLAttributes: {
class: "link"
}
}
},
addProseMirrorPlugins() {
const { editor } = this
return [
...(this.parent?.() || []),
new Plugin({
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
const { selection } = editor.state
/*
* Handles the 'Escape' key press when there's a selection within the link.
* This will move the cursor to the end of the link.
*/
if (event.key === "Escape" && selection.empty !== true) {
console.log("Link handleKeyDown")
editor.commands.focus(selection.to, { scrollIntoView: false })
}
return false
},
handleClick(view, pos) {
/*
* Marks the entire link when the user clicks on it.
*/
const { schema, doc, tr } = view.state
const range = getMarkRange(doc.resolve(pos), schema.marks.link)
if (!range) {
return
}
const { from, to } = range
const start = Math.min(from, to)
const end = Math.max(from, to)
if (pos < start || pos > end) {
return
}
const $start = doc.resolve(start)
const $end = doc.resolve(end)
const transaction = tr.setSelection(new TextSelection($start, $end))
view.dispatch(transaction)
}
}
})
]
}
})
export default Link

View File

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

View File

@@ -0,0 +1,14 @@
import { OrderedList as TiptapOrderedList } from "@tiptap/extension-ordered-list"
export const OrderedList = TiptapOrderedList.extend({
addOptions() {
return {
...this.parent?.(),
HTMLAttributes: {
class: "list-node"
}
}
}
})
export default OrderedList

View File

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

View File

@@ -0,0 +1,14 @@
import { Paragraph as TiptapParagraph } from "@tiptap/extension-paragraph"
export const Paragraph = TiptapParagraph.extend({
addOptions() {
return {
...this.parent?.(),
HTMLAttributes: {
class: "text-node"
}
}
}
})
export default Paragraph

View File

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

View File

@@ -0,0 +1,36 @@
import { Extension } from "@tiptap/core"
import { Plugin, PluginKey } from "@tiptap/pm/state"
import { Decoration, DecorationSet } from "@tiptap/pm/view"
export const Selection = Extension.create({
name: "selection",
addProseMirrorPlugins() {
const { editor } = this
return [
new Plugin({
key: new PluginKey("selection"),
props: {
decorations(state) {
if (state.selection.empty) {
return null
}
if (editor.isFocused === true) {
return null
}
return DecorationSet.create(state.doc, [
Decoration.inline(state.selection.from, state.selection.to, {
class: "selection"
})
])
}
}
})
]
}
})
export default Selection

View File

@@ -0,0 +1,122 @@
import { Group } from "./types"
export const GROUPS: Group[] = [
{
name: "format",
title: "Format",
commands: [
{
name: "heading1",
label: "Heading 1",
iconName: "Heading1",
description: "High priority section title",
aliases: ["h1"],
shortcuts: ["mod", "alt", "1"],
action: editor => {
editor.chain().focus().setHeading({ level: 1 }).run()
}
},
{
name: "heading2",
label: "Heading 2",
iconName: "Heading2",
description: "Medium priority section title",
aliases: ["h2"],
shortcuts: ["mod", "alt", "2"],
action: editor => {
editor.chain().focus().setHeading({ level: 2 }).run()
}
},
{
name: "heading3",
label: "Heading 3",
iconName: "Heading3",
description: "Low priority section title",
aliases: ["h3"],
shortcuts: ["mod", "alt", "3"],
action: editor => {
editor.chain().focus().setHeading({ level: 3 }).run()
}
}
]
},
{
name: "list",
title: "List",
commands: [
{
name: "bulletList",
label: "Bullet List",
iconName: "List",
description: "Unordered list of items",
aliases: ["ul"],
shortcuts: ["mod", "shift", "8"],
action: editor => {
editor.chain().focus().toggleBulletList().run()
}
},
{
name: "numberedList",
label: "Numbered List",
iconName: "ListOrdered",
description: "Ordered list of items",
aliases: ["ol"],
shortcuts: ["mod", "shift", "7"],
action: editor => {
editor.chain().focus().toggleOrderedList().run()
}
},
{
name: "taskList",
label: "Task List",
iconName: "ListTodo",
description: "Task list with todo items",
aliases: ["todo"],
shortcuts: ["mod", "shift", "8"],
action: editor => {
editor.chain().focus().toggleTaskList().run()
}
}
]
},
{
name: "insert",
title: "Insert",
commands: [
{
name: "codeBlock",
label: "Code Block",
iconName: "SquareCode",
description: "Code block with syntax highlighting",
shortcuts: ["mod", "alt", "c"],
shouldBeHidden: editor => editor.isActive("columns"),
action: editor => {
editor.chain().focus().setCodeBlock().run()
}
},
{
name: "horizontalRule",
label: "Divider",
iconName: "Divide",
description: "Insert a horizontal divider",
aliases: ["hr"],
shortcuts: ["mod", "shift", "-"],
action: editor => {
editor.chain().focus().setHorizontalRule().run()
}
},
{
name: "blockquote",
label: "Blockquote",
iconName: "Quote",
description: "Element for quoting",
shortcuts: ["mod", "shift", "b"],
action: editor => {
editor.chain().focus().setBlockquote().run()
}
}
]
}
]
export default GROUPS

View File

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

View File

@@ -0,0 +1,155 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Command, MenuListProps } from "./types"
import { getShortcutKeys } from "../../lib/utils"
import { Icon } from "../../components/ui/icon"
import { PopoverWrapper } from "../../components/ui/popover-wrapper"
import { Shortcut } from "../../components/ui/shortcut"
export const MenuList = React.forwardRef((props: MenuListProps, ref) => {
const scrollContainer = React.useRef<HTMLDivElement>(null)
const activeItem = React.useRef<HTMLButtonElement>(null)
const [selectedGroupIndex, setSelectedGroupIndex] = React.useState(0)
const [selectedCommandIndex, setSelectedCommandIndex] = React.useState(0)
// Anytime the groups change, i.e. the user types to narrow it down, we want to
// reset the current selection to the first menu item
React.useEffect(() => {
setSelectedGroupIndex(0)
setSelectedCommandIndex(0)
}, [props.items])
const selectItem = React.useCallback(
(groupIndex: number, commandIndex: number) => {
const command = props.items[groupIndex].commands[commandIndex]
props.command(command)
},
[props]
)
React.useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: React.KeyboardEvent }) => {
if (event.key === "ArrowDown") {
if (!props.items.length) {
return false
}
const commands = props.items[selectedGroupIndex].commands
let newCommandIndex = selectedCommandIndex + 1
let newGroupIndex = selectedGroupIndex
if (commands.length - 1 < newCommandIndex) {
newCommandIndex = 0
newGroupIndex = selectedGroupIndex + 1
}
if (props.items.length - 1 < newGroupIndex) {
newGroupIndex = 0
}
setSelectedCommandIndex(newCommandIndex)
setSelectedGroupIndex(newGroupIndex)
return true
}
if (event.key === "ArrowUp") {
if (!props.items.length) {
return false
}
let newCommandIndex = selectedCommandIndex - 1
let newGroupIndex = selectedGroupIndex
if (newCommandIndex < 0) {
newGroupIndex = selectedGroupIndex - 1
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0
}
if (newGroupIndex < 0) {
newGroupIndex = props.items.length - 1
newCommandIndex = props.items[newGroupIndex].commands.length - 1
}
setSelectedCommandIndex(newCommandIndex)
setSelectedGroupIndex(newGroupIndex)
return true
}
if (event.key === "Enter") {
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
return false
}
selectItem(selectedGroupIndex, selectedCommandIndex)
return true
}
return false
}
}))
React.useEffect(() => {
if (activeItem.current && scrollContainer.current) {
const offsetTop = activeItem.current.offsetTop
const offsetHeight = activeItem.current.offsetHeight
scrollContainer.current.scrollTop = offsetTop - offsetHeight
}
}, [selectedCommandIndex, selectedGroupIndex])
const createCommandClickHandler = React.useCallback(
(groupIndex: number, commandIndex: number) => {
return () => {
selectItem(groupIndex, commandIndex)
}
},
[selectItem]
)
if (!props.items.length) {
return null
}
return (
<PopoverWrapper ref={scrollContainer} className="flex max-h-[min(80vh,24rem)] flex-col overflow-auto p-1">
{props.items.map((group, groupIndex: number) => (
<React.Fragment key={group.title}>
{group.commands.map((command: Command, commandIndex: number) => (
<Button
key={command.label}
variant="ghost"
onClick={createCommandClickHandler(groupIndex, commandIndex)}
className={cn("relative w-full justify-between gap-2 px-3.5 py-1.5 font-normal", {
"bg-accent text-accent-foreground":
selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex
})}
>
<Icon name={command.iconName} />
<span className="truncate text-sm">{command.label}</span>
<div className="flex flex-auto flex-row"></div>
<Shortcut.Wrapper ariaLabel={getShortcutKeys(command.shortcuts)}>
{command.shortcuts.map(shortcut => (
<Shortcut.Key shortcut={shortcut} key={shortcut} />
))}
</Shortcut.Wrapper>
</Button>
))}
{groupIndex !== props.items.length - 1 && <Separator className="my-1.5" />}
</React.Fragment>
))}
</PopoverWrapper>
)
})
MenuList.displayName = "MenuList"
export default MenuList

View File

@@ -0,0 +1,234 @@
import { Editor, Extension } from "@tiptap/core"
import { ReactRenderer } from "@tiptap/react"
import Suggestion, { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion"
import { PluginKey } from "@tiptap/pm/state"
import tippy from "tippy.js"
import { GROUPS } from "./groups"
import { MenuList } from "./menu-list"
const EXTENSION_NAME = "slashCommand"
let popup: any
export const SlashCommand = Extension.create({
name: EXTENSION_NAME,
priority: 200,
onCreate() {
popup = tippy("body", {
interactive: true,
trigger: "manual",
placement: "bottom-start",
theme: "slash-command",
maxWidth: "16rem",
offset: [16, 8],
popperOptions: {
strategy: "fixed",
modifiers: [{ name: "flip", enabled: false }]
}
})
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: "/",
allowSpaces: true,
startOfLine: true,
pluginKey: new PluginKey(EXTENSION_NAME),
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from)
const isRootDepth = $from.depth === 1
const isParagraph = $from.parent.type.name === "paragraph"
const isStartOfNode = $from.parent.textContent?.charAt(0) === "/"
const isInColumn = this.editor.isActive("column")
const afterContent = $from.parent.textContent?.substring($from.parent.textContent?.indexOf("/"))
const isValidAfterContent = !afterContent?.endsWith(" ")
return (
((isRootDepth && isParagraph && isStartOfNode) || (isInColumn && isParagraph && isStartOfNode)) &&
isValidAfterContent
)
},
command: ({ editor, props }: { editor: Editor; props: any }) => {
const { view, state } = editor
const { $head, $from } = view.state.selection
const end = $from.pos
const from = $head?.nodeBefore
? end - ($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf("/")).length ?? 0)
: $from.start()
const tr = state.tr.deleteRange(from, end)
view.dispatch(tr)
props.action(editor)
view.focus()
},
items: ({ query }: { query: string }) => {
return GROUPS.map(group => ({
...group,
commands: group.commands
.filter(item => {
const labelNormalized = item.label.toLowerCase().trim()
const queryNormalized = query.toLowerCase().trim()
if (item.aliases) {
const aliases = item.aliases.map(alias => alias.toLowerCase().trim())
return labelNormalized.includes(queryNormalized) || aliases.includes(queryNormalized)
}
return labelNormalized.includes(queryNormalized)
})
.filter(command => (command.shouldBeHidden ? !command.shouldBeHidden(this.editor) : true))
.map(command => ({
...command,
isEnabled: true
}))
})).filter(group => group.commands.length > 0)
},
render: () => {
let component: any
let scrollHandler: (() => void) | null = null
return {
onStart: (props: SuggestionProps) => {
component = new ReactRenderer(MenuList, {
props,
editor: props.editor
})
const { view } = props.editor
const editorNode = view.dom as HTMLElement
const getReferenceClientRect = () => {
if (!props.clientRect) {
return props.editor.storage[EXTENSION_NAME].rect
}
const rect = props.clientRect()
if (!rect) {
return props.editor.storage[EXTENSION_NAME].rect
}
let yPos = rect.y
if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40
yPos = rect.y - diff
}
const editorXOffset = editorNode.getBoundingClientRect().x
return new DOMRect(rect.x, yPos, rect.width, rect.height)
}
scrollHandler = () => {
popup?.[0].setProps({
getReferenceClientRect
})
}
view.dom.parentElement?.addEventListener("scroll", scrollHandler)
popup?.[0].setProps({
getReferenceClientRect,
appendTo: () => document.body,
content: component.element
})
popup?.[0].show()
},
onUpdate(props: SuggestionProps) {
component.updateProps(props)
const { view } = props.editor
const editorNode = view.dom as HTMLElement
const getReferenceClientRect = () => {
if (!props.clientRect) {
return props.editor.storage[EXTENSION_NAME].rect
}
const rect = props.clientRect()
if (!rect) {
return props.editor.storage[EXTENSION_NAME].rect
}
return new DOMRect(rect.x, rect.y, rect.width, rect.height)
}
let scrollHandler = () => {
popup?.[0].setProps({
getReferenceClientRect
})
}
view.dom.parentElement?.addEventListener("scroll", scrollHandler)
props.editor.storage[EXTENSION_NAME].rect = props.clientRect
? getReferenceClientRect()
: {
width: 0,
height: 0,
left: 0,
top: 0,
right: 0,
bottom: 0
}
popup?.[0].setProps({
getReferenceClientRect
})
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
popup?.[0].hide()
return true
}
if (!popup?.[0].state.isShown) {
popup?.[0].show()
}
return component.ref?.onKeyDown(props)
},
onExit(props) {
popup?.[0].hide()
if (scrollHandler) {
const { view } = props.editor
view.dom.parentElement?.removeEventListener("scroll", scrollHandler)
}
component.destroy()
}
}
}
})
]
},
addStorage() {
return {
rect: {
width: 0,
height: 0,
left: 0,
top: 0,
right: 0,
bottom: 0
}
}
}
})
export default SlashCommand

View File

@@ -0,0 +1,26 @@
import { Editor } from "@tiptap/core"
import { icons } from "lucide-react"
export interface Group {
name: string
title: string
commands: Command[]
}
export interface Command {
name: string
label: string
description: string
aliases?: string[]
shortcuts: string[]
iconName: keyof typeof icons
action: (editor: Editor) => void
shouldBeHidden?: (editor: Editor) => boolean
}
export interface MenuListProps {
editor: Editor
items: Group[]
command: (command: Command) => void
}

View File

@@ -0,0 +1,153 @@
import { Extension } from "@tiptap/core"
import { Bold, BoldOptions } from "@tiptap/extension-bold"
import { Document } from "@tiptap/extension-document"
import { Gapcursor } from "@tiptap/extension-gapcursor"
import { HardBreak, HardBreakOptions } from "@tiptap/extension-hard-break"
import { Italic, ItalicOptions } from "@tiptap/extension-italic"
import { ListItem, ListItemOptions } from "@tiptap/extension-list-item"
import { Strike, StrikeOptions } from "@tiptap/extension-strike"
import { Text } from "@tiptap/extension-text"
import { FocusClasses, FocusOptions } from "@tiptap/extension-focus"
import { Typography, TypographyOptions } from "@tiptap/extension-typography"
import { Placeholder, PlaceholderOptions } from "@tiptap/extension-placeholder"
import { History, HistoryOptions } from "@tiptap/extension-history"
export interface StarterKitOptions {
/**
* If set to false, the bold extension will not be registered
* @example bold: false
*/
bold: Partial<BoldOptions> | false
/**
* If set to false, the document extension will not be registered
* @example document: false
*/
document: false
/**
* If set to false, the gapcursor extension will not be registered
* @example gapcursor: false
*/
gapcursor: false
/**
* If set to false, the hardBreak extension will not be registered
* @example hardBreak: false
*/
hardBreak: Partial<HardBreakOptions> | false
/**
* If set to false, the history extension will not be registered
* @example history: false
*/
history: Partial<HistoryOptions> | false
/**
* If set to false, the italic extension will not be registered
* @example italic: false
*/
italic: Partial<ItalicOptions> | false
/**
* If set to false, the listItem extension will not be registered
* @example listItem: false
*/
listItem: Partial<ListItemOptions> | false
/**
* If set to false, the strike extension will not be registered
* @example strike: false
*/
strike: Partial<StrikeOptions> | false
/**
* If set to false, the text extension will not be registered
* @example text: false
*/
text: false
/**
* If set to false, the typography extension will not be registered
* @example typography: false
*/
typography: Partial<TypographyOptions> | false
/**
* If set to false, the placeholder extension will not be registered
* @example placeholder: false
*/
placeholder: Partial<PlaceholderOptions> | false
/**
* If set to false, the focus extension will not be registered
* @example focus: false
*/
focus: Partial<FocusOptions> | false
}
/**
* The starter kit is a collection of essential editor extensions.
*
* Its a good starting point for building your own editor.
*/
export const StarterKit = Extension.create<StarterKitOptions>({
name: "starterKit",
addExtensions() {
const extensions = []
if (this.options.bold !== false) {
extensions.push(Bold.configure(this.options?.bold))
}
if (this.options.document !== false) {
extensions.push(Document.configure(this.options?.document))
}
if (this.options.gapcursor !== false) {
extensions.push(Gapcursor.configure(this.options?.gapcursor))
}
if (this.options.hardBreak !== false) {
extensions.push(HardBreak.configure(this.options?.hardBreak))
}
if (this.options.history !== false) {
extensions.push(History.configure(this.options?.history))
}
if (this.options.italic !== false) {
extensions.push(Italic.configure(this.options?.italic))
}
if (this.options.listItem !== false) {
extensions.push(ListItem.configure(this.options?.listItem))
}
if (this.options.strike !== false) {
extensions.push(Strike.configure(this.options?.strike))
}
if (this.options.text !== false) {
extensions.push(Text.configure(this.options?.text))
}
if (this.options.typography !== false) {
extensions.push(Typography.configure(this.options?.typography))
}
if (this.options.placeholder !== false) {
extensions.push(Placeholder.configure(this.options?.placeholder))
}
if (this.options.focus !== false) {
extensions.push(FocusClasses.configure(this.options?.focus))
}
return extensions
}
})
export default StarterKit

View File

@@ -0,0 +1,50 @@
import { NodeViewContent, Editor, NodeViewWrapper } from "@tiptap/react"
import { Icon } from "../../../components/ui/icon"
import { useCallback } from "react"
import { Node as ProseMirrorNode } from "@tiptap/pm/model"
import { Node } from "@tiptap/core"
interface TaskItemProps {
editor: Editor
node: ProseMirrorNode
updateAttributes: (attrs: Record<string, any>) => void
extension: Node
}
export const TaskItemView: React.FC<TaskItemProps> = ({ node, updateAttributes, editor, extension }) => {
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked
if (!editor.isEditable && !extension.options.onReadOnlyChecked) {
return
}
if (editor.isEditable) {
updateAttributes({ checked })
} else if (extension.options.onReadOnlyChecked) {
if (!extension.options.onReadOnlyChecked(node, checked)) {
event.target.checked = !checked
}
}
},
[editor.isEditable, extension.options, node, updateAttributes]
)
return (
<NodeViewWrapper as="li" data-type="taskItem" data-checked={node.attrs.checked}>
<div className="taskItem-checkbox-container">
<Icon name="GripVertical" data-drag-handle className="taskItem-drag-handle" />
<label>
<input type="checkbox" checked={node.attrs.checked} onChange={handleChange} className="taskItem-checkbox" />
</label>
</div>
<div className="taskItem-content">
<NodeViewContent />
</div>
</NodeViewWrapper>
)
}
export default TaskItemView

View File

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

View File

@@ -0,0 +1,64 @@
import { ReactNodeViewRenderer } from "@tiptap/react"
import { mergeAttributes } from "@tiptap/core"
import { TaskItemView } from "./components/task-item-view"
import { TaskItem as TiptapTaskItem } from "@tiptap/extension-task-item"
export const TaskItem = TiptapTaskItem.extend({
name: "taskItem",
draggable: true,
addOptions() {
return {
...this.parent?.(),
nested: true
}
},
addAttributes() {
return {
checked: {
default: false,
keepOnSplit: false,
parseHTML: element => {
const dataChecked = element.getAttribute("data-checked")
return dataChecked === "" || dataChecked === "true"
},
renderHTML: attributes => ({
"data-checked": attributes.checked
})
}
}
},
renderHTML({ node, HTMLAttributes }) {
return [
"li",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
"data-type": this.name
}),
[
"div",
{ class: "taskItem-checkbox-container" },
[
"label",
[
"input",
{
type: "checkbox",
checked: node.attrs.checked ? "checked" : null,
class: "taskItem-checkbox"
}
]
]
],
["div", { class: "taskItem-content" }, 0]
]
},
addNodeView() {
return ReactNodeViewRenderer(TaskItemView, {
as: "span"
})
}
})

View File

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

View File

@@ -0,0 +1,12 @@
import { TaskList as TiptapTaskList } from "@tiptap/extension-task-list"
export const TaskList = TiptapTaskList.extend({
addOptions() {
return {
...this.parent?.(),
HTMLAttributes: {
class: "list-node"
}
}
}
})

View File

@@ -0,0 +1,30 @@
import { Editor } from "@tiptap/react"
import { useCallback } from "react"
export const useTextmenuCommands = (editor: Editor) => {
const onBold = useCallback(() => editor.chain().focus().toggleBold().run(), [editor])
const onItalic = useCallback(() => editor.chain().focus().toggleItalic().run(), [editor])
const onStrike = useCallback(() => editor.chain().focus().toggleStrike().run(), [editor])
const onCode = useCallback(() => editor.chain().focus().toggleCode().run(), [editor])
const onCodeBlock = useCallback(() => editor.chain().focus().toggleCodeBlock().run(), [editor])
const onQuote = useCallback(() => editor.chain().focus().toggleBlockquote().run(), [editor])
const onLink = useCallback(
(url: string, inNewTab?: boolean) =>
editor
.chain()
.focus()
.setLink({ href: url, target: inNewTab ? "_blank" : "" })
.run(),
[editor]
)
return {
onBold,
onItalic,
onStrike,
onCode,
onCodeBlock,
onQuote,
onLink
}
}

View File

@@ -0,0 +1,34 @@
import { Editor } from "@tiptap/react"
import { useCallback } from "react"
import { ShouldShowProps } from "../types"
import { isCustomNodeSelected, isTextSelected } from "../lib/utils"
export const useTextmenuStates = (editor: Editor) => {
const shouldShow = useCallback(
({ view, from }: ShouldShowProps) => {
if (!view) {
return false
}
const domAtPos = view.domAtPos(from || 0).node as HTMLElement
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement
const node = nodeDOM || domAtPos
if (isCustomNodeSelected(editor, node)) {
return false
}
return isTextSelected({ editor })
},
[editor]
)
return {
isBold: editor.isActive("bold"),
isItalic: editor.isActive("italic"),
isStrike: editor.isActive("strike"),
isUnderline: editor.isActive("underline"),
isCode: editor.isActive("code"),
shouldShow
}
}

View File

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

View File

@@ -0,0 +1,146 @@
"use client"
import * as React from "react"
import { EditorContent, useEditor } from "@tiptap/react"
import { Editor, Content } from "@tiptap/core"
import { useThrottleFn } from "react-use"
import { BubbleMenu } from "./components/bubble-menu"
import { createExtensions } from "./extensions"
import "./styles/index.css"
import { cn } from "@/lib/utils"
import { getOutput } from "./lib/utils"
export interface LAEditorProps extends Omit<React.HTMLProps<HTMLDivElement>, "value"> {
initialContent?: any
output?: "html" | "json" | "text"
placeholder?: string
editorClassName?: string
onUpdate?: (content: Content) => void
onBlur?: (content: Content) => void
onNewBlock?: (content: Content) => void
value?: Content
throttleDelay?: number
}
export interface LAEditorRef {
focus: () => void
}
interface CustomEditor extends Editor {
previousBlockCount?: number
}
export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
(
{
initialContent,
value,
placeholder,
output = "html",
editorClassName,
className,
onUpdate,
onBlur,
onNewBlock,
throttleDelay = 1000,
...props
},
ref
) => {
const [content, setContent] = React.useState<Content | undefined>(value)
const throttledContent = useThrottleFn(defaultContent => defaultContent, throttleDelay, [content])
const [lastThrottledContent, setLastThrottledContent] = React.useState(throttledContent)
const handleUpdate = React.useCallback(
(editor: Editor) => {
const newContent = getOutput(editor, output)
setContent(newContent)
const customEditor = editor as CustomEditor
const json = customEditor.getJSON()
if (json.content && Array.isArray(json.content)) {
const currentBlockCount = json.content.length
if (
typeof customEditor.previousBlockCount === "number" &&
currentBlockCount > customEditor.previousBlockCount
) {
requestAnimationFrame(() => {
onNewBlock?.(newContent)
})
}
customEditor.previousBlockCount = currentBlockCount
}
},
[output, onNewBlock]
)
const editor = useEditor({
autofocus: false,
extensions: createExtensions({ placeholder }),
editorProps: {
attributes: {
autocomplete: "off",
autocorrect: "off",
autocapitalize: "off",
class: editorClassName || ""
}
},
onCreate: ({ editor }) => {
if (editor.isEmpty && value) {
editor.commands.setContent(value)
}
},
onUpdate: ({ editor }) => handleUpdate(editor),
onBlur: ({ editor }) => {
requestAnimationFrame(() => {
onBlur?.(getOutput(editor, output))
})
}
})
React.useEffect(() => {
if (editor && initialContent) {
// https://github.com/ueberdosis/tiptap/issues/3764
setTimeout(() => {
editor.commands.setContent(initialContent)
})
}
}, [editor, initialContent])
React.useEffect(() => {
if (lastThrottledContent !== throttledContent) {
setLastThrottledContent(throttledContent)
requestAnimationFrame(() => {
onUpdate?.(throttledContent!)
})
}
}, [throttledContent, lastThrottledContent, onUpdate])
React.useImperativeHandle(
ref,
() => ({
focus: () => editor?.commands.focus()
}),
[editor]
)
if (!editor) {
return null
}
return (
<div className={cn("la-editor relative flex h-full w-full grow flex-col", className)} {...props}>
<EditorContent editor={editor} />
<BubbleMenu editor={editor} />
</div>
)
}
)
LAEditor.displayName = "LAEditor"
export default LAEditor

View File

@@ -0,0 +1,14 @@
import { Editor } from "@tiptap/core"
import { LAEditorProps } from "../../la-editor"
export function getOutput(editor: Editor, output: LAEditorProps["output"]) {
if (output === "html") return editor.getHTML()
if (output === "json") return editor.getJSON()
if (output === "text") return editor.getText()
return ""
}
export * from "./keyboard"
export * from "./platform"
export * from "./isCustomNodeSelected"
export * from "./isTextSelected"

View File

@@ -0,0 +1,28 @@
import { Editor } from "@tiptap/react"
import { Link } from "@/components/la-editor/extensions/link"
import { HorizontalRule } from "@/components/la-editor/extensions/horizontal-rule"
export const isTableGripSelected = (node: HTMLElement) => {
let container = node
while (container && !["TD", "TH"].includes(container.tagName)) {
container = container.parentElement!
}
const gripColumn = container && container.querySelector && container.querySelector("a.grip-column.selected")
const gripRow = container && container.querySelector && container.querySelector("a.grip-row.selected")
if (gripColumn || gripRow) {
return true
}
return false
}
export const isCustomNodeSelected = (editor: Editor, node: HTMLElement) => {
const customNodes = [HorizontalRule.name, Link.name]
return customNodes.some(type => editor.isActive(type)) || isTableGripSelected(node)
}
export default isCustomNodeSelected

View File

@@ -0,0 +1,25 @@
import { isTextSelection } from "@tiptap/core"
import { Editor } from "@tiptap/react"
export const isTextSelected = ({ editor }: { editor: Editor }) => {
const {
state: {
doc,
selection,
selection: { empty, from, to }
}
} = editor
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection)
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false
}
return true
}
export default isTextSelected

View File

@@ -0,0 +1,25 @@
import { isMacOS } from "./platform"
export const getShortcutKey = (key: string) => {
const lowercaseKey = key.toLowerCase()
const macOS = isMacOS()
switch (lowercaseKey) {
case "mod":
return macOS ? "⌘" : "Ctrl"
case "alt":
return macOS ? "⌥" : "Alt"
case "shift":
return macOS ? "⇧" : "Shift"
default:
return key
}
}
export const getShortcutKeys = (keys: string | string[], separator: string = "") => {
const keyArray = Array.isArray(keys) ? keys : keys.split(/\s+/)
const shortcutKeys = keyArray.map(getShortcutKey)
return shortcutKeys.join(separator)
}
export default { getShortcutKey, getShortcutKeys }

View File

@@ -0,0 +1,46 @@
export interface NavigatorWithUserAgentData extends Navigator {
userAgentData?: {
brands: { brand: string; version: string }[]
mobile: boolean
platform: string
getHighEntropyValues: (hints: string[]) => Promise<{
platform: string
platformVersion: string
uaFullVersion: string
}>
}
}
let isMac: boolean | undefined
const getPlatform = () => {
const nav = navigator as NavigatorWithUserAgentData
if (nav.userAgentData) {
if (nav.userAgentData.platform) {
return nav.userAgentData.platform
}
nav.userAgentData
.getHighEntropyValues(["platform"])
.then(highEntropyValues => {
if (highEntropyValues.platform) {
return highEntropyValues.platform
}
})
.catch(() => {
return navigator.platform || ""
})
}
return navigator.platform || ""
}
export const isMacOS = () => {
if (isMac === undefined) {
isMac = getPlatform().toLowerCase().includes("mac")
}
return isMac
}
export default isMacOS

View File

@@ -0,0 +1,140 @@
:root {
--la-font-size-regular: 0.9375rem;
--la-code-background: rgba(8, 43, 120, 0.047);
--la-code-color: rgb(212, 212, 212);
--la-secondary: rgb(157, 157, 159);
--la-pre-background: rgb(236, 236, 236);
--la-pre-border: rgb(224, 224, 224);
--la-pre-color: rgb(47, 47, 49);
--la-hr: rgb(220, 220, 220);
--la-drag-handle-hover: rgb(92, 92, 94);
--hljs-string: rgb(170, 67, 15);
--hljs-title: rgb(176, 136, 54);
--hljs-comment: rgb(153, 153, 153);
--hljs-keyword: rgb(12, 94, 177);
--hljs-attr: rgb(58, 146, 188);
--hljs-literal: rgb(200, 43, 15);
--hljs-name: rgb(37, 151, 146);
--hljs-selector-tag: rgb(200, 80, 15);
--hljs-number: rgb(61, 160, 103);
}
.dark .ProseMirror {
--la-code-background: rgba(255, 255, 255, 0.075);
--la-code-color: rgb(44, 46, 51);
--la-secondary: rgb(89, 90, 92);
--la-pre-background: rgb(8, 8, 8);
--la-pre-border: rgb(35, 37, 42);
--la-pre-color: rgb(227, 228, 230);
--la-hr: rgb(38, 40, 45);
--la-drag-handle-hover: rgb(150, 151, 153);
--hljs-string: rgb(218, 147, 107);
--hljs-title: rgb(241, 213, 157);
--hljs-comment: rgb(170, 170, 170);
--hljs-keyword: rgb(102, 153, 204);
--hljs-attr: rgb(144, 202, 232);
--hljs-literal: rgb(242, 119, 122);
--hljs-name: rgb(95, 192, 160);
--hljs-selector-tag: rgb(232, 199, 133);
--hljs-number: rgb(182, 231, 182);
}
.la-editor .ProseMirror {
@apply flex max-w-full flex-1 cursor-text flex-col;
@apply z-0 outline-0;
}
.la-editor .ProseMirror > div.editor {
@apply block flex-1 whitespace-pre-wrap;
}
.la-editor .ProseMirror .block-node:not(:last-child),
.la-editor .ProseMirror .list-node:not(:last-child),
.la-editor .ProseMirror .text-node:not(:last-child) {
@apply mb-2.5;
}
.la-editor .ProseMirror ol,
.la-editor .ProseMirror ul {
@apply pl-6;
}
.la-editor .ProseMirror blockquote,
.la-editor .ProseMirror dl,
.la-editor .ProseMirror ol,
.la-editor .ProseMirror p,
.la-editor .ProseMirror pre,
.la-editor .ProseMirror ul {
@apply m-0;
}
.la-editor .ProseMirror li {
@apply leading-7;
}
.la-editor .ProseMirror p {
@apply break-words;
}
.la-editor .ProseMirror li .text-node:has(+ .list-node),
.la-editor .ProseMirror li > .list-node,
.la-editor .ProseMirror li > .text-node,
.la-editor .ProseMirror li p {
@apply mb-0;
}
.la-editor .ProseMirror blockquote {
@apply relative pl-3.5;
}
.la-editor .ProseMirror blockquote::before,
.la-editor .ProseMirror blockquote.is-empty::before {
@apply bg-accent absolute bottom-0 left-0 top-0 h-full w-1 rounded-sm content-[''];
}
.la-editor .ProseMirror hr {
@apply my-3 h-0.5 w-full border-none bg-[var(--la-hr)];
}
.la-editor .ProseMirror-focused hr.ProseMirror-selectednode {
@apply outline-muted-foreground rounded-full outline outline-2 outline-offset-1;
}
.la-editor .ProseMirror .ProseMirror-gapcursor {
@apply pointer-events-none absolute hidden;
}
.la-editor .ProseMirror .ProseMirror-hideselection {
@apply caret-transparent;
}
.la-editor .ProseMirror.resize-cursor {
@apply cursor-col-resize;
}
.la-editor .ProseMirror .selection {
@apply inline-block;
}
.la-editor .ProseMirror .selection,
.la-editor .ProseMirror *::selection,
::selection {
@apply bg-primary/40;
}
/* Override native selection when custom selection is present */
.la-editor .ProseMirror .selection::selection {
background: transparent;
}
[data-theme="slash-command"] {
width: 1000vw;
}
@import "./partials/code.css";
@import "./partials/placeholder.css";
@import "./partials/lists.css";
@import "./partials/typography.css";

View File

@@ -0,0 +1,86 @@
.la-editor .ProseMirror code.inline {
@apply rounded border border-[var(--la-code-color)] bg-[var(--la-code-background)] px-1 py-0.5 text-sm;
}
.la-editor .ProseMirror pre {
@apply relative overflow-auto rounded border font-mono text-sm;
@apply border-[var(--la-pre-border)] bg-[var(--la-pre-background)] text-[var(--la-pre-color)];
@apply hyphens-none whitespace-pre text-left;
}
.la-editor .ProseMirror code {
@apply break-words leading-[1.7em];
}
.la-editor .ProseMirror pre code {
@apply block overflow-x-auto p-3.5;
}
.la-editor .ProseMirror pre {
.hljs-keyword,
.hljs-operator,
.hljs-function,
.hljs-built_in,
.hljs-builtin-name {
color: var(--hljs-keyword);
}
.hljs-attr,
.hljs-symbol,
.hljs-property,
.hljs-attribute,
.hljs-variable,
.hljs-template-variable,
.hljs-params {
color: var(--hljs-attr);
}
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-type,
.hljs-addition {
color: var(--hljs-name);
}
.hljs-string,
.hljs-bullet {
color: var(--hljs-string);
}
.hljs-title,
.hljs-subst,
.hljs-section {
color: var(--hljs-title);
}
.hljs-literal,
.hljs-type,
.hljs-deletion {
color: var(--hljs-literal);
}
.hljs-selector-tag,
.hljs-selector-id,
.hljs-selector-class {
color: var(--hljs-selector-tag);
}
.hljs-number {
color: var(--hljs-number);
}
.hljs-comment,
.hljs-meta,
.hljs-quote {
color: var(--hljs-comment);
}
.hljs-emphasis {
@apply italic;
}
.hljs-strong {
@apply font-bold;
}
}

View File

@@ -0,0 +1,82 @@
.la-editor div.tiptap p {
@apply text-[var(--la-font-size-regular)];
}
.la-editor .ProseMirror ol {
@apply list-decimal;
}
.la-editor .ProseMirror ol ol {
list-style: lower-alpha;
}
.la-editor .ProseMirror ol ol ol {
list-style: lower-roman;
}
.la-editor .ProseMirror ul {
list-style: disc;
}
.la-editor .ProseMirror ul ul {
list-style: circle;
}
.la-editor .ProseMirror ul ul ul {
list-style: square;
}
.la-editor .ProseMirror ul[data-type="taskList"] {
@apply list-none pl-1;
}
.la-editor .ProseMirror ul[data-type="taskList"] p {
@apply m-0;
}
.la-editor .ProseMirror ul[data-type="taskList"] li > label {
@apply mr-2 mt-0.5 flex-none select-none;
}
.la-editor .ProseMirror li[data-type="taskItem"] {
@apply flex flex-row items-start;
}
.la-editor .ProseMirror li[data-type="taskItem"] .taskItem-checkbox-container {
@apply relative pr-2;
}
.la-editor .ProseMirror .taskItem-drag-handle {
@apply absolute -left-5 top-1.5 h-[18px] w-[18px] cursor-move pl-0.5 text-[var(--la-secondary)] opacity-0;
}
.la-editor
.ProseMirror
li[data-type="taskItem"]:hover:not(:has(li:hover))
> .taskItem-checkbox-container
> .taskItem-drag-handle {
@apply opacity-100;
}
.la-editor .ProseMirror .taskItem-drag-handle:hover {
@apply text-[var(--la-drag-handle-hover)];
}
.la-editor .ProseMirror .taskItem-checkbox {
fill-opacity: 0;
@apply h-3.5 w-3.5 flex-shrink-0 cursor-pointer select-none appearance-none rounded border border-solid border-[var(--la-secondary)] bg-transparent bg-[1px_2px] p-0.5 align-middle transition-colors duration-75 ease-out;
}
.la-editor .ProseMirror .taskItem-checkbox:checked {
@apply border-primary bg-primary bg-no-repeat;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20width=%2210%22%20height=%229%22%20viewBox=%220%200%2010%208%22%20xmlns=%22http://www.w3.org/2000/svg%22%20fill=%22%23fbfbfb%22%3E%3Cpath%20d=%22M3.46975%205.70757L1.88358%204.1225C1.65832%203.8974%201.29423%203.8974%201.06897%204.1225C0.843675%204.34765%200.843675%204.7116%201.06897%204.93674L3.0648%206.93117C3.29006%207.15628%203.65414%207.15628%203.8794%206.93117L8.93103%201.88306C9.15633%201.65792%209.15633%201.29397%208.93103%201.06883C8.70578%200.843736%208.34172%200.843724%208.11646%201.06879C8.11645%201.0688%208.11643%201.06882%208.11642%201.06883L3.46975%205.70757Z%22%20stroke-width=%220.2%22%20/%3E%3C/svg%3E");
}
.la-editor .ProseMirror .taskItem-content {
@apply min-w-0 flex-1;
}
.la-editor .ProseMirror li[data-checked="true"] .taskItem-content > :not([data-type="taskList"]),
.la-editor .ProseMirror li[data-checked="true"] .taskItem-content .taskItem-checkbox {
@apply opacity-75;
}

View File

@@ -0,0 +1,12 @@
.la-editor .ProseMirror .is-empty::before {
@apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)];
}
.la-editor .ProseMirror.ProseMirror-focused > p.has-focus.is-empty::before {
content: "Type / for commands...";
}
.la-editor .ProseMirror > p.is-editor-empty::before {
content: attr(data-placeholder);
@apply pointer-events-none float-left h-0 text-[var(--la-secondary)];
}

View File

@@ -0,0 +1,27 @@
.la-editor .ProseMirror .heading-node {
@apply relative font-semibold;
}
.la-editor .ProseMirror .heading-node:first-child {
@apply mt-0;
}
.la-editor .ProseMirror h1 {
@apply mb-4 mt-[46px] text-[1.375rem] leading-7 tracking-[-0.004375rem];
}
.la-editor .ProseMirror h2 {
@apply mb-3.5 mt-8 text-[1.1875rem] leading-7 tracking-[0.003125rem];
}
.la-editor .ProseMirror h3 {
@apply mb-3 mt-6 text-[1.0625rem] leading-6 tracking-[0.00625rem];
}
.la-editor .ProseMirror a.link {
@apply text-primary cursor-pointer;
}
.la-editor .ProseMirror a.link:hover {
@apply underline;
}

View File

@@ -0,0 +1,20 @@
import React from "react"
import { Editor as CoreEditor } from "@tiptap/core"
import { Editor } from "@tiptap/react"
import { EditorState } from "@tiptap/pm/state"
import { EditorView } from "@tiptap/pm/view"
export interface MenuProps {
editor: Editor
appendTo?: React.RefObject<any>
shouldHide?: boolean
}
export interface ShouldShowProps {
editor?: CoreEditor
view: EditorView
state?: EditorState
oldState?: EditorState
from?: number
to?: number
}

View File

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

View File

@@ -0,0 +1,438 @@
"use client"
import React, { useState, useEffect, useRef } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { useDebounce } from "react-use"
import { toast } from "sonner"
import Image from "next/image"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Form, FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
import { BoxIcon, PlusIcon, Trash2Icon, PieChartIcon, Bookmark, GraduationCap, Check } from "lucide-react"
import { cn, ensureUrlProtocol, generateUniqueSlug, isUrl as LibIsUrl } from "@/lib/utils"
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
import { LinkMetadata, PersonalLink } from "@/lib/schema/personal-link"
import { createLinkSchema } from "./schema"
import { TopicSelector } from "./partial/topic-section"
import { useAtom } from "jotai"
import { linkEditIdAtom, linkShowCreateAtom } from "@/store/link"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useKey } from "react-use"
export type LinkFormValues = z.infer<typeof createLinkSchema>
const DEFAULT_FORM_VALUES: Partial<LinkFormValues> = {
title: "",
description: "",
topic: "",
isLink: false,
meta: null
}
const LinkManage: React.FC = () => {
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
const [, setEditId] = useAtom(linkEditIdAtom)
const formRef = useRef<HTMLFormElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const toggleForm = (event: React.MouseEvent) => {
event.stopPropagation()
setShowCreate(prev => !prev)
}
useEffect(() => {
if (!showCreate) {
formRef.current?.reset()
setEditId(null)
}
}, [showCreate, setEditId])
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
if (
formRef.current &&
!formRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setShowCreate(false)
}
}
if (showCreate) {
document.addEventListener("mousedown", handleOutsideClick)
}
return () => {
document.removeEventListener("mousedown", handleOutsideClick)
}
}, [showCreate, setShowCreate])
useKey("Escape", () => {
setShowCreate(false)
})
return (
<>
{showCreate && (
<div className="z-50">
<LinkForm ref={formRef} onSuccess={() => setShowCreate(false)} onCancel={() => setShowCreate(false)} />
</div>
)}
<CreateButton ref={buttonRef} onClick={toggleForm} isOpen={showCreate} />
</>
)
}
const CreateButton = React.forwardRef<
HTMLButtonElement,
{
onClick: (event: React.MouseEvent) => void
isOpen: boolean
}
>(({ onClick, isOpen }, ref) => (
<Button
ref={ref}
className={cn(
"absolute bottom-4 right-4 size-12 rounded-full bg-[#274079] p-0 text-white transition-transform hover:bg-[#274079]/90",
{ "rotate-45 transform": isOpen }
)}
onClick={onClick}
>
<PlusIcon className="size-6" />
</Button>
))
CreateButton.displayName = "CreateButton"
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
onSuccess?: () => void
onCancel?: () => void
personalLink?: PersonalLink
}
const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess, onCancel, personalLink }, ref) => {
const selectedLink = useCoState(PersonalLink, personalLink?.id)
const [isFetching, setIsFetching] = useState(false)
const { me } = useAccount()
const form = useForm<LinkFormValues>({
resolver: zodResolver(createLinkSchema),
defaultValues: DEFAULT_FORM_VALUES
})
const title = form.watch("title")
const [originalLink, setOriginalLink] = useState<string>("")
const [linkEntered, setLinkEntered] = useState(false)
const [debouncedText, setDebouncedText] = useState<string>("")
useDebounce(() => setDebouncedText(title), 300, [title])
const [showStatusOptions, setShowStatusOptions] = useState(false)
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
const statusOptions = [
{
text: "To Learn",
icon: <Bookmark size={16} />,
color: "text-white/70"
},
{
text: "Learning",
icon: <GraduationCap size={16} />,
color: "text-[#D29752]"
},
{ text: "Learned", icon: <Check size={16} />, color: "text-[#708F51]" }
]
const statusSelect = (status: string) => {
setSelectedStatus(status === selectedStatus ? null : status)
setShowStatusOptions(false)
}
useEffect(() => {
if (selectedLink) {
form.setValue("title", selectedLink.title)
form.setValue("description", selectedLink.description ?? "")
form.setValue("isLink", selectedLink.isLink)
form.setValue("meta", selectedLink.meta)
}
}, [selectedLink, form])
useEffect(() => {
const fetchMetadata = async (url: string) => {
setIsFetching(true)
try {
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "no-store" })
if (!res.ok) throw new Error("Failed to fetch metadata")
const data = await res.json()
form.setValue("isLink", true)
form.setValue("meta", data)
form.setValue("title", data.title)
form.setValue("description", data.description)
setOriginalLink(url)
} catch (err) {
form.setValue("isLink", false)
form.setValue("meta", null)
form.setValue("title", debouncedText)
form.setValue("description", "")
setOriginalLink("")
} finally {
setIsFetching(false)
}
}
const lowerText = debouncedText.toLowerCase()
if (linkEntered && LibIsUrl(lowerText)) {
fetchMetadata(ensureUrlProtocol(lowerText))
}
}, [debouncedText, form, linkEntered])
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && LibIsUrl(e.currentTarget.value.toLowerCase())) {
e.preventDefault()
setLinkEntered(true)
}
}
const onSubmit = (values: LinkFormValues) => {
if (isFetching) return
try {
let linkMetadata: LinkMetadata | undefined
const personalLinks = me.root?.personalLinks?.toJSON() || []
const slug = generateUniqueSlug(personalLinks, values.title)
if (values.isLink && values.meta) {
linkMetadata = LinkMetadata.create(values.meta, { owner: me._owner })
}
if (selectedLink) {
selectedLink.title = values.title
selectedLink.slug = slug
selectedLink.description = values.description ?? ""
selectedLink.isLink = values.isLink
if (selectedLink.meta) {
Object.assign(selectedLink.meta, values.meta)
}
// toast.success("Todo updated")
} else {
const newPersonalLink = PersonalLink.create(
{
title: values.title,
slug,
description: values.description,
sequence: me.root?.personalLinks?.length || 1,
completed: false,
isLink: values.isLink,
meta: linkMetadata
// topic: values.topic
},
{ owner: me._owner }
)
me.root?.personalLinks?.push(newPersonalLink)
}
form.reset(DEFAULT_FORM_VALUES)
onSuccess?.()
} catch (error) {
console.error("Failed to create/update link", error)
toast.error(personalLink ? "Failed to update link" : "Failed to create link")
}
}
const handleCancel: () => void = () => {
form.reset(DEFAULT_FORM_VALUES)
onCancel?.()
}
return (
<div className="p-3 transition-all">
<div className="bg-muted/50 rounded-md border">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1" ref={ref}>
<div className="flex flex-row p-3">
<div className="flex flex-auto flex-col gap-1.5">
<div className="flex flex-row items-start justify-between">
<div className="flex grow flex-row items-center gap-1.5">
<Button
type="button"
variant="secondary"
size="icon"
aria-label="Choose icon"
className="text-primary/60 size-7"
>
{form.watch("isLink") ? (
<Image
src={form.watch("meta")?.favicon || ""}
alt={form.watch("meta")?.title || ""}
className="size-5 rounded-md"
width={16}
height={16}
/>
) : (
<BoxIcon size={16} />
)}
</Button>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="grow space-y-0">
<FormLabel className="sr-only">Text</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
maxLength={100}
autoFocus
placeholder="Paste a link or write a link"
className="placeholder:text-primary/40 h-6 border-none p-1.5 font-medium focus-visible:outline-none focus-visible:ring-0"
onKeyDown={handleKeyDown}
/>
</FormControl>
</FormItem>
)}
/>
<span className="mr-5 max-w-[200px] truncate text-xs text-white/60">
{linkEntered
? originalLink
: LibIsUrl(form.watch("title").toLowerCase())
? 'Press "Enter" to confirm URL'
: ""}
</span>
</div>
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* <Button
size="icon"
type="button"
variant="ghost"
className="size-7 gap-x-2 text-sm"
>
<EllipsisIcon
size={16}
className="text-primary/60"
/>
</Button> */}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem className="group">
<Trash2Icon size={16} className="text-destructive mr-2 group-hover:text-red-500" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="relative">
<Button
size="icon"
type="button"
variant="ghost"
className="size-7 gap-x-2 text-sm"
onClick={() => setShowStatusOptions(!showStatusOptions)}
>
{selectedStatus ? (
(() => {
const option = statusOptions.find(opt => opt.text === selectedStatus)
return option
? React.cloneElement(option.icon, {
size: 16,
className: option.color
})
: null
})()
) : (
<PieChartIcon size={16} className="text-primary/60" />
)}
</Button>
{showStatusOptions && (
<div className="absolute right-0 mt-1 w-40 rounded-md bg-neutral-800 shadow-lg">
{statusOptions.map(option => (
<Button
key={option.text}
onClick={() => statusSelect(option.text)}
className={`flex w-full items-center justify-start space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-neutral-700 ${option.color} bg-inherit`}
>
{React.cloneElement(option.icon, {
size: 16,
className: option.color
})}
<span>{option.text}</span>
</Button>
))}
</div>
)}
</div>
</div>
</div>
<div className="flex flex-row items-center gap-1.5 pl-8">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="grow space-y-0">
<FormLabel className="sr-only">Description</FormLabel>
<FormControl>
<Textarea
{...field}
autoComplete="off"
placeholder="Description (optional)"
className="placeholder:text-primary/40 min-h-[24px] resize-none overflow-y-auto border-none p-1.5 text-xs font-medium shadow-none focus-visible:outline-none focus-visible:ring-0"
onInput={e => {
const target = e.target as HTMLTextAreaElement
target.style.height = "auto"
target.style.height = `${target.scrollHeight}px`
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</div>
<div className="flex flex-auto flex-row items-center justify-between gap-2 rounded-b-md border border-t px-3 py-2">
<div className="flex flex-row items-center gap-0.5">
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
<TopicSelector />
</div>
</div>
<div className="flex w-auto items-center justify-end">
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row gap-x-2">
<Button size="sm" type="button" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" disabled={isFetching}>
Save
</Button>
</div>
</div>
</div>
</form>
</Form>
</div>
</div>
)
})
LinkManage.displayName = "LinkManage"
LinkForm.displayName = "LinkForm"
export { LinkManage, LinkForm }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "size-8"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,62 @@
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(buttonVariants({ variant: "ghost" }), "h-8 w-8 p-0 font-normal aria-selected:opacity-100"),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeftIcon className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRightIcon className="h-4 w-4" />
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"border-primary focus-visible:ring-ring data-[state=checked]:bg-muted data-[state=checked]:text-primary peer h-4 w-4 shrink-0 rounded-sm border shadow focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { MagnifyingGlassIcon } from "@radix-ui/react-icons"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("bg-border -mx-1 h-px", className)} {...props} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"data-['disabled']:pointer-events-none data-['disabled']:opacity-50 aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props} />
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator
}

View File

@@ -0,0 +1,180 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("text-foreground px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn("bg-border -mx-1 my-1 h-px", className)} {...props} />
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props} />
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup
}

View File

@@ -0,0 +1,97 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}

View File

@@ -0,0 +1,182 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("bg-muted -mx-1 my-1 h-px", className)} {...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup
}

138
web/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
}
)
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
)
}
)
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p ref={ref} id={formDescriptionId} className={cn("text-muted-foreground text-[0.8rem]", className)} {...props} />
)
}
)
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-destructive text-[0.8rem] font-medium", className)}
{...props}
>
{body}
</p>
)
}
)
FormMessage.displayName = "FormMessage"
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,19 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"border-input ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("bg-muted -mx-1 my-1 h-px", className)} {...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton
}

View File

@@ -0,0 +1,22 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("bg-border shrink-0", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,40 @@
"use client"
import { CheckIcon, CircleXIcon } from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
expand
position="top-right"
duration={5000}
icons={{
success: <CheckIcon size={16} className="text-green-500" />,
error: <CircleXIcon size={16} className="text-red-500" />
}}
toastOptions={{
closeButton: true,
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
closeButton:
"group-[.toast]:hover:bg-primary-foreground group-[.toast]:absolute group-[.toast]:border-0 group-[.toast]:top-4 group-[.toast]:right-0.5 group-[.toast]:left-auto group-[.toast]:[&>svg]:size-3.5"
}
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,39 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground"
},
size: {
default: "h-9 px-3",
sm: "h-8 px-2",
lg: "h-10 px-3"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root ref={ref} className={cn(toggleVariants({ variant, size, className }))} {...props} />
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md px-3 py-1.5 text-xs",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }