mirror of
https://github.com/linsa-io/linsa.git
synced 2026-03-18 15:23:59 +01:00
fix: conflict
This commit is contained in:
50
web/components/custom/GuideCommunityToggle.tsx
Normal file
50
web/components/custom/GuideCommunityToggle.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface GuideCommunityToggleProps {
|
||||
topicName: string
|
||||
}
|
||||
|
||||
export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({ topicName }) => {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [view, setView] = useState<"guide" | "community">("guide")
|
||||
|
||||
useEffect(() => {
|
||||
setView(pathname.includes("/community/") ? "community" : "guide")
|
||||
}, [pathname])
|
||||
|
||||
const handleToggle = (newView: "guide" | "community") => {
|
||||
setView(newView)
|
||||
router.push(newView === "community" ? `/community/${topicName}` : `/${topicName}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-accent/70 relative flex h-8 w-48 items-center rounded-md">
|
||||
<div
|
||||
className="absolute h-8 w-[calc(50%-4px)] rounded-md transition-all duration-300 ease-in-out"
|
||||
style={{ left: view === "guide" ? "2px" : "calc(50% + 2px)" }}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
|
||||
view === "guide" ? "text-primary bg-accent" : "text-primary/50"
|
||||
)}
|
||||
onClick={() => handleToggle("guide")}
|
||||
>
|
||||
Guide
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
|
||||
view === "community" ? "text-primary bg-accent" : "text-primary/50"
|
||||
)}
|
||||
onClick={() => handleToggle("community")}
|
||||
>
|
||||
Community
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
web/components/custom/QuestionList.tsx
Normal file
65
web/components/custom/QuestionList.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Input } from "../ui/input"
|
||||
import { LaIcon } from "./la-icon"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface QuestionListProps {
|
||||
topicName: string
|
||||
onSelectQuestion: (question: Question) => void
|
||||
selectedQuestionId?: string
|
||||
}
|
||||
|
||||
export function QuestionList({ topicName, onSelectQuestion, selectedQuestionId }: QuestionListProps) {
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const mockQuestions: Question[] = Array(10)
|
||||
.fill(null)
|
||||
.map((_, index) => ({
|
||||
id: (index + 1).toString(),
|
||||
title: "What can I do offline in Figma?",
|
||||
author: "Ana",
|
||||
timestamp: "13:35"
|
||||
}))
|
||||
setQuestions(mockQuestions)
|
||||
}, [topicName])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="scrollbar-hide flex-grow overflow-y-auto">
|
||||
{questions.map(question => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col gap-2 rounded p-4",
|
||||
selectedQuestionId === question.id && "bg-red-500"
|
||||
)}
|
||||
onClick={() => onSelectQuestion(question)}
|
||||
>
|
||||
<div className="flex flex-row justify-between opacity-50">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<div className="h-6 w-6 rounded-full bg-slate-500" />
|
||||
<p className="text-sm font-medium">{question.author}</p>
|
||||
</div>
|
||||
<p>{question.timestamp}</p>
|
||||
</div>
|
||||
<h3 className="font-medium">{question.title}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative mt-4">
|
||||
<Input className="bg-input py-5 pr-10 focus:outline-none focus:ring-0" placeholder="Ask new question..." />
|
||||
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-60 hover:opacity-80">
|
||||
<LaIcon name="Send" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
web/components/custom/QuestionThread.tsx
Normal file
167
web/components/custom/QuestionThread.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { LaIcon } from "./la-icon"
|
||||
interface Answer {
|
||||
id: string
|
||||
author: string
|
||||
content: string
|
||||
timestamp: string
|
||||
replies?: Answer[]
|
||||
}
|
||||
|
||||
interface QuestionThreadProps {
|
||||
question: {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
timestamp: string
|
||||
}
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
const [answers, setAnswers] = useState<Answer[]>([])
|
||||
const [newAnswer, setNewAnswer] = useState("")
|
||||
const [replyTo, setReplyTo] = useState<Answer | null>(null)
|
||||
const [replyToAuthor, setReplyToAuthor] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const mockAnswers: Answer[] = [
|
||||
{
|
||||
id: "1",
|
||||
author: "Noone",
|
||||
content:
|
||||
"Just press Command + Just press Command + Just press Command + Just press Command + Just press Command +",
|
||||
timestamp: "14:40"
|
||||
}
|
||||
]
|
||||
setAnswers(mockAnswers)
|
||||
}, [question.id])
|
||||
|
||||
const sendReply = (answer: Answer) => {
|
||||
setReplyTo(answer)
|
||||
setReplyToAuthor(answer.author)
|
||||
setNewAnswer(`@${answer.author} `)
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
const length = inputRef.current.value.length
|
||||
inputRef.current.setSelectionRange(length, length)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const changeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
setNewAnswer(newValue)
|
||||
|
||||
if (replyToAuthor && !newValue.startsWith(`@${replyToAuthor}`)) {
|
||||
setReplyTo(null)
|
||||
setReplyToAuthor(null)
|
||||
}
|
||||
}
|
||||
|
||||
const sendAnswer = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newAnswer.trim()) {
|
||||
const newReply: Answer = {
|
||||
id: Date.now().toString(),
|
||||
author: "Me",
|
||||
content: newAnswer,
|
||||
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
if (replyTo) {
|
||||
setAnswers(prevAnswers =>
|
||||
prevAnswers.map(answer =>
|
||||
answer.id === replyTo.id ? { ...answer, replies: [...(answer.replies || []), newReply] } : answer
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setAnswers(prevAnswers => [...prevAnswers, newReply])
|
||||
}
|
||||
setNewAnswer("")
|
||||
setReplyTo(null)
|
||||
setReplyToAuthor(null)
|
||||
}
|
||||
}
|
||||
|
||||
const renderAnswers = (answers: Answer[], isReply = false) => (
|
||||
<div>
|
||||
{answers.map(answer => (
|
||||
<div key={answer.id} className={`flex-grow overflow-y-auto p-4 ${isReply ? "ml-3 border-l" : ""}`}>
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-accent mr-2 h-6 w-6 rounded-full"></div>
|
||||
<span className="text-sm">{answer.author}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="focus:outline-none">
|
||||
<LaIcon name="Ellipsis" className="mr-2 size-4 shrink-0 opacity-30 hover:opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<div className="w-[15px]">
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => sendReply(answer)}>
|
||||
<div className="mx-auto flex flex-row items-center gap-3">
|
||||
<LaIcon name="Reply" className="size-4 shrink-0" />
|
||||
Reply
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
<span className="text-sm opacity-30">{answer.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<p className="">{answer.content}</p>
|
||||
<LaIcon name="ThumbsUp" className="ml-2 size-4 shrink-0 opacity-70" />
|
||||
</div>
|
||||
{answer.replies && renderAnswers(answer.replies, true)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border-accent bg-background fixed bottom-0 right-0 top-0 z-50 flex h-full w-[40%] flex-col border-l">
|
||||
<div className="border-accent flex w-full justify-between border-b p-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-2 flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-accent h-8 w-8 rounded-full"></div>
|
||||
<h2 className="opacity-70">{question.author}</h2>
|
||||
</div>
|
||||
<button className="bg-accent rounded-full p-1.5 opacity-50 hover:opacity-80" onClick={onClose}>
|
||||
<LaIcon name="X" className="text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-md mb-1 font-semibold">{question.title}</p>
|
||||
<p className="text-sm opacity-70">{question.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto">{renderAnswers(answers)}</div>
|
||||
<div className="border-accent border-t p-4">
|
||||
<form className="relative" onSubmit={sendAnswer}>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newAnswer}
|
||||
onChange={changeInput}
|
||||
placeholder="Answer the question..."
|
||||
className="bg-input w-full rounded p-2 text-opacity-70 placeholder:text-opacity-50 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-50 hover:opacity-90">
|
||||
<LaIcon name="Send" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
web/components/custom/Shortcut/shortcut.tsx
Normal file
164
web/components/custom/Shortcut/shortcut.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { Sheet, SheetPortal, SheetOverlay, SheetTitle, sheetVariants, SheetDescription } from "@/components/ui/sheet"
|
||||
import { LaIcon } from "../la-icon"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
|
||||
export const showShortcutAtom = atom(false)
|
||||
|
||||
type ShortcutItem = {
|
||||
label: string
|
||||
keys: string[]
|
||||
then?: string[]
|
||||
}
|
||||
|
||||
type ShortcutSection = {
|
||||
title: string
|
||||
shortcuts: ShortcutItem[]
|
||||
}
|
||||
|
||||
const SHORTCUTS: ShortcutSection[] = [
|
||||
{
|
||||
title: "General",
|
||||
shortcuts: [
|
||||
{ label: "Open command menu", keys: ["⌘", "k"] },
|
||||
{ label: "Log out", keys: ["⌥", "⇧", "q"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Navigation",
|
||||
shortcuts: [
|
||||
{ label: "Go to link", keys: ["G"], then: ["L"] },
|
||||
{ label: "Go to page", keys: ["G"], then: ["P"] },
|
||||
{ label: "Go to topic", keys: ["G"], then: ["T"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Links",
|
||||
shortcuts: [{ label: "Create new link", keys: ["c"] }]
|
||||
},
|
||||
{
|
||||
title: "Pages",
|
||||
shortcuts: [{ label: "Create new page", keys: ["p"] }]
|
||||
}
|
||||
]
|
||||
|
||||
const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => (
|
||||
<kbd
|
||||
aria-hidden="true"
|
||||
className="inline-flex size-5 items-center justify-center rounded border font-sans text-xs capitalize"
|
||||
>
|
||||
{keyChar}
|
||||
</kbd>
|
||||
)
|
||||
|
||||
const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<dt className="flex grow items-center">
|
||||
<span className="text-muted-foreground text-left text-sm">{label}</span>
|
||||
</dt>
|
||||
<dd className="flex items-end">
|
||||
<span className="text-left">
|
||||
<span
|
||||
aria-label={keys.join(" ") + (then ? ` then ${then.join(" ")}` : "")}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
{keys.map((key, index) => (
|
||||
<ShortcutKey key={index} keyChar={key} />
|
||||
))}
|
||||
{then && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">then</span>
|
||||
{then.map((key, index) => (
|
||||
<ShortcutKey key={`then-${index}`} keyChar={key} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ShortcutSection: React.FC<ShortcutSection> = ({ title, shortcuts }) => (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="inline-flex gap-1.5 text-sm">{title}</h2>
|
||||
<dl className="m-0 flex flex-col gap-2">
|
||||
{shortcuts.map((shortcut, index) => (
|
||||
<ShortcutItem key={index} {...shortcut} />
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
|
||||
export function Shortcut() {
|
||||
const [showShortcut, setShowShortcut] = useAtom(showShortcutAtom)
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
|
||||
const { disableKeydown } = useKeyboardManager("shortcutSection")
|
||||
|
||||
React.useEffect(() => {
|
||||
disableKeydown(showShortcut)
|
||||
}, [showShortcut, disableKeydown])
|
||||
|
||||
const filteredShortcuts = React.useMemo(() => {
|
||||
if (!searchQuery) return SHORTCUTS
|
||||
|
||||
return SHORTCUTS.map(section => ({
|
||||
...section,
|
||||
shortcuts: section.shortcuts.filter(shortcut => shortcut.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
})).filter(section => section.shortcuts.length > 0)
|
||||
}, [searchQuery])
|
||||
|
||||
return (
|
||||
<Sheet open={showShortcut} onOpenChange={setShowShortcut}>
|
||||
<SheetPortal>
|
||||
<SheetOverlay className="bg-black/10" />
|
||||
<SheetPrimitive.Content
|
||||
className={cn(sheetVariants({ side: "right" }), "m-3 h-[calc(100vh-24px)] rounded-md p-0")}
|
||||
>
|
||||
<header className="flex flex-[0_0_auto] items-center gap-3 px-5 pb-4 pt-5">
|
||||
<SheetTitle className="text-base font-medium">Keyboard Shortcuts</SheetTitle>
|
||||
<SheetDescription className="sr-only">Quickly navigate around the app</SheetDescription>
|
||||
|
||||
<div className="flex-auto"></div>
|
||||
|
||||
<SheetPrimitive.Close className={cn(buttonVariants({ size: "icon", variant: "ghost" }), "size-6 p-0")}>
|
||||
<LaIcon name="X" className="text-muted-foreground size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-1 px-5 pb-6">
|
||||
<form className="relative flex items-center">
|
||||
<LaIcon name="Search" className="text-muted-foreground absolute left-3 size-4" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search shortcuts"
|
||||
className="border-muted-foreground/50 focus-visible:border-muted-foreground h-10 pl-10 focus-visible:ring-0"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<main className="flex-auto overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
|
||||
<div className="px-5 pb-5">
|
||||
<div role="region" aria-live="polite" className="flex flex-col gap-7">
|
||||
{filteredShortcuts.map((section, index) => (
|
||||
<ShortcutSection key={index} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { ClerkProvider } from "@clerk/nextjs"
|
||||
import { dark } from "@clerk/themes"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
export const ClerkProviderClient = ({ children }: { children: React.ReactNode }) => {
|
||||
return <ClerkProvider>{children}</ClerkProvider>
|
||||
interface ClerkProviderClientProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const ClerkProviderClient: React.FC<ClerkProviderClientProps> = ({ children }) => {
|
||||
const { theme, systemTheme } = useTheme()
|
||||
|
||||
const isDarkTheme = theme === "dark" || (theme === "system" && systemTheme === "dark")
|
||||
|
||||
const appearance = {
|
||||
baseTheme: isDarkTheme ? dark : undefined,
|
||||
variables: { colorPrimary: isDarkTheme ? "#dddddd" : "#2e2e2e" }
|
||||
}
|
||||
|
||||
return <ClerkProvider appearance={appearance}>{children}</ClerkProvider>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { SignIn } from "@clerk/nextjs"
|
||||
|
||||
export const SignInClient = () => {
|
||||
return <SignIn />
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<SignIn
|
||||
appearance={{
|
||||
elements: {
|
||||
formButtonPrimary: "bg-primary text-primary-foreground",
|
||||
card: "shadow-none"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
42
web/components/custom/column.tsx
Normal file
42
web/components/custom/column.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ColumnWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
style?: { [key: string]: string }
|
||||
}
|
||||
|
||||
interface ColumnTextProps extends React.HTMLAttributes<HTMLSpanElement> {}
|
||||
|
||||
const ColumnWrapper = React.forwardRef<HTMLDivElement, ColumnWrapperProps>(
|
||||
({ children, className, style, ...props }, ref) => (
|
||||
<div
|
||||
className={cn("flex grow flex-row items-center justify-start", className)}
|
||||
style={{
|
||||
width: "var(--width)",
|
||||
minWidth: "var(--min-width, min-content)",
|
||||
maxWidth: "min(var(--width), var(--max-width))",
|
||||
flexBasis: "var(--width)",
|
||||
...style
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
ColumnWrapper.displayName = "ColumnWrapper"
|
||||
|
||||
const ColumnText = React.forwardRef<HTMLSpanElement, ColumnTextProps>(({ children, className, ...props }, ref) => (
|
||||
<span className={cn("text-left text-xs", className)} ref={ref} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
))
|
||||
|
||||
ColumnText.displayName = "ColumnText"
|
||||
|
||||
export const Column = {
|
||||
Wrapper: ColumnWrapper,
|
||||
Text: ColumnText
|
||||
}
|
||||
135
web/components/custom/command-palette/command-data.ts
Normal file
135
web/components/custom/command-palette/command-data.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { icons } from "lucide-react"
|
||||
import { useCommandActions } from "./hooks/use-command-actions"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { HTMLLikeElement } from "@/lib/utils"
|
||||
|
||||
export type CommandAction = string | (() => void)
|
||||
|
||||
export interface CommandItemType {
|
||||
id?: string
|
||||
icon?: keyof typeof icons
|
||||
value: string
|
||||
label: HTMLLikeElement | string
|
||||
action: CommandAction
|
||||
payload?: any
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
export type CommandGroupType = Array<{
|
||||
heading?: string
|
||||
items: CommandItemType[]
|
||||
}>
|
||||
|
||||
const createNavigationItem = (
|
||||
icon: keyof typeof icons,
|
||||
value: string,
|
||||
path: string,
|
||||
actions: ReturnType<typeof useCommandActions>
|
||||
): CommandItemType => ({
|
||||
icon,
|
||||
value: `Go to ${value}`,
|
||||
label: {
|
||||
tag: "span",
|
||||
children: ["Go to ", { tag: "span", attributes: { className: "font-semibold" }, children: [value] }]
|
||||
},
|
||||
action: () => actions.navigateTo(path)
|
||||
})
|
||||
|
||||
export const createCommandGroups = (
|
||||
actions: ReturnType<typeof useCommandActions>,
|
||||
me: LaAccount
|
||||
): Record<string, CommandGroupType> => ({
|
||||
home: [
|
||||
{
|
||||
heading: "General",
|
||||
items: [
|
||||
{
|
||||
icon: "SunMoon",
|
||||
value: "Change Theme...",
|
||||
label: "Change Theme...",
|
||||
action: "CHANGE_PAGE",
|
||||
payload: "changeTheme"
|
||||
},
|
||||
{
|
||||
icon: "Copy",
|
||||
value: "Copy Current URL",
|
||||
label: "Copy Current URL",
|
||||
action: actions.copyCurrentURL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "Personal Links",
|
||||
items: [
|
||||
{
|
||||
icon: "TextSearch",
|
||||
value: "Search Links...",
|
||||
label: "Search Links...",
|
||||
action: "CHANGE_PAGE",
|
||||
payload: "searchLinks"
|
||||
},
|
||||
{
|
||||
icon: "Plus",
|
||||
value: "Create New Link...",
|
||||
label: "Create New Link...",
|
||||
action: () => actions.navigateTo("/links?create=true")
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "Personal Pages",
|
||||
items: [
|
||||
{
|
||||
icon: "FileSearch",
|
||||
value: "Search Pages...",
|
||||
label: "Search Pages...",
|
||||
action: "CHANGE_PAGE",
|
||||
payload: "searchPages"
|
||||
},
|
||||
{
|
||||
icon: "Plus",
|
||||
value: "Create New Page...",
|
||||
label: "Create New Page...",
|
||||
action: () => actions.createNewPage(me)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "Navigation",
|
||||
items: [
|
||||
createNavigationItem("ArrowRight", "Links", "/links", actions),
|
||||
createNavigationItem("ArrowRight", "Pages", "/pages", actions),
|
||||
createNavigationItem("ArrowRight", "Search", "/search", actions),
|
||||
createNavigationItem("ArrowRight", "Profile", "/profile", actions),
|
||||
createNavigationItem("ArrowRight", "Settings", "/settings", actions)
|
||||
]
|
||||
}
|
||||
],
|
||||
searchLinks: [],
|
||||
searchPages: [],
|
||||
topics: [],
|
||||
changeTheme: [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
icon: "Moon",
|
||||
value: "Change Theme to Dark",
|
||||
label: "Change Theme to Dark",
|
||||
action: () => actions.changeTheme("dark")
|
||||
},
|
||||
{
|
||||
icon: "Sun",
|
||||
value: "Change Theme to Light",
|
||||
label: "Change Theme to Light",
|
||||
action: () => actions.changeTheme("light")
|
||||
},
|
||||
{
|
||||
icon: "Monitor",
|
||||
value: "Change Theme to System",
|
||||
label: "Change Theme to System",
|
||||
action: () => actions.changeTheme("system")
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
57
web/components/custom/command-palette/command-items.tsx
Normal file
57
web/components/custom/command-palette/command-items.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { CommandSeparator, CommandShortcut } from "@/components/ui/command"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { CommandItemType, CommandAction } from "./command-data"
|
||||
import { HTMLLikeElement, renderHTMLLikeElement } from "@/lib/utils"
|
||||
|
||||
export interface CommandItemProps extends Omit<CommandItemType, "action"> {
|
||||
action: CommandAction
|
||||
handleAction: (action: CommandAction, payload?: any) => void
|
||||
}
|
||||
|
||||
const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = React.memo(({ content }) => {
|
||||
return <span className="line-clamp-1">{renderHTMLLikeElement(content)}</span>
|
||||
})
|
||||
|
||||
HTMLLikeRenderer.displayName = "HTMLLikeRenderer"
|
||||
|
||||
export const CommandItem: React.FC<CommandItemProps> = React.memo(
|
||||
({ icon, label, action, payload, shortcut, handleAction, ...item }) => (
|
||||
<Command.Item value={`${item.id}-${item.value}`} onSelect={() => handleAction(action, payload)}>
|
||||
{icon && <LaIcon name={icon} />}
|
||||
<HTMLLikeRenderer content={label} />
|
||||
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
|
||||
</Command.Item>
|
||||
)
|
||||
)
|
||||
|
||||
CommandItem.displayName = "CommandItem"
|
||||
|
||||
export interface CommandGroupProps {
|
||||
heading?: string
|
||||
items: CommandItemType[]
|
||||
handleAction: (action: CommandAction, payload?: any) => void
|
||||
isLastGroup: boolean
|
||||
}
|
||||
|
||||
export const CommandGroup: React.FC<CommandGroupProps> = React.memo(({ heading, items, handleAction, isLastGroup }) => {
|
||||
return (
|
||||
<>
|
||||
{heading ? (
|
||||
<Command.Group heading={heading}>
|
||||
{items.map((item, index) => (
|
||||
<CommandItem key={`${heading}-${item.label}-${index}`} {...item} handleAction={handleAction} />
|
||||
))}
|
||||
</Command.Group>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<CommandItem key={`item-${item.label}-${index}`} {...item} handleAction={handleAction} />
|
||||
))
|
||||
)}
|
||||
{!isLastGroup && <CommandSeparator className="my-1.5" />}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
CommandGroup.displayName = "CommandGroup"
|
||||
229
web/components/custom/command-palette/command-palette.tsx
Normal file
229
web/components/custom/command-palette/command-palette.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Command } from "cmdk"
|
||||
import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import { CommandGroup } from "./command-items"
|
||||
import { CommandAction, CommandItemType, createCommandGroups } from "./command-data"
|
||||
import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { searchSafeRegExp } from "@/lib/utils"
|
||||
import { GraphNode } from "@/components/routes/public/PublicHomeRoute"
|
||||
import { useCommandActions } from "./hooks/use-command-actions"
|
||||
import { atom, useAtom } from "jotai"
|
||||
|
||||
const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
|
||||
|
||||
const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
|
||||
items.filter(item => searchRegex.test(item.value)).slice(0, 10)
|
||||
|
||||
export const commandPaletteOpenAtom = atom(false)
|
||||
|
||||
export function CommandPalette() {
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
if (me._type === "Anonymous") return null
|
||||
|
||||
return <RealCommandPalette />
|
||||
}
|
||||
|
||||
export function RealCommandPalette() {
|
||||
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
|
||||
const dialogRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const [activePage, setActivePage] = React.useState("home")
|
||||
const [open, setOpen] = useAtom(commandPaletteOpenAtom)
|
||||
|
||||
const actions = useCommandActions()
|
||||
const commandGroups = React.useMemo(() => me && createCommandGroups(actions, me), [actions, me])
|
||||
|
||||
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
||||
|
||||
const bounce = React.useCallback(() => {
|
||||
if (dialogRef.current) {
|
||||
dialogRef.current.style.transform = "scale(0.99) translateX(-50%)"
|
||||
setTimeout(() => {
|
||||
if (dialogRef.current) {
|
||||
dialogRef.current.style.transform = ""
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
bounce()
|
||||
}
|
||||
|
||||
if (activePage !== "home" && !inputValue && e.key === "Backspace") {
|
||||
e.preventDefault()
|
||||
setActivePage("home")
|
||||
setInputValue("")
|
||||
bounce()
|
||||
}
|
||||
},
|
||||
[activePage, inputValue, bounce]
|
||||
)
|
||||
|
||||
const topics = React.useMemo(
|
||||
() => ({
|
||||
heading: "Topics",
|
||||
items: raw_graph_data.map(topic => ({
|
||||
icon: "Circle" as const,
|
||||
value: topic?.prettyName || "",
|
||||
label: topic?.prettyName || "",
|
||||
action: () => actions.navigateTo(`/${topic?.name}`)
|
||||
}))
|
||||
}),
|
||||
[raw_graph_data, actions]
|
||||
)
|
||||
|
||||
const personalLinks = React.useMemo(
|
||||
() => ({
|
||||
heading: "Personal Links",
|
||||
items:
|
||||
me?.root.personalLinks?.map(link => ({
|
||||
id: link?.id,
|
||||
icon: "Link" as const,
|
||||
value: link?.title || "Untitled",
|
||||
label: link?.title || "Untitled",
|
||||
action: () => actions.openLinkInNewTab(link?.url || "#")
|
||||
})) || []
|
||||
}),
|
||||
[me?.root.personalLinks, actions]
|
||||
)
|
||||
|
||||
const personalPages = React.useMemo(
|
||||
() => ({
|
||||
heading: "Personal Pages",
|
||||
items:
|
||||
me?.root.personalPages?.map(page => ({
|
||||
id: page?.id,
|
||||
icon: "FileText" as const,
|
||||
value: page?.title || "Untitled",
|
||||
label: page?.title || "Untitled",
|
||||
action: () => actions.navigateTo(`/pages/${page?.id}`)
|
||||
})) || []
|
||||
}),
|
||||
[me?.root.personalPages, actions]
|
||||
)
|
||||
|
||||
const getFilteredCommands = React.useCallback(() => {
|
||||
if (!commandGroups) return []
|
||||
|
||||
const searchRegex = searchSafeRegExp(inputValue)
|
||||
|
||||
if (activePage === "home") {
|
||||
if (!inputValue) {
|
||||
return commandGroups.home
|
||||
}
|
||||
|
||||
const allGroups = [...Object.values(commandGroups).flat(), personalLinks, personalPages, topics]
|
||||
|
||||
return allGroups
|
||||
.map(group => ({
|
||||
heading: group.heading,
|
||||
items: filterItems(group.items, searchRegex)
|
||||
}))
|
||||
.filter(group => group.items.length > 0)
|
||||
}
|
||||
|
||||
switch (activePage) {
|
||||
case "searchLinks":
|
||||
return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }]
|
||||
case "searchPages":
|
||||
return [...commandGroups.searchPages, { items: filterItems(personalPages.items, searchRegex) }]
|
||||
default:
|
||||
const pageCommands = commandGroups[activePage]
|
||||
if (!inputValue) return pageCommands
|
||||
return pageCommands
|
||||
.map(group => ({
|
||||
heading: group.heading,
|
||||
items: filterItems(group.items, searchRegex)
|
||||
}))
|
||||
.filter(group => group.items.length > 0)
|
||||
}
|
||||
}, [inputValue, activePage, commandGroups, personalLinks, personalPages, topics])
|
||||
|
||||
const handleAction = React.useCallback(
|
||||
(action: CommandAction, payload?: any) => {
|
||||
const closeDialog = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
if (typeof action === "function") {
|
||||
action()
|
||||
closeDialog()
|
||||
return
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "CHANGE_PAGE":
|
||||
if (payload) {
|
||||
setActivePage(payload)
|
||||
setInputValue("")
|
||||
bounce()
|
||||
} else {
|
||||
console.error(`Invalid page: ${payload}`)
|
||||
}
|
||||
break
|
||||
default:
|
||||
console.log(`Unhandled action: ${action}`)
|
||||
closeDialog()
|
||||
}
|
||||
},
|
||||
[bounce, setOpen]
|
||||
)
|
||||
|
||||
const filteredCommands = React.useMemo(() => getFilteredCommands(), [getFilteredCommands])
|
||||
|
||||
const commandKey = React.useMemo(() => {
|
||||
return filteredCommands
|
||||
.map(group => {
|
||||
const itemsKey = group.items.map(item => `${item.label}-${item.value}`).join("|")
|
||||
return `${group.heading}:${itemsKey}`
|
||||
})
|
||||
.join("__")
|
||||
}, [filteredCommands])
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogPortal>
|
||||
<DialogPrimitive.Overlay la-overlay="" cmdk-overlay="" />
|
||||
<DialogPrimitive.Content la-dialog="" cmdk-dialog="" className="la" ref={dialogRef}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Command Palette</DialogTitle>
|
||||
<DialogDescription>Search for commands and actions</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Command key={commandKey} onKeyDown={handleKeyDown}>
|
||||
<div cmdk-input-wrapper="">
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder="Type a command or search..."
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Command.List>
|
||||
<Command.Empty>No results found.</Command.Empty>
|
||||
{filteredCommands.map((group, index, array) => (
|
||||
<CommandGroup
|
||||
key={`${group.heading}-${index}`}
|
||||
heading={group.heading}
|
||||
items={group.items}
|
||||
handleAction={handleAction}
|
||||
isLastGroup={index === array.length - 1}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import { ensureUrlProtocol } from "@/lib/utils"
|
||||
import { useTheme } from "next-themes"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
|
||||
|
||||
export const useCommandActions = () => {
|
||||
const { setTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
const changeTheme = React.useCallback(
|
||||
(theme: string) => {
|
||||
setTheme(theme)
|
||||
toast.success(`Theme changed to ${theme}.`, { position: "bottom-right" })
|
||||
},
|
||||
[setTheme]
|
||||
)
|
||||
|
||||
const navigateTo = React.useCallback(
|
||||
(path: string) => {
|
||||
router.push(path)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
const openLinkInNewTab = React.useCallback((url: string) => {
|
||||
window.open(ensureUrlProtocol(url), "_blank")
|
||||
}, [])
|
||||
|
||||
const copyCurrentURL = React.useCallback(() => {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
toast.success("URL copied to clipboard.", { position: "bottom-right" })
|
||||
}, [])
|
||||
|
||||
const createNewPage = React.useCallback(
|
||||
(me: LaAccount) => {
|
||||
const page = newPage(me)
|
||||
router.push(`/pages/${page.id}`)
|
||||
},
|
||||
[router, newPage]
|
||||
)
|
||||
|
||||
return {
|
||||
changeTheme,
|
||||
navigateTo,
|
||||
openLinkInNewTab,
|
||||
copyCurrentURL,
|
||||
createNewPage
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import * as React from "react"
|
||||
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 { useMedia } from "@/hooks/use-media"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "./la-icon"
|
||||
|
||||
type ContentHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "title">
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex min-h-10 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
|
||||
"flex min-h-10 min-w-0 shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -52,7 +52,7 @@ export const SidebarToggleButton: React.FC = () => {
|
||||
className="text-primary/60"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<PanelLeftIcon size={16} />
|
||||
<LaIcon name="PanelLeft" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
23
web/components/custom/discordIcon.tsx
Normal file
23
web/components/custom/discordIcon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export const DiscordIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.9143 7.38378L4.93679 14.6174C4.82454 15.448 5.24219 16.2606 5.983 16.6528L8.99995 18.25L9.99995 15.75C9.99995 15.75 10.6562 16.25 11.9999 16.25C13.3437 16.25 13.9999 15.75 13.9999 15.75L14.9999 18.25L18.0169 16.6528C18.7577 16.2606 19.1754 15.448 19.0631 14.6174L18.0856 7.38378C18.0334 6.99739 17.7613 6.67658 17.3887 6.56192L14.7499 5.75003V6.25003C14.7499 6.80232 14.3022 7.25003 13.7499 7.25003H10.2499C9.69766 7.25003 9.24995 6.80232 9.24995 6.25003V5.75003L6.61122 6.56192C6.23855 6.67658 5.96652 6.99739 5.9143 7.38378Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M10.5 12C10.5 12.2761 10.2761 12.5 10 12.5C9.72386 12.5 9.5 12.2761 9.5 12C9.5 11.7239 9.72386 11.5 10 11.5C10.2761 11.5 10.5 11.7239 10.5 12Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M14.5 12C14.5 12.2761 14.2761 12.5 14 12.5C13.7239 12.5 13.5 12.2761 13.5 12C13.5 11.7239 13.7239 11.5 14 11.5C14.2761 11.5 14.5 11.7239 14.5 12Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
130
web/components/custom/global-keyboard-handler.tsx
Normal file
130
web/components/custom/global-keyboard-handler.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { useRouter } from "next/navigation"
|
||||
import queryString from "query-string"
|
||||
import { usePageActions } from "../routes/page/hooks/use-page-actions"
|
||||
import { useAuth } from "@clerk/nextjs"
|
||||
import { isModKey } from "@/lib/utils"
|
||||
import { useAtom } from "jotai"
|
||||
import { commandPaletteOpenAtom } from "./command-palette/command-palette"
|
||||
|
||||
type RegisterKeyDownProps = {
|
||||
trigger: KeyFilter
|
||||
handler: (event: KeyboardEvent) => void
|
||||
options?: Options
|
||||
}
|
||||
|
||||
function RegisterKeyDown({ trigger, handler, options }: RegisterKeyDownProps) {
|
||||
useKeyDown(trigger, handler, options)
|
||||
return null
|
||||
}
|
||||
|
||||
type Sequence = {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
const SEQUENCES: Sequence = {
|
||||
GL: "/links",
|
||||
GP: "/pages",
|
||||
GT: "/topics"
|
||||
}
|
||||
|
||||
const MAX_SEQUENCE_TIME = 1000
|
||||
|
||||
export function GlobalKeyboardHandler() {
|
||||
const [openCommandPalette, setOpenCommandPalette] = useAtom(commandPaletteOpenAtom)
|
||||
const [sequence, setSequence] = useState<string[]>([])
|
||||
const { signOut } = useAuth()
|
||||
const router = useRouter()
|
||||
const { me } = useAccountOrGuest()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
const resetSequence = useCallback(() => {
|
||||
setSequence([])
|
||||
}, [])
|
||||
|
||||
const checkSequence = useCallback(() => {
|
||||
const sequenceStr = sequence.join("")
|
||||
const route = SEQUENCES[sequenceStr]
|
||||
|
||||
if (route) {
|
||||
console.log(`Navigating to ${route}...`)
|
||||
router.push(route)
|
||||
resetSequence()
|
||||
}
|
||||
}, [sequence, router, resetSequence])
|
||||
|
||||
const goToNewLink = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/links?${queryString.stringify({ create: true })}`)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
const goToNewPage = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!me || me._type === "Anonymous") {
|
||||
return
|
||||
}
|
||||
|
||||
const page = newPage(me)
|
||||
|
||||
router.push(`/pages/${page.id}`)
|
||||
},
|
||||
[me, newPage, router]
|
||||
)
|
||||
|
||||
useKeyDown(
|
||||
e => e.altKey && e.shiftKey && e.code === "KeyQ",
|
||||
() => {
|
||||
signOut()
|
||||
}
|
||||
)
|
||||
|
||||
useKeyDown(
|
||||
() => true,
|
||||
e => {
|
||||
const key = e.key.toUpperCase()
|
||||
setSequence(prev => [...prev, key])
|
||||
}
|
||||
)
|
||||
|
||||
useKeyDown(
|
||||
e => isModKey(e) && e.code === "KeyK",
|
||||
e => {
|
||||
e.preventDefault()
|
||||
setOpenCommandPalette(prev => !prev)
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
checkSequence()
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
resetSequence()
|
||||
}, MAX_SEQUENCE_TIME)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [sequence, checkSequence, resetSequence])
|
||||
|
||||
return (
|
||||
me &&
|
||||
me._type !== "Anonymous" && (
|
||||
<>
|
||||
<RegisterKeyDown trigger="c" handler={goToNewLink} />
|
||||
<RegisterKeyDown trigger="p" handler={goToNewPage} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
100
web/components/custom/learn-anything-onboarding.tsx
Normal file
100
web/components/custom/learn-anything-onboarding.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { isExistingUser } from "@/app/actions"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
const hasVisitedAtom = atomWithStorage("hasVisitedLearnAnything", false)
|
||||
const isDialogOpenAtom = atom(true)
|
||||
|
||||
export function LearnAnythingOnboarding() {
|
||||
const pathname = usePathname()
|
||||
const [hasVisited, setHasVisited] = useAtom(hasVisitedAtom)
|
||||
const [isOpen, setIsOpen] = useAtom(isDialogOpenAtom)
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const [isExisting, setIsExisting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const existingUser = await isExistingUser()
|
||||
setIsExisting(existingUser)
|
||||
setIsOpen(true)
|
||||
} catch (error) {
|
||||
console.error("Error loading user:", error)
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasVisited && pathname !== "/") {
|
||||
loadUser()
|
||||
}
|
||||
}, [hasVisited, pathname, setIsOpen])
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
setHasVisited(true)
|
||||
}
|
||||
|
||||
if (hasVisited || isFetching) return null
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogContent className="max-w-xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<h1 className="text-2xl font-bold">Welcome to Learn Anything!</h1>
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogDescription className="text-foreground/70 space-y-4 text-base leading-5">
|
||||
{isExisting && (
|
||||
<>
|
||||
<p className="font-medium">Existing Customer Notice</p>
|
||||
<p>
|
||||
We noticed you are an existing Learn Anything customer. We sincerely apologize for any broken experience
|
||||
you may have encountered on the old website. We've been working hard on this new version, which
|
||||
addresses previous issues and offers more features. As an early customer, you're locked in at the{" "}
|
||||
<strong>$3</strong> price for our upcoming pro version. Thank you for your support!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
Learn Anything is a learning platform that organizes knowledge in a social way. You can create pages, add
|
||||
links, track learning status of any topic, and more things in the future.
|
||||
</p>
|
||||
<p>Try do these quick onboarding steps to get a feel for the product:</p>
|
||||
<ul className="list-inside list-disc">
|
||||
<li>Create your first page</li>
|
||||
<li>Add a link to a resource</li>
|
||||
<li>Update your learning status on a topic</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you have any questions, don't hesitate to reach out. Click on question mark button in the bottom
|
||||
right corner and enter your message.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
|
||||
<AlertDialogFooter className="mt-4">
|
||||
<AlertDialogCancel onClick={handleClose}>Close</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClose}>Get Started</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default LearnAnythingOnboarding
|
||||
@@ -8,6 +8,7 @@ import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { linkLearningStateSelectorAtom } from "@/store/link"
|
||||
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
interface LearningStateSelectorProps {
|
||||
showSearch?: boolean
|
||||
@@ -16,15 +17,17 @@ interface LearningStateSelectorProps {
|
||||
value?: string
|
||||
onChange: (value: LearningStateValue) => void
|
||||
className?: string
|
||||
defaultIcon?: keyof typeof icons
|
||||
}
|
||||
|
||||
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
showSearch = true,
|
||||
defaultLabel = "Select state",
|
||||
defaultLabel = "State",
|
||||
searchPlaceholder = "Search state...",
|
||||
value,
|
||||
onChange,
|
||||
className
|
||||
className,
|
||||
defaultIcon
|
||||
}) => {
|
||||
const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
|
||||
const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === value), [value])
|
||||
@@ -34,6 +37,9 @@ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
setIsLearningStateSelectorOpen(false)
|
||||
}
|
||||
|
||||
const iconName = selectedLearningState?.icon || defaultIcon
|
||||
const labelText = selectedLearningState?.label || defaultLabel
|
||||
|
||||
return (
|
||||
<Popover open={isLearningStateSelectorOpen} onOpenChange={setIsLearningStateSelectorOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -44,21 +50,12 @@ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
variant="secondary"
|
||||
className={cn("gap-x-2 text-sm", className)}
|
||||
>
|
||||
{selectedLearningState?.icon && (
|
||||
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
|
||||
)}
|
||||
<span className={cn("truncate", selectedLearningState?.className || "")}>
|
||||
{selectedLearningState?.label || defaultLabel}
|
||||
</span>
|
||||
{iconName && <LaIcon name={iconName} className={cn(selectedLearningState?.className)} />}
|
||||
{labelText && <span className={cn("truncate", selectedLearningState?.className || "")}>{labelText}</span>}
|
||||
<LaIcon name="ChevronDown" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="end"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="end">
|
||||
<LearningStateSelectorContent
|
||||
showSearch={showSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
@@ -91,7 +88,7 @@ export const LearningStateSelectorContent: React.FC<LearningStateSelectorContent
|
||||
<CommandGroup>
|
||||
{LEARNING_STATES.map(ls => (
|
||||
<CommandItem key={ls.value} value={ls.value} onSelect={onSelect}>
|
||||
<LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />
|
||||
{ls.icon && <LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />}
|
||||
<span className={ls.className}>{ls.label}</span>
|
||||
<LaIcon
|
||||
name="Check"
|
||||
|
||||
137
web/components/custom/sidebar/partial/feedback.tsx
Normal file
137
web/components/custom/sidebar/partial/feedback.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogPrimitive
|
||||
} from "@/components/ui/dialog"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { MinimalTiptapEditor, MinimalTiptapEditorRef } from "@/components/minimal-tiptap"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { useRef, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { sendFeedback } from "@/app/actions"
|
||||
import { useServerAction } from "zsa-react"
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import { Spinner } from "@/components/custom/spinner"
|
||||
|
||||
const formSchema = z.object({
|
||||
content: z.string().min(1, {
|
||||
message: "Feedback cannot be empty"
|
||||
})
|
||||
})
|
||||
|
||||
export function Feedback() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const editorRef = useRef<MinimalTiptapEditorRef>(null)
|
||||
const { isPending, execute } = useServerAction(sendFeedback)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
content: ""
|
||||
}
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const [, err] = await execute(values)
|
||||
|
||||
if (err) {
|
||||
toast.error("Failed to send feedback")
|
||||
console.error(err)
|
||||
return
|
||||
}
|
||||
|
||||
form.reset({ content: "" })
|
||||
editorRef.current?.editor?.commands.clearContent()
|
||||
setOpen(false)
|
||||
toast.success("Feedback sent")
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="icon" className="shrink-0" variant="ghost">
|
||||
<LaIcon name="CircleHelp" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
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",
|
||||
"flex flex-col p-4 sm:max-w-2xl"
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader className="mb-5">
|
||||
<DialogTitle>Share feedback</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Your feedback helps us improve. Please share your thoughts, ideas, and suggestions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="sr-only">Content</FormLabel>
|
||||
<FormControl>
|
||||
<MinimalTiptapEditor
|
||||
{...field}
|
||||
ref={editorRef}
|
||||
throttleDelay={500}
|
||||
className={cn(
|
||||
"border-muted-foreground/40 focus-within:border-muted-foreground/80 min-h-52 rounded-lg",
|
||||
{
|
||||
"border-destructive focus-within:border-destructive": form.formState.errors.content
|
||||
}
|
||||
)}
|
||||
editorContentClassName="p-4 overflow-auto flex grow"
|
||||
output="html"
|
||||
placeholder="Your feedback helps us improve. Please share your thoughts, ideas, and suggestions."
|
||||
autofocus={true}
|
||||
immediatelyRender={true}
|
||||
editable={true}
|
||||
injectCSS={true}
|
||||
editorClassName="focus:outline-none"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogPrimitive.Close className={buttonVariants({ variant: "outline" })}>Cancel</DialogPrimitive.Close>
|
||||
<Button type="submit">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2" />
|
||||
<span>Sending feedback...</span>
|
||||
</>
|
||||
) : (
|
||||
"Send feedback"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
113
web/components/custom/sidebar/partial/journal-section.tsx
Normal file
113
web/components/custom/sidebar/partial/journal-section.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "../../la-icon"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuth, useUser } from "@clerk/nextjs"
|
||||
import { getFeatureFlag } from "@/app/actions"
|
||||
|
||||
export const JournalSection: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const journalEntries = me?.root?.journalEntries
|
||||
const pathname = usePathname()
|
||||
const isActive = pathname === "/journal"
|
||||
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [isFeatureActive, setIsFeatureActive] = useState(false)
|
||||
const { isLoaded, isSignedIn } = useAuth()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
async function checkFeatureFlag() {
|
||||
setIsFetching(true)
|
||||
|
||||
if (isLoaded && isSignedIn) {
|
||||
const [data, err] = await getFeatureFlag({ name: "JOURNAL" })
|
||||
|
||||
if (err) {
|
||||
console.error(err)
|
||||
setIsFetching(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (user?.emailAddresses.some(email => data.flag?.emails.includes(email.emailAddress))) {
|
||||
setIsFeatureActive(true)
|
||||
}
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFeatureFlag()
|
||||
}, [isLoaded, isSignedIn, user])
|
||||
|
||||
if (!isLoaded || !isSignedIn) {
|
||||
return <div className="py-2 text-center text-gray-500">Loading...</div>
|
||||
}
|
||||
|
||||
if (!me) return null
|
||||
|
||||
if (!isFeatureActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/journal flex flex-col gap-px py-2">
|
||||
<JournalSectionHeader entriesCount={journalEntries?.length || 0} isActive={isActive} />
|
||||
{journalEntries && journalEntries.length > 0 && <JournalEntryList entries={journalEntries} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JournalHeaderProps {
|
||||
entriesCount: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const JournalSectionHeader: React.FC<JournalHeaderProps> = ({ entriesCount, isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[30px] items-center gap-px rounded-md",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/journal"
|
||||
className="flex flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||
>
|
||||
<p className="text-xs">
|
||||
Journal
|
||||
{entriesCount > 0 && <span className="text-muted-foreground ml-1">({entriesCount})</span>}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface JournalEntryListProps {
|
||||
entries: any[]
|
||||
}
|
||||
|
||||
const JournalEntryList: React.FC<JournalEntryListProps> = ({ entries }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{entries.map((entry, index) => (
|
||||
<JournalEntryItem key={index} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JournalEntryItemProps {
|
||||
entry: any
|
||||
}
|
||||
|
||||
const JournalEntryItem: React.FC<JournalEntryItemProps> = ({ entry }) => (
|
||||
<Link href={`/journal/${entry.id}`} className="group/journal-entry relative flex min-w-0 flex-1">
|
||||
<div className="relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium">
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name="FileText" className="opacity-60" />
|
||||
<p className={cn("truncate opacity-95 group-hover/journal-entry:opacity-100")}>{entry.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
128
web/components/custom/sidebar/partial/link-section.tsx
Normal file
128
web/components/custom/sidebar/partial/link-section.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||
import { useQueryState, parseAsStringLiteral } from "nuqs"
|
||||
import { LEARNING_STATES } from "@/lib/constants"
|
||||
|
||||
const ALL_STATES = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES]
|
||||
const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value)
|
||||
|
||||
interface LinkSectionProps {
|
||||
pathname: string
|
||||
}
|
||||
|
||||
export const LinkSection: React.FC<LinkSectionProps> = ({ pathname }) => {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
personalLinks: []
|
||||
}
|
||||
})
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const linkCount = me.root.personalLinks?.length || 0
|
||||
const isActive = pathname === "/links"
|
||||
|
||||
return (
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<LinkSectionHeader linkCount={linkCount} isActive={isActive} />
|
||||
<List personalLinks={me.root.personalLinks} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkSectionHeaderProps {
|
||||
linkCount: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const LinkSectionHeader: React.FC<LinkSectionHeaderProps> = ({ linkCount }) => {
|
||||
const pathname = usePathname()
|
||||
const [state] = useQueryState("state", parseAsStringLiteral(ALL_STATES_STRING))
|
||||
const isLinksActive = pathname.startsWith("/links") && (!state || state === "all")
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-px rounded-md sm:h-[30px]",
|
||||
isLinksActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/links"
|
||||
className="flex flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||
>
|
||||
<p className="flex w-full items-center text-sm font-medium sm:text-xs">
|
||||
Links
|
||||
{linkCount > 0 && <span className="text-muted-foreground ml-1">{linkCount}</span>}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListProps {
|
||||
personalLinks: PersonalLinkLists
|
||||
}
|
||||
|
||||
const List: React.FC<ListProps> = ({ personalLinks }) => {
|
||||
const pathname = usePathname()
|
||||
const [state] = useQueryState("state", parseAsStringLiteral(LEARNING_STATES.map(ls => ls.value)))
|
||||
|
||||
const linkCounts = {
|
||||
wantToLearn: personalLinks.filter(link => link?.learningState === "wantToLearn").length,
|
||||
learning: personalLinks.filter(link => link?.learningState === "learning").length,
|
||||
learned: personalLinks.filter(link => link?.learningState === "learned").length
|
||||
}
|
||||
|
||||
const isActive = (checkState: string) => pathname === "/links" && state === checkState
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<ListItem
|
||||
label="To Learn"
|
||||
href="/links?state=wantToLearn"
|
||||
count={linkCounts.wantToLearn}
|
||||
isActive={isActive("wantToLearn")}
|
||||
/>
|
||||
<ListItem
|
||||
label="Learning"
|
||||
href="/links?state=learning"
|
||||
count={linkCounts.learning}
|
||||
isActive={isActive("learning")}
|
||||
/>
|
||||
<ListItem label="Learned" href="/links?state=learned" count={linkCounts.learned} isActive={isActive("learned")} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListItemProps {
|
||||
label: string
|
||||
href: string
|
||||
count: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({ label, href, count, isActive }) => (
|
||||
<div className="group/reorder-page relative">
|
||||
<div className="group/topic-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<p className={cn("truncate opacity-95 group-hover/topic-link:opacity-100")}>{label}</p>
|
||||
</div>
|
||||
</Link>
|
||||
{count > 0 && (
|
||||
<span className="absolute right-2 top-1/2 z-[1] -translate-y-1/2 rounded p-1 text-sm">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react"
|
||||
import React, { useMemo } from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
@@ -7,7 +7,6 @@ import { atomWithStorage } from "jotai/utils"
|
||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { icons } from "lucide-react"
|
||||
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
|
||||
|
||||
type SortOption = "title" | "recent"
|
||||
type ShowOption = 5 | 10 | 15 | 20 | 0
|
||||
@@ -46,36 +46,49 @@ const SHOWS: Option<ShowOption>[] = [
|
||||
const pageSortAtom = atomWithStorage<SortOption>("pageSort", "title")
|
||||
const pageShowAtom = atomWithStorage<ShowOption>("pageShow", 5)
|
||||
|
||||
export const PageSection: React.FC = () => {
|
||||
const { me } = useAccount({ root: { personalPages: [] } })
|
||||
const pageCount = me?.root.personalPages?.length || 0
|
||||
export const PageSection: React.FC<{ pathname?: string }> = ({ pathname }) => {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
personalPages: []
|
||||
}
|
||||
})
|
||||
|
||||
const [sort] = useAtom(pageSortAtom)
|
||||
const [show] = useAtom(pageShowAtom)
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const pageCount = me.root.personalPages?.length || 0
|
||||
const isActive = pathname === "/pages"
|
||||
|
||||
return (
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<PageSectionHeader pageCount={pageCount} />
|
||||
{me?.root.personalPages && <PageList personalPages={me.root.personalPages} />}
|
||||
<PageSectionHeader pageCount={pageCount} isActive={isActive} />
|
||||
<PageList personalPages={me.root.personalPages} sort={sort} show={show} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageSectionHeaderProps {
|
||||
pageCount: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => (
|
||||
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount, isActive }) => (
|
||||
<div
|
||||
className={cn("flex min-h-[30px] items-center gap-px rounded-md", "hover:bg-accent hover:text-accent-foreground")}
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-px rounded-md sm:h-[30px]",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||
>
|
||||
<p className="flex items-center text-xs font-medium">
|
||||
<Link href="/pages" className="flex flex-1 items-center justify-start rounded-md px-2 py-1">
|
||||
<p className="text-sm sm:text-xs">
|
||||
Pages
|
||||
{pageCount > 0 && <span className="text-muted-foreground ml-1">{pageCount}</span>}
|
||||
</p>
|
||||
</Button>
|
||||
<div className={cn("flex items-center gap-px pr-2")}>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-px pr-2">
|
||||
<ShowAllForm />
|
||||
<NewPageButton />
|
||||
</div>
|
||||
@@ -85,20 +98,13 @@ const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => (
|
||||
const NewPageButton: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const router = useRouter()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const handleClick = () => {
|
||||
try {
|
||||
const newPersonalPage = PersonalPage.create(
|
||||
{ public: false, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ owner: me._owner }
|
||||
)
|
||||
me.root?.personalPages?.push(newPersonalPage)
|
||||
router.push(`/pages/${newPersonalPage.id}`)
|
||||
} catch (error) {
|
||||
toast.error("Failed to create page")
|
||||
}
|
||||
const page = newPage(me)
|
||||
router.push(`/pages/${page.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -121,26 +127,23 @@ const NewPageButton: React.FC = () => {
|
||||
|
||||
interface PageListProps {
|
||||
personalPages: PersonalPageLists
|
||||
sort: SortOption
|
||||
show: ShowOption
|
||||
}
|
||||
|
||||
const PageList: React.FC<PageListProps> = ({ personalPages }) => {
|
||||
const PageList: React.FC<PageListProps> = ({ personalPages, sort, show }) => {
|
||||
const pathname = usePathname()
|
||||
|
||||
const [sortCriteria] = useAtom(pageSortAtom)
|
||||
const [showCount] = useAtom(pageShowAtom)
|
||||
|
||||
const sortedPages = [...personalPages]
|
||||
.sort((a, b) => {
|
||||
switch (sortCriteria) {
|
||||
case "title":
|
||||
const sortedPages = useMemo(() => {
|
||||
return [...personalPages]
|
||||
.sort((a, b) => {
|
||||
if (sort === "title") {
|
||||
return (a?.title ?? "").localeCompare(b?.title ?? "")
|
||||
case "recent":
|
||||
return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
.slice(0, showCount === 0 ? personalPages.length : showCount)
|
||||
}
|
||||
return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0)
|
||||
})
|
||||
.slice(0, show === 0 ? personalPages.length : show)
|
||||
}, [personalPages, sort, show])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
@@ -162,11 +165,11 @@ const PageListItem: React.FC<PageListItemProps> = ({ page, isActive }) => (
|
||||
<Link
|
||||
href={`/pages/${page.id}`}
|
||||
className={cn(
|
||||
"group-hover/sidebar-link:bg-accent group-hover/sidebar-link:text-accent-foreground relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium",
|
||||
"group-hover/sidebar-link:bg-accent group-hover/sidebar-link:text-accent-foreground relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
|
||||
{ "bg-accent text-accent-foreground": isActive }
|
||||
)}
|
||||
>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<div className="flex max-w-[calc(100%-1rem)] flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name="FileText" className="flex-shrink-0 opacity-60" />
|
||||
<p className="truncate opacity-95 group-hover/sidebar-link:opacity-100">{page.title || "Untitled"}</p>
|
||||
</div>
|
||||
@@ -250,4 +253,4 @@ const ShowAllForm: React.FC = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { LaIcon } from "../../la-icon"
|
||||
import { useState } from "react"
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { SignInButton, useAuth, useUser } from "@clerk/nextjs"
|
||||
import { useAtom } from "jotai"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { DiscordIcon } from "@/components/custom/discordIcon"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -9,105 +16,141 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import Link from "next/link"
|
||||
import { useAuth } from "@clerk/nextjs"
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Feedback } from "./feedback"
|
||||
import { showShortcutAtom } from "@/components/custom/Shortcut/shortcut"
|
||||
import { ShortcutKey } from "@/components/minimal-tiptap/components/shortcut-key"
|
||||
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
|
||||
const MenuItem = ({
|
||||
icon,
|
||||
text,
|
||||
href,
|
||||
onClick,
|
||||
onClose
|
||||
}: {
|
||||
icon: string
|
||||
text: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
onClose()
|
||||
if (onClick) {
|
||||
onClick()
|
||||
}
|
||||
export const ProfileSection: React.FC = () => {
|
||||
const { user, isSignedIn } = useUser()
|
||||
const { signOut } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = React.useState(false)
|
||||
const pathname = usePathname()
|
||||
const [, setShowShortcut] = useAtom(showShortcutAtom)
|
||||
|
||||
const { disableKeydown } = useKeyboardManager("profileSection")
|
||||
|
||||
React.useEffect(() => {
|
||||
disableKeydown(menuOpen)
|
||||
}, [menuOpen, disableKeydown])
|
||||
|
||||
if (!isSignedIn) {
|
||||
return (
|
||||
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
|
||||
<SignInButton mode="modal" forceRedirectUrl={pathname}>
|
||||
<Button variant="outline" className="flex w-full items-center gap-2">
|
||||
<LaIcon name="LogIn" />
|
||||
Sign in
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 items-center gap-2">
|
||||
<LaIcon name={icon as any} />
|
||||
{href ? (
|
||||
<Link href={href} onClick={onClose}>
|
||||
<span className="line-clamp-1 flex-1">{text}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="line-clamp-1 flex-1" onClick={handleClick}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const ProfileSection: React.FC = () => {
|
||||
const { me } = useAccount({
|
||||
profile: true
|
||||
})
|
||||
const { signOut } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const closeMenu = () => setMenuOpen(false)
|
||||
|
||||
return (
|
||||
<div className="visible absolute inset-x-0 bottom-0 z-10 flex gap-8 p-2.5">
|
||||
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
|
||||
<div className="flex h-10 min-w-full items-center">
|
||||
<div className="flex min-w-0">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Profile"
|
||||
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex items-center gap-1.5 truncate rounded pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
{/* <AvatarFallback>CN</AvatarFallback> */}
|
||||
</Avatar>
|
||||
<span className="truncate text-left text-sm font-medium -tracking-wider">{me?.profile?.name}</span>
|
||||
<LaIcon
|
||||
name="ChevronDown"
|
||||
className={`size-4 shrink-0 transition-transform duration-300 ${menuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start" side="top">
|
||||
<DropdownMenuItem>
|
||||
<MenuItem icon="CircleUser" text="My profile" href="/profile" onClose={closeMenu} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<MenuItem icon="Settings" text="Settings" href="/settings" onClose={closeMenu} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<MenuItem icon="LogOut" text="Log out" onClick={signOut} onClose={closeMenu} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<MenuItem icon="CircleUser" text="Tauri" href="/tauri" onClose={closeMenu} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{/* <div className="flex min-w-2 grow flex-row"></div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Button size="icon" variant="ghost" aria-label="Settings" className="size-7 p-0">
|
||||
<LaIcon name="Settings" />
|
||||
</Button>
|
||||
<Link href="/">
|
||||
<Button size="icon" variant="ghost" aria-label="Settings" className="size-7 p-0">
|
||||
<LaIcon name="House" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div> */}
|
||||
<ProfileDropdown
|
||||
user={user}
|
||||
menuOpen={menuOpen}
|
||||
setMenuOpen={setMenuOpen}
|
||||
signOut={signOut}
|
||||
setShowShortcut={setShowShortcut}
|
||||
/>
|
||||
<Feedback />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileDropdownProps {
|
||||
user: any
|
||||
menuOpen: boolean
|
||||
setMenuOpen: (open: boolean) => void
|
||||
signOut: () => void
|
||||
setShowShortcut: (show: boolean) => void
|
||||
}
|
||||
|
||||
const ProfileDropdown: React.FC<ProfileDropdownProps> = ({ user, menuOpen, setMenuOpen, signOut, setShowShortcut }) => (
|
||||
<div className="flex min-w-0">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Profile"
|
||||
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
|
||||
</Avatar>
|
||||
<span className="truncate text-left text-sm font-medium -tracking-wider">{user.fullName}</span>
|
||||
<LaIcon
|
||||
name="ChevronDown"
|
||||
className={cn("size-4 shrink-0 transition-transform duration-300", {
|
||||
"rotate-180": menuOpen
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start" side="top">
|
||||
<DropdownMenuItems signOut={signOut} setShowShortcut={setShowShortcut} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface DropdownMenuItemsProps {
|
||||
signOut: () => void
|
||||
setShowShortcut: (show: boolean) => void
|
||||
}
|
||||
|
||||
const DropdownMenuItems: React.FC<DropdownMenuItemsProps> = ({ signOut, setShowShortcut }) => (
|
||||
<>
|
||||
<MenuLink href="/profile" icon="CircleUser" text="My profile" />
|
||||
<DropdownMenuItem className="gap-2" onClick={() => setShowShortcut(true)}>
|
||||
<LaIcon name="Keyboard" />
|
||||
<span>Shortcut</span>
|
||||
</DropdownMenuItem>
|
||||
<MenuLink href="/onboarding" icon="LayoutList" text="Onboarding" />
|
||||
<DropdownMenuSeparator />
|
||||
<MenuLink href="https://docs.learn-anything.xyz/" icon="Sticker" text="Docs" />
|
||||
<MenuLink href="https://github.com/learn-anything/learn-anything" icon="Github" text="GitHub" />
|
||||
<MenuLink href="https://discord.com/invite/bxtD8x6aNF" icon={DiscordIcon} text="Discord" iconClass="-ml-1" />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={signOut}>
|
||||
<div className="relative flex flex-1 cursor-pointer items-center gap-2">
|
||||
<LaIcon name="LogOut" />
|
||||
<span>Log out</span>
|
||||
<div className="absolute right-0">
|
||||
<ShortcutKey keys={["alt", "shift", "q"]} />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
|
||||
interface MenuLinkProps {
|
||||
href: string
|
||||
icon: keyof typeof icons | React.FC
|
||||
text: string
|
||||
iconClass?: string
|
||||
}
|
||||
|
||||
const MenuLink: React.FC<MenuLinkProps> = ({ href, icon, text, iconClass = "" }) => {
|
||||
const IconComponent = typeof icon === "string" ? icons[icon] : icon
|
||||
return (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="cursor-pointer" href={href}>
|
||||
<div className={cn("relative flex flex-1 items-center gap-2", iconClass)}>
|
||||
<IconComponent className="size-4" />
|
||||
<span className="line-clamp-1 flex-1">{text}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileSection
|
||||
|
||||
149
web/components/custom/sidebar/partial/task-section.tsx
Normal file
149
web/components/custom/sidebar/partial/task-section.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ListOfTasks } from "@/lib/schema/tasks"
|
||||
import { LaIcon } from "../../la-icon"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuth, useUser } from "@clerk/nextjs"
|
||||
import { getFeatureFlag } from "@/app/actions"
|
||||
|
||||
export const TaskSection: React.FC<{ pathname: string }> = ({ pathname }) => {
|
||||
const me = { root: { tasks: [{ id: "1", title: "Test Task" }] } }
|
||||
|
||||
const taskCount = me?.root.tasks?.length || 0
|
||||
const isActive = pathname === "/tasks"
|
||||
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [isFeatureActive, setIsFeatureActive] = useState(false)
|
||||
const { isLoaded, isSignedIn } = useAuth()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
async function checkFeatureFlag() {
|
||||
setIsFetching(true)
|
||||
|
||||
if (isLoaded && isSignedIn) {
|
||||
const [data, err] = await getFeatureFlag({ name: "TASK" })
|
||||
|
||||
if (err) {
|
||||
console.error(err)
|
||||
setIsFetching(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (user?.emailAddresses.some(email => data.flag?.emails.includes(email.emailAddress))) {
|
||||
setIsFeatureActive(true)
|
||||
}
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFeatureFlag()
|
||||
}, [isLoaded, isSignedIn, user])
|
||||
|
||||
if (!isLoaded || !isSignedIn) {
|
||||
return <div className="py-2 text-center text-gray-500">Loading...</div>
|
||||
}
|
||||
|
||||
if (!me) return null
|
||||
|
||||
if (!isFeatureActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/tasks flex flex-col gap-px py-2">
|
||||
<TaskSectionHeader taskCount={taskCount} isActive={isActive} />
|
||||
{isFetching ? (
|
||||
<div className="py-2 text-center text-gray-500">Fetching tasks...</div>
|
||||
) : (
|
||||
<List tasks={me.root.tasks as ListOfTasks} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskSectionHeaderProps {
|
||||
taskCount: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const TaskSectionHeader: React.FC<TaskSectionHeaderProps> = ({ taskCount, isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[30px] items-center gap-px rounded-md",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/tasks"
|
||||
className="flex flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||
>
|
||||
<p className="text-xs">
|
||||
Tasks
|
||||
{taskCount > 0 && <span className="text-muted-foreground ml-1">{taskCount}</span>}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
// <div
|
||||
// className={cn(
|
||||
// "flex min-h-[30px] items-center gap-px rounded-md",
|
||||
// isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
// )}
|
||||
// >
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||
// >
|
||||
// <p className="flex items-center text-xs font-medium">
|
||||
// Tasks
|
||||
// {taskCount > 0 && <span className="text-muted-foreground ml-1">{taskCount}</span>}
|
||||
// </p>
|
||||
// </Button>
|
||||
// </div>
|
||||
)
|
||||
|
||||
interface ListProps {
|
||||
tasks: ListOfTasks
|
||||
}
|
||||
|
||||
const List: React.FC<ListProps> = ({ tasks }) => {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<ListItem label="All Tasks" href="/tasks" count={tasks.length} isActive={pathname === "/tasks"} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListItemProps {
|
||||
label: string
|
||||
href: string
|
||||
count: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({ label, href, count, isActive }) => (
|
||||
<div className="group/reorder-task relative">
|
||||
<div className="group/task-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
// TODO: update links
|
||||
href="/tasks"
|
||||
className="relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium"
|
||||
// className={cn(
|
||||
// "relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium",
|
||||
// isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
// )}
|
||||
>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name="BookCheck" className="opacity-60" />
|
||||
<p className={cn("truncate opacity-95 group-hover/task-link:opacity-100")}>{label}</p>
|
||||
</div>
|
||||
</Link>
|
||||
{count > 0 && (
|
||||
<span className="absolute right-2 top-1/2 z-[1] -translate-y-1/2 rounded p-1 text-sm">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -3,12 +3,11 @@ import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { ListOfTopics } from "@/lib/schema"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
|
||||
export const TopicSection: React.FC = () => {
|
||||
export const TopicSection: React.FC<{ pathname: string }> = ({ pathname }) => {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
topicsWantToLearn: [],
|
||||
@@ -22,11 +21,13 @@ export const TopicSection: React.FC = () => {
|
||||
(me?.root.topicsLearning?.length || 0) +
|
||||
(me?.root.topicsLearned?.length || 0)
|
||||
|
||||
const isActive = pathname.startsWith("/topics")
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<TopicSectionHeader topicCount={topicCount} />
|
||||
<div className="group/topics flex flex-col gap-px py-2">
|
||||
<TopicSectionHeader topicCount={topicCount} isActive={isActive} />
|
||||
<List
|
||||
topicsWantToLearn={me.root.topicsWantToLearn}
|
||||
topicsLearning={me.root.topicsLearning}
|
||||
@@ -38,21 +39,22 @@ export const TopicSection: React.FC = () => {
|
||||
|
||||
interface TopicSectionHeaderProps {
|
||||
topicCount: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const TopicSectionHeader: React.FC<TopicSectionHeaderProps> = ({ topicCount }) => (
|
||||
const TopicSectionHeader: React.FC<TopicSectionHeaderProps> = ({ topicCount, isActive }) => (
|
||||
<div
|
||||
className={cn("flex min-h-[30px] items-center gap-px rounded-md", "hover:bg-accent hover:text-accent-foreground")}
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-px rounded-md sm:h-[30px]",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||
>
|
||||
<p className="flex items-center text-xs font-medium">
|
||||
<Link href="/topics" className="flex flex-1 items-center justify-start rounded-md px-2 py-1">
|
||||
<p className="text-sm sm:text-xs">
|
||||
Topics
|
||||
{topicCount > 0 && <span className="text-muted-foreground ml-1">{topicCount}</span>}
|
||||
</p>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -72,7 +74,7 @@ const List: React.FC<ListProps> = ({ topicsWantToLearn, topicsLearning, topicsLe
|
||||
count={topicsWantToLearn.length}
|
||||
label="To Learn"
|
||||
value="wantToLearn"
|
||||
href="/me/wantToLearn"
|
||||
href="#"
|
||||
isActive={pathname === "/me/wantToLearn"}
|
||||
/>
|
||||
<ListItem
|
||||
@@ -80,7 +82,7 @@ const List: React.FC<ListProps> = ({ topicsWantToLearn, topicsLearning, topicsLe
|
||||
label="Learning"
|
||||
value="learning"
|
||||
count={topicsLearning.length}
|
||||
href="/me/learning"
|
||||
href="#"
|
||||
isActive={pathname === "/me/learning"}
|
||||
/>
|
||||
<ListItem
|
||||
@@ -88,7 +90,7 @@ const List: React.FC<ListProps> = ({ topicsWantToLearn, topicsLearning, topicsLe
|
||||
label="Learned"
|
||||
value="learned"
|
||||
count={topicsLearned.length}
|
||||
href="/me/learned"
|
||||
href="#"
|
||||
isActive={pathname === "/me/learned"}
|
||||
/>
|
||||
</div>
|
||||
@@ -114,7 +116,7 @@ const ListItem: React.FC<ListItemProps> = ({ label, value, href, count, isActive
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"group-hover/topic-link:bg-accent relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium",
|
||||
"group-hover/topic-link:bg-accent relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
|
||||
{ "bg-accent text-accent-foreground": isActive },
|
||||
le.className
|
||||
)}
|
||||
@@ -131,4 +133,4 @@ const ListItem: React.FC<ListItemProps> = ({ label, value, href, count, isActive
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useAtom } from "jotai"
|
||||
import { 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 { LinkSection } from "./partial/link-section"
|
||||
import { PageSection } from "./partial/page-section"
|
||||
import { TopicSection } from "./partial/topic-section"
|
||||
import { ProfileSection } from "./partial/profile-section"
|
||||
import { TaskSection } from "./partial/task-section"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "../la-icon"
|
||||
import { JournalSection } from "./partial/journal-section"
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean
|
||||
@@ -96,7 +100,7 @@ const LogoAndSearch: React.FC = React.memo(() => {
|
||||
type="button"
|
||||
className="text-primary/60 flex w-20 items-center justify-start py-4 pl-2"
|
||||
>
|
||||
<SearchIcon size={16} className="mr-2" />
|
||||
<LaIcon name="Search" className="mr-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
@@ -108,20 +112,25 @@ const LogoAndSearch: React.FC = React.memo(() => {
|
||||
LogoAndSearch.displayName = "LogoAndSearch"
|
||||
|
||||
const SidebarContent: React.FC = React.memo(() => {
|
||||
const { me } = useAccountOrGuest()
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
|
||||
<div>
|
||||
<LogoAndSearch />
|
||||
</div>
|
||||
<div tabIndex={-1} className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3">
|
||||
<div className="h-2 shrink-0" />
|
||||
<PageSection />
|
||||
<TopicSection />
|
||||
</div>
|
||||
</nav>
|
||||
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
|
||||
<div>
|
||||
<LogoAndSearch />
|
||||
</div>
|
||||
<div className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3 outline-none">
|
||||
<div className="h-2 shrink-0" />
|
||||
{me._type === "Account" && <LinkSection pathname={pathname} />}
|
||||
{me._type === "Account" && <TopicSection pathname={pathname} />}
|
||||
{me._type === "Account" && <JournalSection />}
|
||||
{me._type === "Account" && <TaskSection pathname={pathname} />}
|
||||
{me._type === "Account" && <PageSection pathname={pathname} />}
|
||||
</div>
|
||||
|
||||
<ProfileSection />
|
||||
</>
|
||||
</nav>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
17
web/components/custom/spinner.tsx
Normal file
17
web/components/custom/spinner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SpinnerProps extends React.SVGAttributes<SVGElement> {}
|
||||
|
||||
export const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(({ className, ...props }, ref) => (
|
||||
<svg ref={ref} className={cn("h-4 w-4 animate-spin", className)} viewBox="0 0 24 24" {...props}>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
))
|
||||
|
||||
Spinner.displayName = "Spinner"
|
||||
26
web/components/custom/text-blur-transition.tsx
Normal file
26
web/components/custom/text-blur-transition.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export default function TextBlurTransition(props: { children: string; className?: string }) {
|
||||
const words = props.children.split(" ")
|
||||
|
||||
return (
|
||||
<motion.div className={cn("flex w-full justify-center gap-3 transition-all", props.className)}>
|
||||
{words.map((word, index) => {
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ filter: "blur(8px)", translateY: "18px", opacity: 0 }}
|
||||
animate={{ filter: "blur(0px)", translateY: "0px", opacity: 1 }}
|
||||
transition={{
|
||||
duration: index * 0.4 + 0.7,
|
||||
easings: "cubic-bezier(.77, 0, .175, 1)"
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -79,12 +79,7 @@ export const TopicSelector = forwardRef<HTMLButtonElement, TopicSelectorProps>(
|
||||
<LaIcon name="ChevronDown" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side={side}
|
||||
align={align}
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side={side} align={align}>
|
||||
{group?.root.topics && (
|
||||
<TopicSelectorContent
|
||||
showSearch={showSearch}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getShortcutKey } from "../../lib/utils"
|
||||
import { getShortcutKey } from "@/lib/utils"
|
||||
|
||||
export interface ShortcutKeyWrapperProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
ariaLabel: string
|
||||
@@ -32,7 +32,7 @@ const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ class
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{getShortcutKey(shortcut)}
|
||||
{getShortcutKey(shortcut).symbol}
|
||||
</kbd>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Toggle } from "@/components/ui/toggle"
|
||||
|
||||
import * as React from "react"
|
||||
@@ -16,31 +16,29 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(fu
|
||||
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>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ export const Link = TiptapLink.extend({
|
||||
* 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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
import { Command, MenuListProps } from "./types"
|
||||
import { getShortcutKeys } from "../../lib/utils"
|
||||
import { getShortcutKeys } from "@/lib/utils"
|
||||
import { Icon } from "../../components/ui/icon"
|
||||
import { PopoverWrapper } from "../../components/ui/popover-wrapper"
|
||||
import { Shortcut } from "../../components/ui/shortcut"
|
||||
@@ -136,7 +136,11 @@ export const MenuList = React.forwardRef((props: MenuListProps, ref) => {
|
||||
<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)}>
|
||||
<Shortcut.Wrapper
|
||||
ariaLabel={getShortcutKeys(command.shortcuts)
|
||||
.map(shortcut => shortcut.readable)
|
||||
.join(" + ")}
|
||||
>
|
||||
{command.shortcuts.map(shortcut => (
|
||||
<Shortcut.Key shortcut={shortcut} key={shortcut} />
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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"
|
||||
import { EditorView } from "@tiptap/pm/view"
|
||||
import type { EditorView } from "@tiptap/pm/view"
|
||||
import { useThrottle } from "@/hooks/use-throttle"
|
||||
|
||||
export interface LAEditorProps extends Omit<React.HTMLProps<HTMLDivElement>, "value"> {
|
||||
output?: "html" | "json" | "text"
|
||||
@@ -25,10 +25,6 @@ export interface LAEditorRef {
|
||||
editor: Editor | null
|
||||
}
|
||||
|
||||
interface CustomEditor extends Editor {
|
||||
previousBlockCount?: number
|
||||
}
|
||||
|
||||
export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
||||
(
|
||||
{
|
||||
@@ -46,32 +42,13 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [content, setContent] = React.useState<Content | undefined>(value)
|
||||
const throttledContent = useThrottleFn(defaultContent => defaultContent, throttleDelay, [content])
|
||||
const [lastThrottledContent, setLastThrottledContent] = React.useState(throttledContent)
|
||||
const throttledSetValue = useThrottle((value: Content) => onUpdate?.(value), throttleDelay)
|
||||
|
||||
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
|
||||
) {
|
||||
onNewBlock?.(newContent)
|
||||
}
|
||||
|
||||
customEditor.previousBlockCount = currentBlockCount
|
||||
}
|
||||
throttledSetValue(getOutput(editor, output))
|
||||
},
|
||||
[output, onNewBlock]
|
||||
[output, throttledSetValue]
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
@@ -96,13 +73,6 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (lastThrottledContent !== throttledContent) {
|
||||
setLastThrottledContent(throttledContent)
|
||||
onUpdate?.(throttledContent!)
|
||||
}
|
||||
}, [throttledContent, lastThrottledContent, onUpdate])
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
||||
@@ -8,7 +8,5 @@ export function getOutput(editor: Editor, output: LAEditorProps["output"]) {
|
||||
return ""
|
||||
}
|
||||
|
||||
export * from "./keyboard"
|
||||
export * from "./platform"
|
||||
export * from "./isCustomNodeSelected"
|
||||
export * from "./isTextSelected"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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 }
|
||||
@@ -1,46 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { BubbleMenu } from '@tiptap/react'
|
||||
import { ImagePopoverBlock } from '../image/image-popover-block'
|
||||
import { ShouldShowProps } from '../../types'
|
||||
|
||||
const ImageBubbleMenu = ({ editor }: { editor: Editor }) => {
|
||||
const shouldShow = ({ editor, from, to }: ShouldShowProps) => {
|
||||
if (from === to) {
|
||||
return false
|
||||
}
|
||||
|
||||
const img = editor.getAttributes('image')
|
||||
|
||||
if (img.src) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const unSetImage = () => {
|
||||
editor.commands.deleteSelection()
|
||||
}
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={shouldShow}
|
||||
tippyOptions={{
|
||||
placement: 'bottom',
|
||||
offset: [0, 8]
|
||||
}}
|
||||
>
|
||||
<ImagePopoverBlock onRemove={unSetImage} />
|
||||
</BubbleMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export { ImageBubbleMenu }
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import { BubbleMenu } from '@tiptap/react'
|
||||
import { LinkEditBlock } from '../link/link-edit-block'
|
||||
import { LinkPopoverBlock } from '../link/link-popover-block'
|
||||
import { ShouldShowProps } from '../../types'
|
||||
|
||||
interface LinkBubbleMenuProps {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
interface LinkAttributes {
|
||||
href: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export const LinkBubbleMenu: React.FC<LinkBubbleMenuProps> = ({ editor }) => {
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [linkAttrs, setLinkAttrs] = useState<LinkAttributes>({ href: '', target: '' })
|
||||
const [selectedText, setSelectedText] = useState('')
|
||||
|
||||
const updateLinkState = useCallback(() => {
|
||||
const { from, to } = editor.state.selection
|
||||
const { href, target } = editor.getAttributes('link')
|
||||
const text = editor.state.doc.textBetween(from, to, ' ')
|
||||
|
||||
setLinkAttrs({ href, target })
|
||||
setSelectedText(text)
|
||||
}, [editor])
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ editor, from, to }: ShouldShowProps) => {
|
||||
if (from === to) {
|
||||
return false
|
||||
}
|
||||
const { href } = editor.getAttributes('link')
|
||||
|
||||
if (href) {
|
||||
updateLinkState()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[updateLinkState]
|
||||
)
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setShowEdit(true)
|
||||
}, [])
|
||||
|
||||
const onSetLink = useCallback(
|
||||
(url: string, text?: string, openInNewTab?: boolean) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: text || url,
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: url,
|
||||
target: openInNewTab ? '_blank' : ''
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
.setLink({ href: url, target: openInNewTab ? '_blank' : '' })
|
||||
.run()
|
||||
setShowEdit(false)
|
||||
updateLinkState()
|
||||
},
|
||||
[editor, updateLinkState]
|
||||
)
|
||||
|
||||
const onUnsetLink = useCallback(() => {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
setShowEdit(false)
|
||||
updateLinkState()
|
||||
}, [editor, updateLinkState])
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={shouldShow}
|
||||
tippyOptions={{
|
||||
placement: 'bottom-start',
|
||||
onHidden: () => setShowEdit(false)
|
||||
}}
|
||||
>
|
||||
{showEdit ? (
|
||||
<LinkEditBlock
|
||||
defaultUrl={linkAttrs.href}
|
||||
defaultText={selectedText}
|
||||
defaultIsNewTab={linkAttrs.target === '_blank'}
|
||||
onSave={onSetLink}
|
||||
className="w-full min-w-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none"
|
||||
/>
|
||||
) : (
|
||||
<LinkPopoverBlock onClear={onUnsetLink} url={linkAttrs.href} onEdit={handleEdit} />
|
||||
)}
|
||||
</BubbleMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import React, { useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { storeImage } from "@/app/actions"
|
||||
|
||||
interface ImageEditBlockProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
editor: Editor
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const ImageEditBlock = ({ editor, className, close, ...props }: ImageEditBlockProps) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [link, setLink] = useState<string>("")
|
||||
const [isUploading, setIsUploading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleLink = () => {
|
||||
editor.chain().focus().setImage({ src: link }).run()
|
||||
close()
|
||||
}
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setIsUploading(true)
|
||||
setError(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", files[0])
|
||||
|
||||
try {
|
||||
const [response, err] = await storeImage(formData)
|
||||
|
||||
if (err) {
|
||||
throw new Error(err.fieldErrors?.file?.join(", "))
|
||||
}
|
||||
|
||||
if (response?.fileModel) {
|
||||
editor.chain().setImage({ src: response.fileModel.content.src }).focus().run()
|
||||
close()
|
||||
} else {
|
||||
throw new Error("Failed to upload image")
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "An unknown error occurred")
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
handleLink()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={cn("space-y-5", className)} {...props}>
|
||||
<div className="space-y-1">
|
||||
<Label>Attach an image link</Label>
|
||||
<div className="flex">
|
||||
<Input
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com"
|
||||
value={link}
|
||||
className="grow"
|
||||
onChange={e => setLink(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" className="ml-2 inline-block">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" onClick={handleClick} disabled={isUploading}>
|
||||
{isUploading ? "Uploading..." : "Upload from your computer"}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
{error && <div className="text-destructive text-sm">{error}</div>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export { ImageEditBlock }
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { useState } from 'react'
|
||||
import { ImageIcon } from '@radix-ui/react-icons'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { ImageEditBlock } from './image-edit-block'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { toggleVariants } from '@/components/ui/toggle'
|
||||
|
||||
interface ImageEditDialogProps extends VariantProps<typeof toggleVariants> {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
const ImageEditDialog = ({ editor, size, variant }: ImageEditDialogProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<ToolbarButton
|
||||
isActive={editor.isActive('image')}
|
||||
tooltip="Image"
|
||||
aria-label="Image"
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
<ImageIcon className="size-5" />
|
||||
</ToolbarButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select image</DialogTitle>
|
||||
<DialogDescription className="sr-only">Upload an image from your computer</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ImageEditBlock editor={editor} close={() => setOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export { ImageEditDialog }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { TrashIcon } from '@radix-ui/react-icons'
|
||||
|
||||
const ImagePopoverBlock = ({ onRemove }: { onRemove: (e: React.MouseEvent<HTMLButtonElement>) => void }) => {
|
||||
const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
onRemove(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-10 overflow-hidden rounded bg-background p-2 shadow-lg">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<ToolbarButton tooltip="Remove" onClick={handleRemove}>
|
||||
<TrashIcon className="size-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ImagePopoverBlock }
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface LinkEditorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
defaultUrl?: string
|
||||
defaultText?: string
|
||||
defaultIsNewTab?: boolean
|
||||
onSave: (url: string, text?: string, isNewTab?: boolean) => void
|
||||
}
|
||||
|
||||
export const LinkEditBlock = React.forwardRef<HTMLDivElement, LinkEditorProps>(
|
||||
({ onSave, defaultIsNewTab, defaultUrl, defaultText, className }, ref) => {
|
||||
const formRef = React.useRef<HTMLDivElement>(null)
|
||||
const [url, setUrl] = React.useState(defaultUrl || '')
|
||||
const [text, setText] = React.useState(defaultText || '')
|
||||
const [isNewTab, setIsNewTab] = React.useState(defaultIsNewTab || false)
|
||||
|
||||
const handleSave = React.useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (formRef.current) {
|
||||
const isValid = Array.from(formRef.current.querySelectorAll('input')).every(input => input.checkValidity())
|
||||
|
||||
if (isValid) {
|
||||
onSave(url, text, isNewTab)
|
||||
} else {
|
||||
formRef.current.querySelectorAll('input').forEach(input => {
|
||||
if (!input.checkValidity()) {
|
||||
input.reportValidity()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSave, url, text, isNewTab]
|
||||
)
|
||||
|
||||
React.useImperativeHandle(ref, () => formRef.current as HTMLDivElement)
|
||||
|
||||
return (
|
||||
<div ref={formRef}>
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="space-y-1">
|
||||
<Label>URL</Label>
|
||||
<Input type="url" required placeholder="Enter URL" value={url} onChange={e => setUrl(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Display Text (optional)</Label>
|
||||
<Input type="text" placeholder="Enter display text" value={text} onChange={e => setText(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label>Open in New Tab</Label>
|
||||
<Switch checked={isNewTab} onCheckedChange={setIsNewTab} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
LinkEditBlock.displayName = 'LinkEditBlock'
|
||||
|
||||
export default LinkEditBlock
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import * as React from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Link2Icon } from '@radix-ui/react-icons'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { LinkEditBlock } from './link-edit-block'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { toggleVariants } from '@/components/ui/toggle'
|
||||
|
||||
interface LinkEditPopoverProps extends VariantProps<typeof toggleVariants> {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
const LinkEditPopover = ({ editor, size, variant }: LinkEditPopoverProps) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const { from, to } = editor.state.selection
|
||||
const text = editor.state.doc.textBetween(from, to, ' ')
|
||||
|
||||
const onSetLink = React.useCallback(
|
||||
(url: string, text?: string, openInNewTab?: boolean) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: text || url,
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: url,
|
||||
target: openInNewTab ? '_blank' : ''
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
.setLink({ href: url })
|
||||
.run()
|
||||
|
||||
editor.commands.enter()
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<ToolbarButton
|
||||
isActive={editor.isActive('link')}
|
||||
tooltip="Link"
|
||||
aria-label="Insert link"
|
||||
disabled={editor.isActive('codeBlock')}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
<Link2Icon className="size-5" />
|
||||
</ToolbarButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full min-w-80" align="start" side="bottom">
|
||||
<LinkEditBlock onSave={onSetLink} defaultText={text} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { LinkEditPopover }
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { CopyIcon, ExternalLinkIcon, LinkBreak2Icon } from '@radix-ui/react-icons'
|
||||
|
||||
interface LinkPopoverBlockProps {
|
||||
url: string
|
||||
onClear: () => void
|
||||
onEdit: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
export const LinkPopoverBlock: React.FC<LinkPopoverBlockProps> = ({ url, onClear, onEdit }) => {
|
||||
const [copyTitle, setCopyTitle] = useState<string>('Copy')
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
setCopyTitle('Copied!')
|
||||
setTimeout(() => setCopyTitle('Copy'), 1000)
|
||||
})
|
||||
.catch(console.error)
|
||||
},
|
||||
[url]
|
||||
)
|
||||
|
||||
const handleOpenLink = useCallback(() => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}, [url])
|
||||
|
||||
return (
|
||||
<div className="flex h-10 overflow-hidden rounded bg-background p-2 shadow-lg">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<ToolbarButton tooltip="Edit link" onClick={onEdit} className="w-auto px-2">
|
||||
Edit link
|
||||
</ToolbarButton>
|
||||
<Separator orientation="vertical" />
|
||||
<ToolbarButton tooltip="Open link in a new tab" onClick={handleOpenLink}>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</ToolbarButton>
|
||||
<Separator orientation="vertical" />
|
||||
<ToolbarButton tooltip="Clear link" onClick={onClear}>
|
||||
<LinkBreak2Icon className="size-4" />
|
||||
</ToolbarButton>
|
||||
<Separator orientation="vertical" />
|
||||
<ToolbarButton
|
||||
tooltip={copyTitle}
|
||||
onClick={handleCopy}
|
||||
tooltipOptions={{
|
||||
onPointerDownOutside: e => {
|
||||
if (e.target === e.currentTarget) e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
web/components/minimal-tiptap/components/section/five.tsx
Normal file
84
web/components/minimal-tiptap/components/section/five.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import * as React from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { CaretDownIcon, CodeIcon, DividerHorizontalIcon, PlusIcon, QuoteIcon } from '@radix-ui/react-icons'
|
||||
import { LinkEditPopover } from '../link/link-edit-popover'
|
||||
import { ImageEditDialog } from '../image/image-edit-dialog'
|
||||
import type { FormatAction } from '../../types'
|
||||
import { ToolbarSection } from '../toolbar-section'
|
||||
import type { toggleVariants } from '@/components/ui/toggle'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
type InsertElementAction = 'codeBlock' | 'blockquote' | 'horizontalRule'
|
||||
interface InsertElement extends FormatAction {
|
||||
value: InsertElementAction
|
||||
}
|
||||
|
||||
const formatActions: InsertElement[] = [
|
||||
{
|
||||
value: 'codeBlock',
|
||||
label: 'Code block',
|
||||
icon: <CodeIcon className="size-5" />,
|
||||
action: editor => editor.chain().focus().toggleCodeBlock().run(),
|
||||
isActive: editor => editor.isActive('codeBlock'),
|
||||
canExecute: editor => editor.can().chain().focus().toggleCodeBlock().run(),
|
||||
shortcuts: ['mod', 'alt', 'C']
|
||||
},
|
||||
{
|
||||
value: 'blockquote',
|
||||
label: 'Blockquote',
|
||||
icon: <QuoteIcon className="size-5" />,
|
||||
action: editor => editor.chain().focus().toggleBlockquote().run(),
|
||||
isActive: editor => editor.isActive('blockquote'),
|
||||
canExecute: editor => editor.can().chain().focus().toggleBlockquote().run(),
|
||||
shortcuts: ['mod', 'shift', 'B']
|
||||
},
|
||||
{
|
||||
value: 'horizontalRule',
|
||||
label: 'Divider',
|
||||
icon: <DividerHorizontalIcon className="size-5" />,
|
||||
action: editor => editor.chain().focus().setHorizontalRule().run(),
|
||||
isActive: () => false,
|
||||
canExecute: editor => editor.can().chain().focus().setHorizontalRule().run(),
|
||||
shortcuts: ['mod', 'alt', '-']
|
||||
}
|
||||
]
|
||||
|
||||
interface SectionFiveProps extends VariantProps<typeof toggleVariants> {
|
||||
editor: Editor
|
||||
activeActions?: InsertElementAction[]
|
||||
mainActionCount?: number
|
||||
}
|
||||
|
||||
export const SectionFive: React.FC<SectionFiveProps> = ({
|
||||
editor,
|
||||
activeActions = formatActions.map(action => action.value),
|
||||
mainActionCount = 0,
|
||||
size,
|
||||
variant
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<LinkEditPopover editor={editor} size={size} variant={variant} />
|
||||
<ImageEditDialog editor={editor} size={size} variant={variant} />
|
||||
<ToolbarSection
|
||||
editor={editor}
|
||||
actions={formatActions}
|
||||
activeActions={activeActions}
|
||||
mainActionCount={mainActionCount}
|
||||
dropdownIcon={
|
||||
<>
|
||||
<PlusIcon className="size-5" />
|
||||
<CaretDownIcon className="size-5" />
|
||||
</>
|
||||
}
|
||||
dropdownTooltip="Insert elements"
|
||||
size={size}
|
||||
variant={variant}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
SectionFive.displayName = 'SectionFive'
|
||||
|
||||
export default SectionFive
|
||||
73
web/components/minimal-tiptap/components/section/four.tsx
Normal file
73
web/components/minimal-tiptap/components/section/four.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as React from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { CaretDownIcon, ListBulletIcon } from '@radix-ui/react-icons'
|
||||
import type { FormatAction } from '../../types'
|
||||
import { ToolbarSection } from '../toolbar-section'
|
||||
import type { toggleVariants } from '@/components/ui/toggle'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
type ListItemAction = 'orderedList' | 'bulletList'
|
||||
interface ListItem extends FormatAction {
|
||||
value: ListItemAction
|
||||
}
|
||||
|
||||
const formatActions: ListItem[] = [
|
||||
{
|
||||
value: 'orderedList',
|
||||
label: 'Numbered list',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="currentColor">
|
||||
<path d="M144-144v-48h96v-24h-48v-48h48v-24h-96v-48h120q10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v48q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9 10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v48q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9H144Zm0-240v-96q0-10.2 6.9-17.1 6.9-6.9 17.1-6.9h72v-24h-96v-48h120q10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v72q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9h-72v24h96v48H144Zm48-240v-144h-48v-48h96v192h-48Zm168 384v-72h456v72H360Zm0-204v-72h456v72H360Zm0-204v-72h456v72H360Z" />
|
||||
</svg>
|
||||
),
|
||||
isActive: editor => editor.isActive('orderedList'),
|
||||
action: editor => editor.chain().focus().toggleOrderedList().run(),
|
||||
canExecute: editor => editor.can().chain().focus().toggleOrderedList().run(),
|
||||
shortcuts: ['mod', 'shift', '7']
|
||||
},
|
||||
{
|
||||
value: 'bulletList',
|
||||
label: 'Bullet list',
|
||||
icon: <ListBulletIcon className="size-5" />,
|
||||
isActive: editor => editor.isActive('bulletList'),
|
||||
action: editor => editor.chain().focus().toggleBulletList().run(),
|
||||
canExecute: editor => editor.can().chain().focus().toggleBulletList().run(),
|
||||
shortcuts: ['mod', 'shift', '8']
|
||||
}
|
||||
]
|
||||
|
||||
interface SectionFourProps extends VariantProps<typeof toggleVariants> {
|
||||
editor: Editor
|
||||
activeActions?: ListItemAction[]
|
||||
mainActionCount?: number
|
||||
}
|
||||
|
||||
export const SectionFour: React.FC<SectionFourProps> = ({
|
||||
editor,
|
||||
activeActions = formatActions.map(action => action.value),
|
||||
mainActionCount = 0,
|
||||
size,
|
||||
variant
|
||||
}) => {
|
||||
return (
|
||||
<ToolbarSection
|
||||
editor={editor}
|
||||
actions={formatActions}
|
||||
activeActions={activeActions}
|
||||
mainActionCount={mainActionCount}
|
||||
dropdownIcon={
|
||||
<>
|
||||
<ListBulletIcon className="size-5" />
|
||||
<CaretDownIcon className="size-5" />
|
||||
</>
|
||||
}
|
||||
dropdownTooltip="Lists"
|
||||
size={size}
|
||||
variant={variant}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
SectionFour.displayName = 'SectionFour'
|
||||
|
||||
export default SectionFour
|
||||
137
web/components/minimal-tiptap/components/section/one.tsx
Normal file
137
web/components/minimal-tiptap/components/section/one.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import type { Level } from '@tiptap/extension-heading'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CaretDownIcon, LetterCaseCapitalizeIcon } from '@radix-ui/react-icons'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { ShortcutKey } from '../shortcut-key'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import type { FormatAction } from '../../types'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { toggleVariants } from '@/components/ui/toggle'
|
||||
|
||||
interface TextStyle extends Omit<FormatAction, 'value' | 'icon' | 'action' | 'isActive' | 'canExecute'> {
|
||||
element: keyof JSX.IntrinsicElements
|
||||
level?: Level
|
||||
className: string
|
||||
}
|
||||
|
||||
const formatActions: TextStyle[] = [
|
||||
{
|
||||
label: 'Normal Text',
|
||||
element: 'span',
|
||||
className: 'grow',
|
||||
shortcuts: ['mod', 'alt', '0']
|
||||
},
|
||||
{
|
||||
label: 'Heading 1',
|
||||
element: 'h1',
|
||||
level: 1,
|
||||
className: 'm-0 grow text-3xl font-extrabold',
|
||||
shortcuts: ['mod', 'alt', '1']
|
||||
},
|
||||
{
|
||||
label: 'Heading 2',
|
||||
element: 'h2',
|
||||
level: 2,
|
||||
className: 'm-0 grow text-xl font-bold',
|
||||
shortcuts: ['mod', 'alt', '2']
|
||||
},
|
||||
{
|
||||
label: 'Heading 3',
|
||||
element: 'h3',
|
||||
level: 3,
|
||||
className: 'm-0 grow text-lg font-semibold',
|
||||
shortcuts: ['mod', 'alt', '3']
|
||||
},
|
||||
{
|
||||
label: 'Heading 4',
|
||||
element: 'h4',
|
||||
level: 4,
|
||||
className: 'm-0 grow text-base font-semibold',
|
||||
shortcuts: ['mod', 'alt', '4']
|
||||
},
|
||||
{
|
||||
label: 'Heading 5',
|
||||
element: 'h5',
|
||||
level: 5,
|
||||
className: 'm-0 grow text-sm font-normal',
|
||||
shortcuts: ['mod', 'alt', '5']
|
||||
},
|
||||
{
|
||||
label: 'Heading 6',
|
||||
element: 'h6',
|
||||
level: 6,
|
||||
className: 'm-0 grow text-sm font-normal',
|
||||
shortcuts: ['mod', 'alt', '6']
|
||||
}
|
||||
]
|
||||
|
||||
interface SectionOneProps extends VariantProps<typeof toggleVariants> {
|
||||
editor: Editor
|
||||
activeLevels?: Level[]
|
||||
}
|
||||
|
||||
export const SectionOne: React.FC<SectionOneProps> = React.memo(
|
||||
({ editor, activeLevels = [1, 2, 3, 4, 5, 6], size, variant }) => {
|
||||
const filteredActions = useMemo(
|
||||
() => formatActions.filter(action => !action.level || activeLevels.includes(action.level)),
|
||||
[activeLevels]
|
||||
)
|
||||
|
||||
const handleStyleChange = useCallback(
|
||||
(level?: Level) => {
|
||||
if (level) {
|
||||
editor.chain().focus().toggleHeading({ level }).run()
|
||||
} else {
|
||||
editor.chain().focus().setParagraph().run()
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
({ label, element: Element, level, className, shortcuts }: TextStyle) => (
|
||||
<DropdownMenuItem
|
||||
key={label}
|
||||
onClick={() => handleStyleChange(level)}
|
||||
className={cn('flex flex-row items-center justify-between gap-4', {
|
||||
'bg-accent': level ? editor.isActive('heading', { level }) : editor.isActive('paragraph')
|
||||
})}
|
||||
aria-label={label}
|
||||
>
|
||||
<Element className={className}>{label}</Element>
|
||||
<ShortcutKey keys={shortcuts} />
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
[editor, handleStyleChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton
|
||||
isActive={editor.isActive('heading')}
|
||||
tooltip="Text styles"
|
||||
aria-label="Text styles"
|
||||
pressed={editor.isActive('heading')}
|
||||
className="w-12"
|
||||
disabled={editor.isActive('codeBlock')}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
<LetterCaseCapitalizeIcon className="size-5" />
|
||||
<CaretDownIcon className="size-5" />
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-full">
|
||||
{filteredActions.map(renderMenuItem)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SectionOne.displayName = 'SectionOne'
|
||||
|
||||
export default SectionOne
|
||||
191
web/components/minimal-tiptap/components/section/three.tsx
Normal file
191
web/components/minimal-tiptap/components/section/three.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import * as React from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { CaretDownIcon, CheckIcon } from '@radix-ui/react-icons'
|
||||
import { ToolbarButton } from '../toolbar-button'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useTheme } from '../../hooks/use-theme'
|
||||
import type { toggleVariants } from '@/components/ui/toggle'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
interface ColorItem {
|
||||
cssVar: string
|
||||
label: string
|
||||
darkLabel?: string
|
||||
}
|
||||
|
||||
interface ColorPalette {
|
||||
label: string
|
||||
colors: ColorItem[]
|
||||
inverse: string
|
||||
}
|
||||
|
||||
const COLORS: ColorPalette[] = [
|
||||
{
|
||||
label: 'Palette 1',
|
||||
inverse: 'hsl(var(--background))',
|
||||
colors: [
|
||||
{ cssVar: 'hsl(var(--foreground))', label: 'Default' },
|
||||
{ cssVar: 'var(--mt-accent-bold-blue)', label: 'Bold blue' },
|
||||
{ cssVar: 'var(--mt-accent-bold-teal)', label: 'Bold teal' },
|
||||
{ cssVar: 'var(--mt-accent-bold-green)', label: 'Bold green' },
|
||||
{ cssVar: 'var(--mt-accent-bold-orange)', label: 'Bold orange' },
|
||||
{ cssVar: 'var(--mt-accent-bold-red)', label: 'Bold red' },
|
||||
{ cssVar: 'var(--mt-accent-bold-purple)', label: 'Bold purple' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Palette 2',
|
||||
inverse: 'hsl(var(--background))',
|
||||
colors: [
|
||||
{ cssVar: 'var(--mt-accent-gray)', label: 'Gray' },
|
||||
{ cssVar: 'var(--mt-accent-blue)', label: 'Blue' },
|
||||
{ cssVar: 'var(--mt-accent-teal)', label: 'Teal' },
|
||||
{ cssVar: 'var(--mt-accent-green)', label: 'Green' },
|
||||
{ cssVar: 'var(--mt-accent-orange)', label: 'Orange' },
|
||||
{ cssVar: 'var(--mt-accent-red)', label: 'Red' },
|
||||
{ cssVar: 'var(--mt-accent-purple)', label: 'Purple' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Palette 3',
|
||||
inverse: 'hsl(var(--foreground))',
|
||||
colors: [
|
||||
{ cssVar: 'hsl(var(--background))', label: 'White', darkLabel: 'Black' },
|
||||
{ cssVar: 'var(--mt-accent-blue-subtler)', label: 'Blue subtle' },
|
||||
{ cssVar: 'var(--mt-accent-teal-subtler)', label: 'Teal subtle' },
|
||||
{ cssVar: 'var(--mt-accent-green-subtler)', label: 'Green subtle' },
|
||||
{ cssVar: 'var(--mt-accent-yellow-subtler)', label: 'Yellow subtle' },
|
||||
{ cssVar: 'var(--mt-accent-red-subtler)', label: 'Red subtle' },
|
||||
{ cssVar: 'var(--mt-accent-purple-subtler)', label: 'Purple subtle' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const MemoizedColorButton = React.memo<{
|
||||
color: ColorItem
|
||||
isSelected: boolean
|
||||
inverse: string
|
||||
onClick: (value: string) => void
|
||||
}>(({ color, isSelected, inverse, onClick }) => {
|
||||
const isDarkMode = useTheme()
|
||||
const label = isDarkMode && color.darkLabel ? color.darkLabel : color.label
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem
|
||||
className="relative size-7 rounded-md p-0"
|
||||
value={color.cssVar}
|
||||
aria-label={label}
|
||||
style={{ backgroundColor: color.cssVar }}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
onClick(color.cssVar)
|
||||
}}
|
||||
>
|
||||
{isSelected && <CheckIcon className="absolute inset-0 m-auto size-6" style={{ color: inverse }} />}
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
|
||||
MemoizedColorButton.displayName = 'MemoizedColorButton'
|
||||
|
||||
const MemoizedColorPicker = React.memo<{
|
||||
palette: ColorPalette
|
||||
selectedColor: string
|
||||
inverse: string
|
||||
onColorChange: (value: string) => void
|
||||
}>(({ palette, selectedColor, inverse, onColorChange }) => (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={selectedColor}
|
||||
onValueChange={(value: string) => {
|
||||
if (value) onColorChange(value)
|
||||
}}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{palette.colors.map((color, index) => (
|
||||
<MemoizedColorButton
|
||||
key={index}
|
||||
inverse={inverse}
|
||||
color={color}
|
||||
isSelected={selectedColor === color.cssVar}
|
||||
onClick={onColorChange}
|
||||
/>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
))
|
||||
|
||||
MemoizedColorPicker.displayName = 'MemoizedColorPicker'
|
||||
|
||||
interface SectionThreeProps extends VariantProps<typeof toggleVariants> {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
export const SectionThree: React.FC<SectionThreeProps> = ({ editor, size, variant }) => {
|
||||
const color = editor.getAttributes('textStyle')?.color || 'hsl(var(--foreground))'
|
||||
const [selectedColor, setSelectedColor] = React.useState(color)
|
||||
|
||||
const handleColorChange = React.useCallback(
|
||||
(value: string) => {
|
||||
setSelectedColor(value)
|
||||
editor.chain().setColor(value).run()
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedColor(color)
|
||||
}, [color])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<ToolbarButton tooltip="Text color" aria-label="Text color" className="w-12" size={size} variant={variant}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-5"
|
||||
style={{ color: selectedColor }}
|
||||
>
|
||||
<path d="M4 20h16" />
|
||||
<path d="m6 16 6-12 6 12" />
|
||||
<path d="M8 12h8" />
|
||||
</svg>
|
||||
<CaretDownIcon className="size-5" />
|
||||
</ToolbarButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-full">
|
||||
<div className="space-y-1.5">
|
||||
{COLORS.map((palette, index) => (
|
||||
<MemoizedColorPicker
|
||||
key={index}
|
||||
palette={palette}
|
||||
inverse={palette.inverse}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={handleColorChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
SectionThree.displayName = 'SectionThree'
|
||||
|
||||
export default SectionThree
|
||||
100
web/components/minimal-tiptap/components/section/two.tsx
Normal file
100
web/components/minimal-tiptap/components/section/two.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import {
|
||||
CodeIcon,
|
||||
DotsHorizontalIcon,
|
||||
FontBoldIcon,
|
||||
FontItalicIcon,
|
||||
StrikethroughIcon,
|
||||
TextNoneIcon
|
||||
} from '@radix-ui/react-icons'
|
||||
import type { FormatAction } from '../../types'
|
||||
import { ToolbarSection } from '../toolbar-section'
|
||||
import type { toggleVariants } from '@/components/ui/toggle'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
type TextStyleAction = 'bold' | 'italic' | 'strikethrough' | 'code' | 'clearFormatting'
|
||||
|
||||
interface TextStyle extends FormatAction {
|
||||
value: TextStyleAction
|
||||
}
|
||||
|
||||
const formatActions: TextStyle[] = [
|
||||
{
|
||||
value: 'bold',
|
||||
label: 'Bold',
|
||||
icon: <FontBoldIcon className="size-5" />,
|
||||
action: editor => editor.chain().focus().toggleBold().run(),
|
||||
isActive: editor => editor.isActive('bold'),
|
||||
canExecute: editor => editor.can().chain().focus().toggleBold().run() && !editor.isActive('codeBlock'),
|
||||
shortcuts: ['mod', 'B']
|
||||
},
|
||||
{
|
||||
value: 'italic',
|
||||
label: 'Italic',
|
||||
icon: <FontItalicIcon className="size-5" />,
|
||||
action: editor => editor.chain().focus().toggleItalic().run(),
|
||||
isActive: editor => editor.isActive('italic'),
|
||||
canExecute: editor => editor.can().chain().focus().toggleItalic().run() && !editor.isActive('codeBlock'),
|
||||
shortcuts: ['mod', 'I']
|
||||
},
|
||||
{
|
||||
value: 'strikethrough',
|
||||
label: 'Strikethrough',
|
||||
icon: <StrikethroughIcon className="size-5" />,
|
||||
action: editor => editor.chain().focus().toggleStrike().run(),
|
||||
isActive: editor => editor.isActive('strike'),
|
||||
canExecute: editor => editor.can().chain().focus().toggleStrike().run() && !editor.isActive('codeBlock'),
|
||||
shortcuts: ['mod', 'shift', 'S']
|
||||
},
|
||||
{
|
||||
value: 'code',
|
||||
label: 'Code',
|
||||
icon: <CodeIcon className="size-5" />,
|
||||
action: editor => editor.chain().focus().toggleCode().run(),
|
||||
isActive: editor => editor.isActive('code'),
|
||||
canExecute: editor => editor.can().chain().focus().toggleCode().run() && !editor.isActive('codeBlock'),
|
||||
shortcuts: ['mod', 'E']
|
||||
},
|
||||
{
|
||||
value: 'clearFormatting',
|
||||
label: 'Clear formatting',
|
||||
icon: <TextNoneIcon className="size-5" />,
|
||||
action: editor => editor.chain().focus().unsetAllMarks().run(),
|
||||
isActive: () => false,
|
||||
canExecute: editor => editor.can().chain().focus().unsetAllMarks().run() && !editor.isActive('codeBlock'),
|
||||
shortcuts: ['mod', '\\']
|
||||
}
|
||||
]
|
||||
|
||||
interface SectionTwoProps extends VariantProps<typeof toggleVariants> {
|
||||
editor: Editor
|
||||
activeActions?: TextStyleAction[]
|
||||
mainActionCount?: number
|
||||
}
|
||||
|
||||
export const SectionTwo: React.FC<SectionTwoProps> = ({
|
||||
editor,
|
||||
activeActions = formatActions.map(action => action.value),
|
||||
mainActionCount = 2,
|
||||
size,
|
||||
variant
|
||||
}) => {
|
||||
return (
|
||||
<ToolbarSection
|
||||
editor={editor}
|
||||
actions={formatActions}
|
||||
activeActions={activeActions}
|
||||
mainActionCount={mainActionCount}
|
||||
dropdownIcon={<DotsHorizontalIcon className="size-5" />}
|
||||
dropdownTooltip="More formatting"
|
||||
dropdownClassName="w-8"
|
||||
size={size}
|
||||
variant={variant}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
SectionTwo.displayName = 'SectionTwo'
|
||||
|
||||
export default SectionTwo
|
||||
33
web/components/minimal-tiptap/components/shortcut-key.tsx
Normal file
33
web/components/minimal-tiptap/components/shortcut-key.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getShortcutKey } from "@/lib/utils"
|
||||
|
||||
export interface ShortcutKeyProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
export const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ className, keys, ...props }, ref) => {
|
||||
const modifiedKeys = keys.map(key => getShortcutKey(key))
|
||||
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ")
|
||||
|
||||
return (
|
||||
<span aria-label={ariaLabel} className={cn("inline-flex items-center gap-0.5", className)} {...props} ref={ref}>
|
||||
{modifiedKeys.map(shortcut => (
|
||||
<kbd
|
||||
key={shortcut.symbol}
|
||||
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}
|
||||
>
|
||||
{shortcut.symbol}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
ShortcutKey.displayName = "ShortcutKey"
|
||||
38
web/components/minimal-tiptap/components/toolbar-button.tsx
Normal file
38
web/components/minimal-tiptap/components/toolbar-button.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Toggle } from '@/components/ui/toggle'
|
||||
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
|
||||
}
|
||||
|
||||
export const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
||||
({ isActive, children, tooltip, className, tooltipOptions, ...props }, ref) => {
|
||||
const toggleButton = (
|
||||
<Toggle size="sm" ref={ref} className={cn('size-8 p-0', { 'bg-accent': isActive }, className)} {...props}>
|
||||
{children}
|
||||
</Toggle>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return toggleButton
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{toggleButton}</TooltipTrigger>
|
||||
<TooltipContent {...tooltipOptions}>
|
||||
<div className="flex flex-col items-center text-center">{tooltip}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ToolbarButton.displayName = 'ToolbarButton'
|
||||
|
||||
export default ToolbarButton
|
||||
112
web/components/minimal-tiptap/components/toolbar-section.tsx
Normal file
112
web/components/minimal-tiptap/components/toolbar-section.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as React from "react"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CaretDownIcon } from "@radix-ui/react-icons"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { ToolbarButton } from "./toolbar-button"
|
||||
import { ShortcutKey } from "./shortcut-key"
|
||||
import { getShortcutKey } from "@/lib/utils"
|
||||
import type { FormatAction } from "../types"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import type { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
interface ToolbarSectionProps extends VariantProps<typeof toggleVariants> {
|
||||
editor: Editor
|
||||
actions: FormatAction[]
|
||||
activeActions?: string[]
|
||||
mainActionCount?: number
|
||||
dropdownIcon?: React.ReactNode
|
||||
dropdownTooltip?: string
|
||||
dropdownClassName?: string
|
||||
}
|
||||
|
||||
export const ToolbarSection: React.FC<ToolbarSectionProps> = ({
|
||||
editor,
|
||||
actions,
|
||||
activeActions = actions.map(action => action.value),
|
||||
mainActionCount = 0,
|
||||
dropdownIcon,
|
||||
dropdownTooltip = "More options",
|
||||
dropdownClassName = "w-12",
|
||||
size,
|
||||
variant
|
||||
}) => {
|
||||
const { mainActions, dropdownActions } = React.useMemo(() => {
|
||||
const sortedActions = actions
|
||||
.filter(action => activeActions.includes(action.value))
|
||||
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
|
||||
|
||||
return {
|
||||
mainActions: sortedActions.slice(0, mainActionCount),
|
||||
dropdownActions: sortedActions.slice(mainActionCount)
|
||||
}
|
||||
}, [actions, activeActions, mainActionCount])
|
||||
|
||||
const renderToolbarButton = React.useCallback(
|
||||
(action: FormatAction) => (
|
||||
<ToolbarButton
|
||||
key={action.label}
|
||||
onClick={() => action.action(editor)}
|
||||
disabled={!action.canExecute(editor)}
|
||||
isActive={action.isActive(editor)}
|
||||
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(" ")}`}
|
||||
aria-label={action.label}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
{action.icon}
|
||||
</ToolbarButton>
|
||||
),
|
||||
[editor, size, variant]
|
||||
)
|
||||
|
||||
const renderDropdownMenuItem = React.useCallback(
|
||||
(action: FormatAction) => (
|
||||
<DropdownMenuItem
|
||||
key={action.label}
|
||||
onClick={() => action.action(editor)}
|
||||
disabled={!action.canExecute(editor)}
|
||||
className={cn("flex flex-row items-center justify-between gap-4", {
|
||||
"bg-accent": action.isActive(editor)
|
||||
})}
|
||||
aria-label={action.label}
|
||||
>
|
||||
<span className="grow">{action.label}</span>
|
||||
<ShortcutKey keys={action.shortcuts} />
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
[editor]
|
||||
)
|
||||
|
||||
const isDropdownActive = React.useMemo(
|
||||
() => dropdownActions.some(action => action.isActive(editor)),
|
||||
[dropdownActions, editor]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{mainActions.map(renderToolbarButton)}
|
||||
{dropdownActions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton
|
||||
isActive={isDropdownActive}
|
||||
tooltip={dropdownTooltip}
|
||||
aria-label={dropdownTooltip}
|
||||
className={cn(dropdownClassName)}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
{dropdownIcon || <CaretDownIcon className="size-5" />}
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-full">
|
||||
{dropdownActions.map(renderDropdownMenuItem)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolbarSection
|
||||
@@ -0,0 +1,17 @@
|
||||
import { CodeBlockLowlight as TiptapCodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
|
||||
export const CodeBlockLowlight = TiptapCodeBlockLowlight.extend({
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
lowlight: createLowlight(common),
|
||||
defaultLanguage: null,
|
||||
HTMLAttributes: {
|
||||
class: 'block-node'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default CodeBlockLowlight
|
||||
@@ -0,0 +1 @@
|
||||
export * from './code-block-lowlight'
|
||||
20
web/components/minimal-tiptap/extensions/color/color.ts
Normal file
20
web/components/minimal-tiptap/extensions/color/color.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Color as TiptapColor } from '@tiptap/extension-color'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
|
||||
export const Color = TiptapColor.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
new Plugin({
|
||||
props: {
|
||||
handleKeyDown: (_, event) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.editor.commands.unsetColor()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
1
web/components/minimal-tiptap/extensions/color/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/color/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './color'
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Wrap the horizontal rule in a div element.
|
||||
* Also add a keyboard shortcut to insert a horizontal rule.
|
||||
*/
|
||||
import { HorizontalRule as TiptapHorizontalRule } from '@tiptap/extension-horizontal-rule'
|
||||
|
||||
export const HorizontalRule = TiptapHorizontalRule.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-Alt--': () =>
|
||||
this.editor.commands.insertContent({
|
||||
type: this.name
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default HorizontalRule
|
||||
@@ -0,0 +1 @@
|
||||
export * from './horizontal-rule'
|
||||
@@ -0,0 +1,45 @@
|
||||
import { isNumber, NodeViewProps, NodeViewWrapper } from '@tiptap/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useImageLoad } from '../../../hooks/use-image-load'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ImageViewBlock = ({ editor, node, getPos }: NodeViewProps) => {
|
||||
const imgSize = useImageLoad(node.attrs.src)
|
||||
|
||||
const paddingBottom = useMemo(() => {
|
||||
if (!imgSize.width || !imgSize.height) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (imgSize.height / imgSize.width) * 100
|
||||
}, [imgSize.width, imgSize.height])
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div draggable data-drag-handle>
|
||||
<figure>
|
||||
<div className="relative w-full" style={{ paddingBottom: `${isNumber(paddingBottom) ? paddingBottom : 0}%` }}>
|
||||
<div className="absolute h-full w-full">
|
||||
<div
|
||||
className={cn('relative h-full max-h-full w-full max-w-full rounded transition-all')}
|
||||
style={{
|
||||
boxShadow: editor.state.selection.from === getPos() ? '0 0 0 1px hsl(var(--primary))' : 'none'
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-full max-h-full w-full max-w-full overflow-hidden">
|
||||
<img
|
||||
alt={node.attrs.alt}
|
||||
src={node.attrs.src}
|
||||
className="absolute left-2/4 top-2/4 m-0 h-full max-w-full -translate-x-2/4 -translate-y-2/4 transform object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export { ImageViewBlock }
|
||||
9
web/components/minimal-tiptap/extensions/image/image.ts
Normal file
9
web/components/minimal-tiptap/extensions/image/image.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Image as TiptapImage } from '@tiptap/extension-image'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { ImageViewBlock } from './components/image-view-block'
|
||||
|
||||
export const Image = TiptapImage.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageViewBlock)
|
||||
}
|
||||
})
|
||||
1
web/components/minimal-tiptap/extensions/image/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/image/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './image'
|
||||
8
web/components/minimal-tiptap/extensions/index.ts
Normal file
8
web/components/minimal-tiptap/extensions/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './code-block-lowlight'
|
||||
export * from './color'
|
||||
export * from './horizontal-rule'
|
||||
export * from './image'
|
||||
export * from './link'
|
||||
export * from './selection'
|
||||
export * from './unset-all-marks'
|
||||
export * from './reset-marks-on-enter'
|
||||
1
web/components/minimal-tiptap/extensions/link/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/link/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './link'
|
||||
89
web/components/minimal-tiptap/extensions/link/link.ts
Normal file
89
web/components/minimal-tiptap/extensions/link/link.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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: (_: 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) {
|
||||
editor.commands.focus(selection.to, { scrollIntoView: false })
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
handleClick(view, pos) {
|
||||
/*
|
||||
* Marks the entire link when the user clicks on it.
|
||||
*/
|
||||
|
||||
const { schema, doc, tr } = view.state
|
||||
const range = getMarkRange(doc.resolve(pos), schema.marks.link)
|
||||
|
||||
if (!range) {
|
||||
return
|
||||
}
|
||||
|
||||
const { from, to } = range
|
||||
const start = Math.min(from, to)
|
||||
const end = Math.max(from, to)
|
||||
|
||||
if (pos < start || pos > end) {
|
||||
return
|
||||
}
|
||||
|
||||
const $start = doc.resolve(start)
|
||||
const $end = doc.resolve(end)
|
||||
const transaction = tr.setSelection(new TextSelection($start, $end))
|
||||
|
||||
view.dispatch(transaction)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export default Link
|
||||
@@ -0,0 +1 @@
|
||||
export * from './reset-marks-on-enter'
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
export const ResetMarksOnEnter = Extension.create({
|
||||
name: 'resetMarksOnEnter',
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor }) => {
|
||||
if (
|
||||
editor.isActive('bold') ||
|
||||
editor.isActive('italic') ||
|
||||
editor.isActive('strike') ||
|
||||
editor.isActive('underline') ||
|
||||
editor.isActive('code')
|
||||
) {
|
||||
editor.commands.splitBlock({ keepMarks: false })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from './selection'
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export * from './unset-all-marks'
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
export const UnsetAllMarks = Extension.create({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-\\': () => this.editor.commands.unsetAllMarks()
|
||||
}
|
||||
}
|
||||
})
|
||||
15
web/components/minimal-tiptap/hooks/use-image-load.ts
Normal file
15
web/components/minimal-tiptap/hooks/use-image-load.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export const useImageLoad = (src: string) => {
|
||||
const [imgSize, setImgSize] = React.useState({ width: 0, height: 0 })
|
||||
|
||||
React.useEffect(() => {
|
||||
const img = new Image()
|
||||
img.src = src
|
||||
img.onload = () => {
|
||||
setImgSize({ width: img.width, height: img.height })
|
||||
}
|
||||
}, [src])
|
||||
|
||||
return imgSize
|
||||
}
|
||||
107
web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts
Normal file
107
web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as React from "react"
|
||||
import { StarterKit } from "@tiptap/starter-kit"
|
||||
import type { Content, UseEditorOptions } from "@tiptap/react"
|
||||
import { useEditor } from "@tiptap/react"
|
||||
import type { Editor } from "@tiptap/core"
|
||||
import { Typography } from "@tiptap/extension-typography"
|
||||
import { Placeholder } from "@tiptap/extension-placeholder"
|
||||
import { TextStyle } from "@tiptap/extension-text-style"
|
||||
import {
|
||||
Link,
|
||||
Image,
|
||||
HorizontalRule,
|
||||
CodeBlockLowlight,
|
||||
Selection,
|
||||
Color,
|
||||
UnsetAllMarks,
|
||||
ResetMarksOnEnter
|
||||
} from "../extensions"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getOutput } from "../utils"
|
||||
import { useThrottle } from "../hooks/use-throttle"
|
||||
|
||||
export interface UseMinimalTiptapEditorProps extends UseEditorOptions {
|
||||
value?: Content
|
||||
output?: "html" | "json" | "text"
|
||||
placeholder?: string
|
||||
editorClassName?: string
|
||||
throttleDelay?: number
|
||||
onUpdate?: (content: Content) => void
|
||||
onBlur?: (content: Content) => void
|
||||
}
|
||||
|
||||
const createExtensions = (placeholder: string) => [
|
||||
StarterKit.configure({
|
||||
horizontalRule: false,
|
||||
codeBlock: false,
|
||||
paragraph: { HTMLAttributes: { class: "text-node" } },
|
||||
heading: { HTMLAttributes: { class: "heading-node" } },
|
||||
blockquote: { HTMLAttributes: { class: "block-node" } },
|
||||
bulletList: { HTMLAttributes: { class: "list-node" } },
|
||||
orderedList: { HTMLAttributes: { class: "list-node" } },
|
||||
code: { HTMLAttributes: { class: "inline", spellcheck: "false" } },
|
||||
dropcursor: { width: 2, class: "ProseMirror-dropcursor border" }
|
||||
}),
|
||||
Link,
|
||||
Image,
|
||||
Color,
|
||||
TextStyle,
|
||||
Selection,
|
||||
Typography,
|
||||
UnsetAllMarks,
|
||||
HorizontalRule,
|
||||
ResetMarksOnEnter,
|
||||
CodeBlockLowlight,
|
||||
Placeholder.configure({ placeholder: () => placeholder })
|
||||
]
|
||||
|
||||
export const useMinimalTiptapEditor = ({
|
||||
value,
|
||||
output = "html",
|
||||
placeholder = "",
|
||||
editorClassName,
|
||||
throttleDelay = 1000,
|
||||
onUpdate,
|
||||
onBlur,
|
||||
...props
|
||||
}: UseMinimalTiptapEditorProps) => {
|
||||
const throttledSetValue = useThrottle((value: Content) => onUpdate?.(value), throttleDelay)
|
||||
|
||||
const handleUpdate = React.useCallback(
|
||||
(editor: Editor) => {
|
||||
throttledSetValue(getOutput(editor, output))
|
||||
},
|
||||
[output, throttledSetValue]
|
||||
)
|
||||
|
||||
const handleCreate = React.useCallback(
|
||||
(editor: Editor) => {
|
||||
if (value && editor.isEmpty) {
|
||||
editor.commands.setContent(value)
|
||||
}
|
||||
},
|
||||
[value]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback((editor: Editor) => onBlur?.(getOutput(editor, output)), [output, onBlur])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: createExtensions(placeholder!),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
autocomplete: "off",
|
||||
autocorrect: "off",
|
||||
autocapitalize: "off",
|
||||
class: cn("focus:outline-none", editorClassName)
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => handleUpdate(editor),
|
||||
onCreate: ({ editor }) => handleCreate(editor),
|
||||
onBlur: ({ editor }) => handleBlur(editor),
|
||||
...props
|
||||
})
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
export default useMinimalTiptapEditor
|
||||
25
web/components/minimal-tiptap/hooks/use-theme.ts
Normal file
25
web/components/minimal-tiptap/hooks/use-theme.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export const useTheme = () => {
|
||||
const [isDarkMode, setIsDarkMode] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
setIsDarkMode(darkModeMediaQuery.matches)
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
const newDarkMode = e.matches
|
||||
setIsDarkMode(newDarkMode)
|
||||
}
|
||||
|
||||
darkModeMediaQuery.addEventListener('change', handleChange)
|
||||
|
||||
return () => {
|
||||
darkModeMediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isDarkMode
|
||||
}
|
||||
|
||||
export default useTheme
|
||||
34
web/components/minimal-tiptap/hooks/use-throttle.ts
Normal file
34
web/components/minimal-tiptap/hooks/use-throttle.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
|
||||
export function useThrottle<T extends (...args: any[]) => void>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
const lastRan = useRef(Date.now())
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
const handler = () => {
|
||||
if (Date.now() - lastRan.current >= delay) {
|
||||
callback(...args)
|
||||
lastRan.current = Date.now()
|
||||
} else {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
callback(...args)
|
||||
lastRan.current = Date.now()
|
||||
},
|
||||
delay - (Date.now() - lastRan.current)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
handler()
|
||||
},
|
||||
[callback, delay]
|
||||
)
|
||||
}
|
||||
1
web/components/minimal-tiptap/index.ts
Normal file
1
web/components/minimal-tiptap/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './minimal-tiptap'
|
||||
95
web/components/minimal-tiptap/minimal-tiptap.tsx
Normal file
95
web/components/minimal-tiptap/minimal-tiptap.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as React from "react"
|
||||
import "./styles/index.css"
|
||||
|
||||
import { EditorContent } from "@tiptap/react"
|
||||
import type { Content, Editor } from "@tiptap/react"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SectionOne } from "./components/section/one"
|
||||
import { SectionTwo } from "./components/section/two"
|
||||
import { SectionThree } from "./components/section/three"
|
||||
import { SectionFour } from "./components/section/four"
|
||||
import { SectionFive } from "./components/section/five"
|
||||
import { LinkBubbleMenu } from "./components/bubble-menu/link-bubble-menu"
|
||||
import { ImageBubbleMenu } from "./components/bubble-menu/image-bubble-menu"
|
||||
import type { UseMinimalTiptapEditorProps } from "./hooks/use-minimal-tiptap"
|
||||
import { useMinimalTiptapEditor } from "./hooks/use-minimal-tiptap"
|
||||
|
||||
export interface MinimalTiptapProps extends Omit<UseMinimalTiptapEditorProps, "onUpdate"> {
|
||||
value?: Content
|
||||
onChange?: (value: Content) => void
|
||||
className?: string
|
||||
editorContentClassName?: string
|
||||
}
|
||||
|
||||
const Toolbar = ({ editor }: { editor: Editor }) => (
|
||||
<div className="border-border shrink-0 overflow-x-auto border-b p-2">
|
||||
<div className="flex w-max items-center gap-px">
|
||||
<SectionOne editor={editor} activeLevels={[1, 2, 3, 4, 5, 6]} />
|
||||
|
||||
<Separator orientation="vertical" className="mx-2 h-7" />
|
||||
|
||||
<SectionTwo
|
||||
editor={editor}
|
||||
activeActions={["bold", "italic", "strikethrough", "code", "clearFormatting"]}
|
||||
mainActionCount={2}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-2 h-7" />
|
||||
|
||||
<SectionThree editor={editor} />
|
||||
|
||||
<Separator orientation="vertical" className="mx-2 h-7" />
|
||||
|
||||
<SectionFour editor={editor} activeActions={["orderedList", "bulletList"]} mainActionCount={0} />
|
||||
|
||||
<Separator orientation="vertical" className="mx-2 h-7" />
|
||||
|
||||
<SectionFive editor={editor} activeActions={["codeBlock", "blockquote", "horizontalRule"]} mainActionCount={0} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MinimalTiptapEditorRef = {
|
||||
editor: Editor | null
|
||||
}
|
||||
|
||||
export const MinimalTiptapEditor = React.forwardRef<MinimalTiptapEditorRef, MinimalTiptapProps>(
|
||||
({ value, onChange, className, editorContentClassName, ...props }, ref) => {
|
||||
const editor = useMinimalTiptapEditor({
|
||||
value,
|
||||
onUpdate: onChange,
|
||||
...props
|
||||
})
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
editor: editor || null
|
||||
}),
|
||||
[editor]
|
||||
)
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-input focus-within:border-primary flex h-auto min-h-72 w-full flex-col rounded-md border shadow-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Toolbar editor={editor} />
|
||||
<EditorContent editor={editor} className={cn("minimal-tiptap-editor", editorContentClassName)} />
|
||||
<LinkBubbleMenu editor={editor} />
|
||||
<ImageBubbleMenu editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MinimalTiptapEditor.displayName = "MinimalTiptapEditor"
|
||||
|
||||
export default MinimalTiptapEditor
|
||||
182
web/components/minimal-tiptap/styles/index.css
Normal file
182
web/components/minimal-tiptap/styles/index.css
Normal file
@@ -0,0 +1,182 @@
|
||||
@import './partials/code.css';
|
||||
@import './partials/placeholder.css';
|
||||
@import './partials/lists.css';
|
||||
@import './partials/typography.css';
|
||||
|
||||
:root {
|
||||
--mt-font-size-regular: 0.9375rem;
|
||||
|
||||
--mt-code-background: #082b781f;
|
||||
--mt-code-color: #d4d4d4;
|
||||
--mt-secondary: #9d9d9f;
|
||||
--mt-pre-background: #ececec;
|
||||
--mt-pre-border: #e0e0e0;
|
||||
--mt-pre-color: #2f2f31;
|
||||
--mt-hr: #dcdcdc;
|
||||
--mt-drag-handle-hover: #5c5c5e;
|
||||
|
||||
--mt-accent-bold-blue: #05c;
|
||||
--mt-accent-bold-teal: #206a83;
|
||||
--mt-accent-bold-green: #216e4e;
|
||||
--mt-accent-bold-orange: #a54800;
|
||||
--mt-accent-bold-red: #ae2e24;
|
||||
--mt-accent-bold-purple: #5e4db2;
|
||||
|
||||
--mt-accent-gray: #758195;
|
||||
--mt-accent-blue: #1d7afc;
|
||||
--mt-accent-teal: #2898bd;
|
||||
--mt-accent-green: #22a06b;
|
||||
--mt-accent-orange: #fea362;
|
||||
--mt-accent-red: #c9372c;
|
||||
--mt-accent-purple: #8270db;
|
||||
|
||||
--mt-accent-blue-subtler: #cce0ff;
|
||||
--mt-accent-teal-subtler: #c6edfb;
|
||||
--mt-accent-green-subtler: #baf3db;
|
||||
--mt-accent-yellow-subtler: #f8e6a0;
|
||||
--mt-accent-red-subtler: #ffd5d2;
|
||||
--mt-accent-purple-subtler: #dfd8fd;
|
||||
|
||||
--hljs-string: #aa430f;
|
||||
--hljs-title: #b08836;
|
||||
--hljs-comment: #999999;
|
||||
--hljs-keyword: #0c5eb1;
|
||||
--hljs-attr: #3a92bc;
|
||||
--hljs-literal: #c82b0f;
|
||||
--hljs-name: #259792;
|
||||
--hljs-selector-tag: #c8500f;
|
||||
--hljs-number: #3da067;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--mt-font-size-regular: 0.9375rem;
|
||||
|
||||
--mt-code-background: #ffffff13;
|
||||
--mt-code-color: #2c2e33;
|
||||
--mt-secondary: #595a5c;
|
||||
--mt-pre-background: #080808;
|
||||
--mt-pre-border: #23252a;
|
||||
--mt-pre-color: #e3e4e6;
|
||||
--mt-hr: #26282d;
|
||||
--mt-drag-handle-hover: #969799;
|
||||
|
||||
--mt-accent-bold-blue: #85b8ff;
|
||||
--mt-accent-bold-teal: #9dd9ee;
|
||||
--mt-accent-bold-green: #7ee2b8;
|
||||
--mt-accent-bold-orange: #fec195;
|
||||
--mt-accent-bold-red: #fd9891;
|
||||
--mt-accent-bold-purple: #b8acf6;
|
||||
|
||||
--mt-accent-gray: #738496;
|
||||
--mt-accent-blue: #388bff;
|
||||
--mt-accent-teal: #42b2d7;
|
||||
--mt-accent-green: #2abb7f;
|
||||
--mt-accent-orange: #a54800;
|
||||
--mt-accent-red: #e2483d;
|
||||
--mt-accent-purple: #8f7ee7;
|
||||
|
||||
--mt-accent-blue-subtler: #09326c;
|
||||
--mt-accent-teal-subtler: #164555;
|
||||
--mt-accent-green-subtler: #164b35;
|
||||
--mt-accent-yellow-subtler: #533f04;
|
||||
--mt-accent-red-subtler: #5d1f1a;
|
||||
--mt-accent-purple-subtler: #352c63;
|
||||
|
||||
--hljs-string: #da936b;
|
||||
--hljs-title: #f1d59d;
|
||||
--hljs-comment: #aaaaaa;
|
||||
--hljs-keyword: #6699cc;
|
||||
--hljs-attr: #90cae8;
|
||||
--hljs-literal: #f2777a;
|
||||
--hljs-name: #5fc0a0;
|
||||
--hljs-selector-tag: #e8c785;
|
||||
--hljs-number: #b6e7b6;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror {
|
||||
@apply flex max-w-full flex-1 cursor-text flex-col;
|
||||
@apply z-0 outline-0;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror > div.editor {
|
||||
@apply block flex-1 whitespace-pre-wrap;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror .block-node:not(:last-child),
|
||||
.minimal-tiptap-editor .ProseMirror .list-node:not(:last-child),
|
||||
.minimal-tiptap-editor .ProseMirror .text-node:not(:last-child) {
|
||||
@apply mb-2.5;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ol,
|
||||
.minimal-tiptap-editor .ProseMirror ul {
|
||||
@apply pl-6;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror blockquote,
|
||||
.minimal-tiptap-editor .ProseMirror dl,
|
||||
.minimal-tiptap-editor .ProseMirror ol,
|
||||
.minimal-tiptap-editor .ProseMirror p,
|
||||
.minimal-tiptap-editor .ProseMirror pre,
|
||||
.minimal-tiptap-editor .ProseMirror ul {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror li {
|
||||
@apply leading-7;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror p {
|
||||
@apply break-words;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror li .text-node:has(+ .list-node),
|
||||
.minimal-tiptap-editor .ProseMirror li > .list-node,
|
||||
.minimal-tiptap-editor .ProseMirror li > .text-node,
|
||||
.minimal-tiptap-editor .ProseMirror li p {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror blockquote {
|
||||
@apply relative pl-3.5;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror blockquote::before,
|
||||
.minimal-tiptap-editor .ProseMirror blockquote.is-empty::before {
|
||||
@apply absolute bottom-0 left-0 top-0 h-full w-1 rounded-sm bg-accent-foreground/15 content-[''];
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror hr {
|
||||
@apply my-3 h-0.5 w-full border-none bg-[var(--mt-hr)];
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror-focused hr.ProseMirror-selectednode {
|
||||
@apply rounded-full outline outline-2 outline-offset-1 outline-muted-foreground;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror .ProseMirror-gapcursor {
|
||||
@apply pointer-events-none absolute hidden;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror .ProseMirror-hideselection {
|
||||
@apply caret-transparent;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror.resize-cursor {
|
||||
@apply cursor-col-resize;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror .selection {
|
||||
@apply inline-block;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror .selection,
|
||||
.minimal-tiptap-editor .ProseMirror *::selection,
|
||||
::selection {
|
||||
@apply bg-primary/25;
|
||||
}
|
||||
|
||||
/* Override native selection when custom selection is present */
|
||||
.minimal-tiptap-editor .ProseMirror .selection::selection {
|
||||
background: transparent;
|
||||
}
|
||||
86
web/components/minimal-tiptap/styles/partials/code.css
Normal file
86
web/components/minimal-tiptap/styles/partials/code.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.minimal-tiptap-editor .ProseMirror code.inline {
|
||||
@apply rounded border border-[var(--mt-code-color)] bg-[var(--mt-code-background)] px-1 py-0.5 text-sm;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror pre {
|
||||
@apply relative overflow-auto rounded border font-mono text-sm;
|
||||
@apply border-[var(--mt-pre-border)] bg-[var(--mt-pre-background)] text-[var(--mt-pre-color)];
|
||||
@apply hyphens-none whitespace-pre text-left;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror code {
|
||||
@apply break-words leading-[1.7em];
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror pre code {
|
||||
@apply block overflow-x-auto p-3.5;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror pre {
|
||||
.hljs-keyword,
|
||||
.hljs-operator,
|
||||
.hljs-function,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name {
|
||||
color: var(--hljs-keyword);
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-symbol,
|
||||
.hljs-property,
|
||||
.hljs-attribute,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-params {
|
||||
color: var(--hljs-attr);
|
||||
}
|
||||
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
.hljs-link,
|
||||
.hljs-type,
|
||||
.hljs-addition {
|
||||
color: var(--hljs-name);
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-bullet {
|
||||
color: var(--hljs-string);
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-subst,
|
||||
.hljs-section {
|
||||
color: var(--hljs-title);
|
||||
}
|
||||
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-deletion {
|
||||
color: var(--hljs-literal);
|
||||
}
|
||||
|
||||
.hljs-selector-tag,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: var(--hljs-selector-tag);
|
||||
}
|
||||
|
||||
.hljs-number {
|
||||
color: var(--hljs-number);
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-meta,
|
||||
.hljs-quote {
|
||||
color: var(--hljs-comment);
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
82
web/components/minimal-tiptap/styles/partials/lists.css
Normal file
82
web/components/minimal-tiptap/styles/partials/lists.css
Normal file
@@ -0,0 +1,82 @@
|
||||
.minimal-tiptap-editor div.tiptap p {
|
||||
@apply text-[var(--mt-font-size-regular)];
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ol ol {
|
||||
list-style: lower-alpha;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ol ol ol {
|
||||
list-style: lower-roman;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ul ul {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ul ul ul {
|
||||
list-style: square;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ul[data-type='taskList'] {
|
||||
@apply list-none pl-1;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ul[data-type='taskList'] p {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror ul[data-type='taskList'] li > label {
|
||||
@apply mr-2 mt-0.5 flex-none select-none;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror li[data-type='taskItem'] {
|
||||
@apply flex flex-row items-start;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror li[data-type='taskItem'] .taskItem-checkbox-container {
|
||||
@apply relative pr-2;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror .taskItem-drag-handle {
|
||||
@apply absolute -left-5 top-1.5 h-[18px] w-[18px] cursor-move pl-0.5 text-[var(--mt-secondary)] opacity-0;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor
|
||||
.ProseMirror
|
||||
li[data-type='taskItem']:hover:not(:has(li:hover))
|
||||
> .taskItem-checkbox-container
|
||||
> .taskItem-drag-handle {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror .taskItem-drag-handle:hover {
|
||||
@apply text-[var(--mt-drag-handle-hover)];
|
||||
}
|
||||
|
||||
.minimal-tiptap-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(--mt-secondary)] bg-transparent bg-[1px_2px] p-0.5 align-middle transition-colors duration-75 ease-out;
|
||||
}
|
||||
|
||||
.minimal-tiptap-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');
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror .taskItem-content {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror li[data-checked='true'] .taskItem-content > :not([data-type='taskList']),
|
||||
.minimal-tiptap-editor .ProseMirror li[data-checked='true'] .taskItem-content .taskItem-checkbox {
|
||||
@apply opacity-75;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.minimal-tiptap-editor .ProseMirror > p.is-editor-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
@apply pointer-events-none float-left h-0 text-[var(--mt-secondary)];
|
||||
}
|
||||
27
web/components/minimal-tiptap/styles/partials/typography.css
Normal file
27
web/components/minimal-tiptap/styles/partials/typography.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.minimal-tiptap-editor .ProseMirror .heading-node {
|
||||
@apply relative font-semibold;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror .heading-node:first-child {
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror h1 {
|
||||
@apply mb-4 mt-[46px] text-[1.375rem] leading-7 tracking-[-0.004375rem];
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror h2 {
|
||||
@apply mb-3.5 mt-8 text-[1.1875rem] leading-7 tracking-[0.003125rem];
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror h3 {
|
||||
@apply mb-3 mt-6 text-[1.0625rem] leading-6 tracking-[0.00625rem];
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror a.link {
|
||||
@apply cursor-pointer text-primary;
|
||||
}
|
||||
|
||||
.minimal-tiptap-editor .ProseMirror a.link:hover {
|
||||
@apply underline;
|
||||
}
|
||||
28
web/components/minimal-tiptap/types.ts
Normal file
28
web/components/minimal-tiptap/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
export interface LinkProps {
|
||||
url: string
|
||||
text?: string
|
||||
openInNewTab?: boolean
|
||||
}
|
||||
|
||||
export interface ShouldShowProps {
|
||||
editor: Editor
|
||||
view: EditorView
|
||||
state: EditorState
|
||||
oldState?: EditorState
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
export interface FormatAction {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
action: (editor: Editor) => void
|
||||
isActive: (editor: Editor) => boolean
|
||||
canExecute: (editor: Editor) => boolean
|
||||
shortcuts: string[]
|
||||
value: string
|
||||
}
|
||||
14
web/components/minimal-tiptap/utils.ts
Normal file
14
web/components/minimal-tiptap/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Editor } from "@tiptap/core"
|
||||
import type { MinimalTiptapProps } from "./minimal-tiptap"
|
||||
|
||||
export function getOutput(editor: Editor, format: MinimalTiptapProps["output"]) {
|
||||
if (format === "json") {
|
||||
return editor.getJSON()
|
||||
}
|
||||
|
||||
if (format === "html") {
|
||||
return editor.getText() ? editor.getHTML() : ""
|
||||
}
|
||||
|
||||
return editor.getText()
|
||||
}
|
||||
141
web/components/routes/OnboardingRoute.tsx
Normal file
141
web/components/routes/OnboardingRoute.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect } from "react"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import { LaIcon } from "../custom/la-icon"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useAtom } from "jotai"
|
||||
|
||||
const isCreateLinkDoneAtom = atomWithStorage("isCreateLinkDone", false)
|
||||
const isCreatePageDoneAtom = atomWithStorage("isCreatePageDone", false)
|
||||
const isStartTrackingDoneAtom = atomWithStorage("isStartTrackingDone", false)
|
||||
const isAddLinkDoneAtom = atomWithStorage("isAddLinkDone", false)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Create Link",
|
||||
description:
|
||||
"Links are essentially bookmarks of things from internet. You can create a link by pressing Links button in left sidebar. Then pressing + button on the bottom.",
|
||||
task: "create any Link with any title or description (for example, you can add https://learn-anything.xyz as link)"
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Create Page",
|
||||
description:
|
||||
"Pages are things with content inside (images, text, anything). You can think of them as Notion pages. To create page, press the + button next to pages, then create title and put some content.",
|
||||
task: "create any Page with any content inside"
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Start tracking Learning status of some Topic",
|
||||
description:
|
||||
"What makes Learn Anything different from Notion and other tools is notion of topics. A topic is anything after learn-anything.xyz/<topic>, for example learn-anything.xyz/typescript. You can go to the page, then on top right corner where it says add to my profile, press it and change the state of the topic to I want to learn, Learning or Learned.",
|
||||
task: "go to any Topic, and mark it as I want to learn"
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: "Add a Link from a Topic into personal link collection",
|
||||
description:
|
||||
"If you noticed, there are links attached to topics as a list. This is the topic's study guide. It will be improved greatly in future and we will allow any user to edit these study guides too (Reddit style). You can click on the circle to left of the links and add a link to your personal collection with learning status too.",
|
||||
task: "add any Link from topic typescript into your personal collection"
|
||||
}
|
||||
]
|
||||
|
||||
const StepItem = ({
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
task,
|
||||
done
|
||||
}: {
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
task: string
|
||||
done: boolean
|
||||
}) => (
|
||||
<div className="flex items-start space-x-4 py-4">
|
||||
<div className="border-foreground/20 w-6 flex-shrink-0 items-center justify-center rounded-3xl border text-center opacity-70">
|
||||
{number}
|
||||
</div>
|
||||
<div className="flex-grow space-y-2">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<p className="w-[90%] leading-relaxed opacity-70">{description}</p>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<LaIcon name={done ? "SquareCheck" : "Square"} className={`${done ? "text-green-500" : ""}`} />
|
||||
<p className={`${done ? "opacity-35" : ""}`}>{task}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function OnboardingRoute() {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
personalPages: [],
|
||||
personalLinks: [],
|
||||
topicsWantToLearn: []
|
||||
}
|
||||
})
|
||||
|
||||
const [isCreateLinkDone, setIsCreateLinkDone] = useAtom(isCreateLinkDoneAtom)
|
||||
const [isCreatePageDone, setIsCreatePageDone] = useAtom(isCreatePageDoneAtom)
|
||||
const [isStartTrackingDone, setIsStartTrackingDone] = useAtom(isStartTrackingDoneAtom)
|
||||
const [isAddLinkDone, setIsAddLinkDone] = useAtom(isAddLinkDoneAtom)
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return
|
||||
|
||||
if (me.root.personalLinks.length > 0 && !isCreateLinkDone) {
|
||||
setIsCreateLinkDone(true)
|
||||
}
|
||||
|
||||
if (me.root.personalPages.length > 0 && !isCreatePageDone) {
|
||||
setIsCreatePageDone(true)
|
||||
}
|
||||
|
||||
if (me.root.topicsWantToLearn.length > 0 && !isStartTrackingDone) {
|
||||
setIsStartTrackingDone(true)
|
||||
}
|
||||
|
||||
if (me.root.personalLinks.some(link => link?.topic?.name === "typescript") && !isAddLinkDone) {
|
||||
setIsAddLinkDone(true)
|
||||
}
|
||||
}, [
|
||||
me,
|
||||
isCreateLinkDone,
|
||||
isCreatePageDone,
|
||||
setIsCreateLinkDone,
|
||||
setIsCreatePageDone,
|
||||
isAddLinkDone,
|
||||
setIsAddLinkDone,
|
||||
isStartTrackingDone,
|
||||
setIsStartTrackingDone
|
||||
])
|
||||
|
||||
const completedSteps = [isCreateLinkDone, isCreatePageDone, isStartTrackingDone, isAddLinkDone].filter(Boolean).length
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4 text-sm text-black dark:text-white">
|
||||
<div className="ml-10 flex flex-col items-start border-b border-neutral-200 bg-inherit dark:border-neutral-900">
|
||||
<p className="h-[70px] p-[20px] text-2xl font-semibold opacity-60">Onboarding</p>
|
||||
</div>
|
||||
<div className="mx-auto w-[70%] rounded-lg border border-neutral-200 bg-inherit p-6 shadow dark:border-neutral-900">
|
||||
<h2 className="mb-4 text-lg font-semibold">Complete the steps below to get started</h2>
|
||||
<p className="mb-4">
|
||||
Completed {completedSteps} out of {steps.length} steps
|
||||
</p>
|
||||
<div className="divide-y">
|
||||
{steps.map((step, index) => (
|
||||
<StepItem
|
||||
key={step.number}
|
||||
{...step}
|
||||
done={[isCreateLinkDone, isCreatePageDone, isStartTrackingDone, isAddLinkDone][index]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -97,7 +97,6 @@ export const SettingsRoute = () => {
|
||||
const [topInboxHotkey, setTopInboxHotkey] = useState("")
|
||||
|
||||
const saveSettings = () => {
|
||||
console.log("Saving settings:", { inboxHotkey, topInboxHotkey })
|
||||
toast.success("Settings saved", {
|
||||
description: "Your hotkey settings have been updated."
|
||||
})
|
||||
|
||||
74
web/components/routes/community/CommunityTopicRoute.tsx
Normal file
74
web/components/routes/community/CommunityTopicRoute.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { GuideCommunityToggle } from "@/components/custom/GuideCommunityToggle"
|
||||
import { QuestionList } from "@/components/custom/QuestionList"
|
||||
import { QuestionThread } from "@/components/custom/QuestionThread"
|
||||
import { Topic } from "@/lib/schema"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
|
||||
interface CommunityTopicRouteProps {
|
||||
topicName: string
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export function CommunityTopicRoute({ topicName }: CommunityTopicRouteProps) {
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me])
|
||||
const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } })
|
||||
|
||||
const [selectedQuestion, setSelectedQuestion] = useState<Question | null>(null)
|
||||
|
||||
if (!topic) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<ContentHeader className="px-6 py-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 flex-col items-start">
|
||||
<p className="opacity-40">Topic</p>
|
||||
<span className="truncate text-left font-bold lg:text-xl">{topic.prettyName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
<GuideCommunityToggle topicName={topic.name} />
|
||||
</ContentHeader>
|
||||
<div className="relative flex flex-1 justify-center overflow-hidden">
|
||||
<div
|
||||
className={`w-1/2 overflow-y-auto p-3 transition-all duration-300 ${
|
||||
selectedQuestion ? "opacity-700 translate-x-[-50%]" : ""
|
||||
}`}
|
||||
>
|
||||
<QuestionList
|
||||
topicName={topic.name}
|
||||
onSelectQuestion={(question: Question) => setSelectedQuestion(question)}
|
||||
/>
|
||||
</div>
|
||||
{selectedQuestion && (
|
||||
<div className="absolute right-0 top-0 h-full w-1/2 overflow-y-auto">
|
||||
<QuestionThread
|
||||
question={{
|
||||
id: selectedQuestion.id,
|
||||
title: selectedQuestion.title,
|
||||
author: selectedQuestion.author,
|
||||
timestamp: selectedQuestion.timestamp
|
||||
}}
|
||||
onClose={() => setSelectedQuestion(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
web/components/routes/journal/JournalRoute.tsx
Normal file
114
web/components/routes/journal/JournalRoute.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { JournalEntry, JournalEntryLists } from "@/lib/schema/journal"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { calendarFormatDate } from "@/lib/utils"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
|
||||
export function JournalRoute() {
|
||||
const [date, setDate] = useState<Date>(new Date())
|
||||
const { me } = useAccount({ root: { journalEntries: [] } })
|
||||
const [newNote, setNewNote] = useState<JournalEntry | null>(null)
|
||||
|
||||
const notes = me?.root?.journalEntries || (me ? JournalEntryLists.create([], { owner: me }) : [])
|
||||
|
||||
useEffect(() => {
|
||||
console.log("me:", me)
|
||||
}, [me])
|
||||
|
||||
const selectDate = (selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
setDate(selectedDate)
|
||||
}
|
||||
}
|
||||
|
||||
const createNewNote = () => {
|
||||
if (me) {
|
||||
const newEntry = JournalEntry.create(
|
||||
{
|
||||
title: "",
|
||||
content: "",
|
||||
date: date,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{ owner: me._owner }
|
||||
)
|
||||
setNewNote(newEntry)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewNoteChange = (field: keyof JournalEntry, value: string) => {
|
||||
if (newNote) {
|
||||
setNewNote(prevNote => {
|
||||
if (prevNote) {
|
||||
return JournalEntry.create({ ...prevNote, [field]: value }, { owner: me!._owner })
|
||||
}
|
||||
return prevNote
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const saveNewNote = () => {
|
||||
if (newNote && me?.root?.journalEntries) {
|
||||
me.root.journalEntries.push(newNote)
|
||||
setNewNote(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<div className="flex-grow overflow-y-auto p-6">
|
||||
{newNote ? (
|
||||
<div className="mb-6 rounded-lg border p-4 shadow-sm">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={newNote.title}
|
||||
onChange={e => handleNewNoteChange("title", e.target.value)}
|
||||
className="mb-2 w-full text-xl font-semibold"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Content"
|
||||
value={newNote.content as string}
|
||||
onChange={e => handleNewNoteChange("content", e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<Button onClick={saveNewNote} className="mt-2">
|
||||
Save Note
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{notes.map((entry, index) => (
|
||||
<div key={index} className="mb-6 rounded-lg border p-4 shadow-sm">
|
||||
<h2 className="mb-2 text-xl font-semibold">{entry?.title}</h2>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{entry?.content &&
|
||||
(typeof entry.content === "string" ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: entry.content }} />
|
||||
) : (
|
||||
<pre>{JSON.stringify(entry.content, null, 2)}</pre>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-sm opacity-70">{entry?.date && calendarFormatDate(new Date(entry.date))}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-[22%] border-l p-2">
|
||||
<Calendar mode="single" selected={date} onSelect={selectDate} className="rounded-md border" />
|
||||
<Button onClick={createNewNote} className="mt-4 w-full">
|
||||
New Note
|
||||
</Button>
|
||||
<div className="p-2 text-sm opacity-50">
|
||||
<p>Total notes: {notes.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { LinkHeader } from "@/components/routes/link/header"
|
||||
import { LinkList } from "@/components/routes/link/list"
|
||||
import { LinkManage } from "@/components/routes/link/manage"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useEffect } from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkEditIdAtom } from "@/store/link"
|
||||
import { atom } from "jotai"
|
||||
import { LinkBottomBar } from "./bottom-bar"
|
||||
|
||||
export function LinkRoute() {
|
||||
const [, setEditId] = useAtom(linkEditIdAtom)
|
||||
const [nuqsEditId] = useQueryState("editId")
|
||||
|
||||
useEffect(() => {
|
||||
setEditId(nuqsEditId)
|
||||
}, [nuqsEditId, setEditId])
|
||||
export const isDeleteConfirmShownAtom = atom(false)
|
||||
|
||||
export function LinkRoute(): React.ReactElement {
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<>
|
||||
<LinkHeader />
|
||||
<LinkManage />
|
||||
<LinkList />
|
||||
<LinkBottomBar />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,73 +1,81 @@
|
||||
import React, { useEffect, useRef } from "react"
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { icons } from "lucide-react"
|
||||
import type { icons } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { cn, getShortcutKeys } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkShowCreateAtom } from "@/store/link"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalLink } from "@/lib/schema"
|
||||
import { ID } from "jazz-tools"
|
||||
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
|
||||
import { toast } from "sonner"
|
||||
import { useLinkActions } from "./hooks/use-link-actions"
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
|
||||
icon: keyof typeof icons
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(({ icon, onClick, tooltip }, ref) => {
|
||||
const button = (
|
||||
<Button variant="ghost" className="h-8 min-w-14" onClick={onClick} ref={ref}>
|
||||
<LaIcon name={icon} />
|
||||
</Button>
|
||||
)
|
||||
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
||||
({ icon, onClick, tooltip, className, ...props }, ref) => {
|
||||
const button = (
|
||||
<Button variant="ghost" className={cn("h-8 min-w-14 p-0", className)} onClick={onClick} ref={ref} {...props}>
|
||||
<LaIcon name={icon} />
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
})
|
||||
return button
|
||||
}
|
||||
)
|
||||
|
||||
ToolbarButton.displayName = "ToolbarButton"
|
||||
|
||||
export const LinkBottomBar: React.FC = () => {
|
||||
const [editId, setEditId] = useQueryState("editId")
|
||||
const [createMode, setCreateMode] = useQueryState("create", parseAsBoolean)
|
||||
const [, setGlobalLinkFormExceptionRefsAtom] = useAtom(globalLinkFormExceptionRefsAtom)
|
||||
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
|
||||
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
const personalLink = useCoState(PersonalLink, editId as ID<PersonalLink>)
|
||||
|
||||
const cancelBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const confirmBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const cancelBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const confirmBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const overlayRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const deleteBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const editMoreBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const plusBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const plusMoreBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const deleteBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const editMoreBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const plusBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const plusMoreBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
||||
const { deleteLink } = useLinkActions()
|
||||
const confirm = useConfirm()
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalLinkFormExceptionRefsAtom([
|
||||
const handleCreateMode = React.useCallback(() => {
|
||||
setEditId(null)
|
||||
requestAnimationFrame(() => {
|
||||
setCreateMode(prev => !prev)
|
||||
})
|
||||
}, [setEditId, setCreateMode])
|
||||
|
||||
const exceptionRefs = React.useMemo(
|
||||
() => [
|
||||
overlayRef,
|
||||
contentRef,
|
||||
deleteBtnRef,
|
||||
@@ -76,11 +84,16 @@ export const LinkBottomBar: React.FC = () => {
|
||||
confirmBtnRef,
|
||||
plusBtnRef,
|
||||
plusMoreBtnRef
|
||||
])
|
||||
}, [setGlobalLinkFormExceptionRefsAtom])
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
setGlobalLinkFormExceptionRefsAtom(exceptionRefs)
|
||||
}, [setGlobalLinkFormExceptionRefsAtom, exceptionRefs])
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
if (!personalLink) return
|
||||
if (!personalLink || !me) return
|
||||
|
||||
const result = await confirm({
|
||||
title: `Delete "${personalLink.title}"?`,
|
||||
@@ -105,97 +118,60 @@ export const LinkBottomBar: React.FC = () => {
|
||||
})
|
||||
|
||||
if (result) {
|
||||
if (!me?.root.personalLinks) return
|
||||
|
||||
const index = me.root.personalLinks.findIndex(item => item?.id === personalLink.id)
|
||||
if (index === -1) {
|
||||
console.error("Delete operation fail", { index, personalLink })
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Link deleted.", {
|
||||
position: "bottom-right",
|
||||
description: (
|
||||
<span>
|
||||
<strong>{personalLink.title}</strong> has been deleted.
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
me.root.personalLinks.splice(index, 1)
|
||||
deleteLink(me, personalLink)
|
||||
setEditId(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isMacOS()) {
|
||||
if (event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n") {
|
||||
event.preventDefault()
|
||||
setShowCreate(true)
|
||||
}
|
||||
} else {
|
||||
// For Windows, we'll use Ctrl + Win + N
|
||||
// Note: The Windows key is not directly detectable in most browsers
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey)) {
|
||||
event.preventDefault()
|
||||
setShowCreate(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [setShowCreate])
|
||||
|
||||
const shortcutKeys = getSpecialShortcut("expandToolbar")
|
||||
const shortcutText = formatShortcut(shortcutKeys)
|
||||
const shortcutText = getShortcutKeys(["c"])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-background absolute bottom-0 left-0 right-0 border-t"
|
||||
animate={{ y: 0 }}
|
||||
initial={{ y: "100%" }}
|
||||
>
|
||||
<div className="bg-background min-h-11 border-t">
|
||||
<AnimatePresence mode="wait">
|
||||
{editId && (
|
||||
<motion.div
|
||||
key="expanded"
|
||||
className="flex items-center justify-center gap-1 px-2 py-1"
|
||||
className="flex h-full items-center justify-center gap-1 border-t px-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} />
|
||||
<ToolbarButton icon={"Trash"} onClick={handleDelete} ref={deleteBtnRef} />
|
||||
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} />
|
||||
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} aria-label="Go back" />
|
||||
<ToolbarButton
|
||||
icon={"Trash"}
|
||||
onClick={handleDelete}
|
||||
className="text-destructive hover:text-destructive"
|
||||
ref={deleteBtnRef}
|
||||
aria-label="Delete link"
|
||||
/>
|
||||
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} aria-label="More options" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!editId && (
|
||||
<motion.div
|
||||
key="collapsed"
|
||||
className="flex items-center justify-center gap-1 px-2 py-1"
|
||||
className="flex h-full items-center justify-center gap-1 px-2"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{showCreate && <ToolbarButton icon={"ArrowLeft"} onClick={() => setShowCreate(true)} />}
|
||||
{!showCreate && (
|
||||
{createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} aria-label="Go back" />}
|
||||
{!createMode && (
|
||||
<ToolbarButton
|
||||
icon={"Plus"}
|
||||
onClick={() => setShowCreate(true)}
|
||||
tooltip={`New Link (${shortcutText})`}
|
||||
onClick={handleCreateMode}
|
||||
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
|
||||
ref={plusBtnRef}
|
||||
aria-label="New link"
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton icon={"Ellipsis"} ref={plusMoreBtnRef} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ListFilterIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -15,6 +14,7 @@ import { LEARNING_STATES } from "@/lib/constants"
|
||||
import { useQueryState, parseAsStringLiteral } from "nuqs"
|
||||
import { FancySwitch } from "@omit/react-fancy-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
const ALL_STATES = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES]
|
||||
const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value)
|
||||
@@ -26,11 +26,11 @@ export const LinkHeader = React.memo(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||
<ContentHeader className="px-6 max-lg:px-4 lg:py-4">
|
||||
<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>
|
||||
<span className="truncate text-left font-bold lg:text-xl">Links</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const LinkHeader = React.memo(() => {
|
||||
</ContentHeader>
|
||||
|
||||
{isTablet && (
|
||||
<div className="flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
|
||||
<div className="flex flex-row items-start justify-between border-b px-6 pb-4 pt-2 max-lg:pl-4">
|
||||
<LearningTab />
|
||||
</div>
|
||||
)}
|
||||
@@ -61,15 +61,19 @@ const LearningTab = React.memo(() => {
|
||||
|
||||
const handleTabChange = React.useCallback(
|
||||
(value: string) => {
|
||||
setActiveTab(value)
|
||||
setActiveState(value)
|
||||
if (value !== activeTab) {
|
||||
setActiveTab(value)
|
||||
setActiveState(value)
|
||||
}
|
||||
},
|
||||
[setActiveTab, setActiveState]
|
||||
[activeTab, setActiveTab, setActiveState]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveTab(activeState)
|
||||
}, [activeState, setActiveTab])
|
||||
if (activeState !== activeTab) {
|
||||
setActiveTab(activeState)
|
||||
}
|
||||
}, [activeState, activeTab, setActiveTab])
|
||||
|
||||
return (
|
||||
<FancySwitch
|
||||
@@ -78,8 +82,8 @@ const LearningTab = React.memo(() => {
|
||||
handleTabChange(value as string)
|
||||
}}
|
||||
options={ALL_STATES}
|
||||
className="bg-secondary flex rounded-lg"
|
||||
highlighterClassName="bg-secondary-foreground/10 rounded-lg"
|
||||
className="bg-muted flex rounded-lg"
|
||||
highlighterClassName="bg-muted-foreground/10 rounded-md"
|
||||
radioClassName={cn(
|
||||
"relative mx-2 flex h-8 cursor-pointer items-center justify-center rounded-full px-1 text-sm text-secondary-foreground/60 data-[checked]:text-secondary-foreground font-medium transition-colors focus:outline-none"
|
||||
)}
|
||||
@@ -111,8 +115,8 @@ const FilterAndSort = React.memo(() => {
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={sortOpen} onOpenChange={setSortOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" type="button" variant="secondary" className="gap-x-2 text-sm">
|
||||
<ListFilterIcon size={16} className="text-primary/60" />
|
||||
<Button size="sm" type="button" variant="secondary" className="min-w-8 gap-x-2 text-sm max-sm:p-0">
|
||||
<LaIcon name="ListFilter" className="text-primary/60" />
|
||||
<span className="hidden md:block">Filter: {getFilterText()}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
32
web/components/routes/link/hooks/use-link-actions.ts
Normal file
32
web/components/routes/link/hooks/use-link-actions.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react"
|
||||
import { toast } from "sonner"
|
||||
import { LaAccount, PersonalLink } from "@/lib/schema"
|
||||
|
||||
export const useLinkActions = () => {
|
||||
const deleteLink = React.useCallback((me: LaAccount, link: PersonalLink) => {
|
||||
if (!me.root?.personalLinks) return
|
||||
|
||||
try {
|
||||
const index = me.root.personalLinks.findIndex(item => item?.id === link.id)
|
||||
if (index === -1) {
|
||||
throw new Error(`Link with id ${link.id} not found`)
|
||||
}
|
||||
|
||||
me.root.personalLinks.splice(index, 1)
|
||||
|
||||
toast.success("Link deleted.", {
|
||||
position: "bottom-right",
|
||||
description: `${link.title} has been deleted.`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to delete link:", error)
|
||||
toast.error("Failed to delete link", {
|
||||
description: error instanceof Error ? error.message : "An unknown error occurred"
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
deleteLink
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -9,36 +8,55 @@ import {
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
UniqueIdentifier
|
||||
UniqueIdentifier,
|
||||
MeasuringStrategy,
|
||||
TouchSensor
|
||||
} from "@dnd-kit/core"
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||
import type { MeasuringConfiguration } from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkSortAtom } from "@/store/link"
|
||||
import { useKey } from "react-use"
|
||||
import { LinkItem } from "./partials/link-item"
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
import { learningStateAtom } from "./header"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { useLinkActions } from "./hooks/use-link-actions"
|
||||
import { isDeleteConfirmShownAtom } from "./LinkRoute"
|
||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||
import { useTouchSensor } from "@/hooks/use-touch-sensor"
|
||||
import { useKeyDown } from "@/hooks/use-key-down"
|
||||
import { isModKey } from "@/lib/utils"
|
||||
|
||||
interface LinkListProps {}
|
||||
|
||||
const measuring: MeasuringConfiguration = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always
|
||||
}
|
||||
}
|
||||
|
||||
const LinkList: React.FC<LinkListProps> = () => {
|
||||
const isTouchDevice = useTouchSensor()
|
||||
const lastActiveIndexRef = React.useRef<number | null>(null)
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(null)
|
||||
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<number | null>(null)
|
||||
const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
|
||||
const [editId, setEditId] = useQueryState("editId")
|
||||
const [createMode] = useQueryState("create", parseAsBoolean)
|
||||
const [activeLearningState] = useAtom(learningStateAtom)
|
||||
|
||||
const { me } = useAccount({
|
||||
root: { personalLinks: [] }
|
||||
})
|
||||
const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
|
||||
|
||||
const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(null)
|
||||
const [sort] = useAtom(linkSortAtom)
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<UniqueIdentifier | null>(null)
|
||||
const linkRefs = useRef<{ [key: string]: HTMLLIElement | null }>({})
|
||||
|
||||
const filteredLinks = useMemo(
|
||||
const { deleteLink } = useLinkActions()
|
||||
const confirm = useConfirm()
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
|
||||
const personalLinks = React.useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
|
||||
|
||||
const filteredLinks = React.useMemo(
|
||||
() =>
|
||||
personalLinks.filter(link => {
|
||||
if (activeLearningState === "all") return true
|
||||
@@ -48,7 +66,7 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
[personalLinks, activeLearningState]
|
||||
)
|
||||
|
||||
const sortedLinks = useMemo(
|
||||
const sortedLinks = React.useMemo(
|
||||
() =>
|
||||
sort === "title"
|
||||
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
|
||||
@@ -56,10 +74,22 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
[filteredLinks, sort]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editId !== null) {
|
||||
const index = sortedLinks.findIndex(link => link?.id === editId)
|
||||
if (index !== -1) {
|
||||
lastActiveIndexRef.current = index
|
||||
setActiveItemIndex(index)
|
||||
setKeyboardActiveIndex(index)
|
||||
}
|
||||
}
|
||||
}, [editId, setActiveItemIndex, setKeyboardActiveIndex, sortedLinks])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
useSensor(isTouchDevice ? TouchSensor : PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8
|
||||
...(isTouchDevice ? { delay: 100, tolerance: 5 } : {}),
|
||||
distance: 5
|
||||
}
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
@@ -67,17 +97,7 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
})
|
||||
)
|
||||
|
||||
const registerRef = useCallback((id: string, ref: HTMLLIElement | null) => {
|
||||
linkRefs.current[id] = ref
|
||||
}, [])
|
||||
|
||||
useKey("Escape", () => {
|
||||
if (editId) {
|
||||
setEditId(null)
|
||||
}
|
||||
})
|
||||
|
||||
const updateSequences = useCallback((links: PersonalLinkLists) => {
|
||||
const updateSequences = React.useCallback((links: PersonalLinkLists) => {
|
||||
links.forEach((link, index) => {
|
||||
if (link) {
|
||||
link.sequence = index
|
||||
@@ -85,69 +105,73 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
|
||||
const handleDeleteLink = React.useCallback(async () => {
|
||||
if (activeItemIndex === null) return
|
||||
setIsDeleteConfirmShown(true)
|
||||
const activeLink = sortedLinks[activeItemIndex]
|
||||
if (!activeLink || !me) return
|
||||
|
||||
const currentIndex = sortedLinks.findIndex(link => link?.id === focusedId)
|
||||
const result = await confirm({
|
||||
title: `Delete "${activeLink.title}"?`,
|
||||
description: "This action cannot be undone.",
|
||||
alertDialogTitle: { className: "text-base" },
|
||||
cancelButton: { variant: "outline" },
|
||||
confirmButton: { variant: "destructive" }
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result) {
|
||||
deleteLink(me, activeLink)
|
||||
}
|
||||
setIsDeleteConfirmShown(false)
|
||||
}, [activeItemIndex, sortedLinks, me, confirm, deleteLink, setIsDeleteConfirmShown])
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [me?.root?.personalLinks, sortedLinks, focusedId, editId, sort, updateSequences])
|
||||
useKeyDown(e => isModKey(e) && e.key === "Backspace", handleDeleteLink)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
const next = () => Math.min((activeItemIndex ?? 0) + 1, sortedLinks.length - 1)
|
||||
|
||||
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(next())
|
||||
setKeyboardActiveIndex(next())
|
||||
break
|
||||
case "ArrowUp":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(prev())
|
||||
setKeyboardActiveIndex(prev())
|
||||
}
|
||||
}
|
||||
|
||||
useKeyDown(() => true, handleKeyDown)
|
||||
|
||||
const handleDragStart = React.useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (sort !== "manual") return
|
||||
if (!me) return
|
||||
|
||||
const { active } = event
|
||||
const activeIndex = me?.root.personalLinks.findIndex(item => item?.id === active.id)
|
||||
|
||||
if (activeIndex === -1) {
|
||||
console.error("Drag operation fail", { activeIndex, activeId: active.id })
|
||||
return
|
||||
}
|
||||
|
||||
setActiveItemIndex(activeIndex)
|
||||
setDraggingId(active.id)
|
||||
},
|
||||
[sort]
|
||||
[sort, me, setActiveItemIndex]
|
||||
)
|
||||
|
||||
const handleDragCancel = React.useCallback(() => {
|
||||
setDraggingId(null)
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
@@ -190,39 +214,64 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
}
|
||||
}
|
||||
|
||||
setActiveItemIndex(null)
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({
|
||||
activeIndex: keyboardActiveIndex
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mb-14 flex w-full flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
measuring={measuring}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<div className="relative flex h-full grow items-stretch overflow-hidden" tabIndex={-1}>
|
||||
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
|
||||
<ul role="list" className="divide-primary/5 divide-y">
|
||||
{sortedLinks.map(
|
||||
linkItem =>
|
||||
linkItem && (
|
||||
<LinkItem
|
||||
key={linkItem.id}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
<div className="relative flex h-full grow flex-col items-stretch overflow-hidden">
|
||||
<div className="flex h-full w-[calc(100%+0px)] flex-col overflow-hidden pr-0">
|
||||
<div className="relative overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
|
||||
{sortedLinks.map(
|
||||
(linkItem, index) =>
|
||||
linkItem && (
|
||||
<LinkItem
|
||||
key={linkItem.id}
|
||||
isActive={activeItemIndex === index}
|
||||
personalLink={linkItem}
|
||||
editId={editId}
|
||||
disabled={sort !== "manual" || editId !== null}
|
||||
onPointerMove={() => {
|
||||
if (editId !== null || draggingId !== null || createMode) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
setKeyboardActiveIndex(null)
|
||||
setActiveItemIndex(index)
|
||||
}}
|
||||
onFormClose={() => {
|
||||
setEditId(null)
|
||||
setActiveItemIndex(lastActiveIndexRef.current)
|
||||
setKeyboardActiveIndex(lastActiveIndexRef.current)
|
||||
}}
|
||||
index={index}
|
||||
onItemSelected={link => setEditId(link.id)}
|
||||
data-keyboard-active={keyboardActiveIndex === index}
|
||||
ref={el => setElementRef(el, index)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { linkShowCreateAtom } from "@/store/link"
|
||||
import { useAtom } from "jotai"
|
||||
import { useKey } from "react-use"
|
||||
import { LinkForm } from "./partials/form/link-form"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
|
||||
interface LinkManageProps {}
|
||||
|
||||
const LinkManage: React.FC<LinkManageProps> = () => {
|
||||
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
|
||||
const [createMode, setCreateMode] = useQueryState("create", parseAsBoolean)
|
||||
|
||||
const handleFormClose = () => setShowCreate(false)
|
||||
const handleFormFail = () => {}
|
||||
|
||||
useKey("Escape", handleFormClose)
|
||||
const handleFormClose = () => setCreateMode(false)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{showCreate && (
|
||||
{createMode && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} onFail={handleFormFail} />
|
||||
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
|
||||
<TextareaAutosize
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Description (optional)"
|
||||
placeholder="Description"
|
||||
className="placeholder:text-muted-foreground/70 resize-none overflow-y-auto border-none p-1.5 text-[13px] font-medium shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { FormField, FormItem, FormLabel } from "@/components/ui/form"
|
||||
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
|
||||
import { TopicSelector, topicSelectorAtom } from "@/components/custom/topic-selector"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { useOnClickOutside } from "@/hooks/use-on-click-outside"
|
||||
|
||||
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
|
||||
|
||||
@@ -78,26 +79,16 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
[exceptionsRefs, globalExceptionRefs]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const isClickInsideForm = formRef.current && formRef.current.contains(event.target as Node)
|
||||
|
||||
const isClickInsideExceptions = allExceptionRefs.some((ref, index) => {
|
||||
const isInside = ref.current && ref.current.contains(event.target as Node)
|
||||
return isInside
|
||||
})
|
||||
|
||||
if (!isClickInsideForm && !istopicSelectorOpen && !islearningStateSelectorOpen && !isClickInsideExceptions) {
|
||||
onClose?.()
|
||||
}
|
||||
useOnClickOutside(formRef, event => {
|
||||
if (
|
||||
!istopicSelectorOpen &&
|
||||
!islearningStateSelectorOpen &&
|
||||
!allExceptionRefs.some(ref => ref.current?.contains(event.target as Node))
|
||||
) {
|
||||
console.log("clicking outside")
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [islearningStateSelectorOpen, istopicSelectorOpen, allExceptionRefs, onClose])
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedLink) {
|
||||
@@ -118,7 +109,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
const fetchMetadata = async (url: string) => {
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "no-cache" })
|
||||
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "force-cache" })
|
||||
const data = await res.json()
|
||||
setUrlFetched(data.url)
|
||||
form.setValue("url", data.url, {
|
||||
@@ -135,7 +126,6 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
shouldValidate: true
|
||||
})
|
||||
form.setFocus("title")
|
||||
console.log(form.formState.isValid, "form state after....")
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch metadata", err)
|
||||
} finally {
|
||||
@@ -147,8 +137,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
if (isFetching || !me) return
|
||||
|
||||
try {
|
||||
const personalLinks = me.root?.personalLinks?.toJSON() || []
|
||||
const slug = generateUniqueSlug(personalLinks, values.title)
|
||||
const slug = generateUniqueSlug(values.title)
|
||||
|
||||
if (selectedLink) {
|
||||
const { topic, ...diffValues } = values
|
||||
@@ -195,7 +184,15 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
const canSubmit = form.formState.isValid && !form.formState.isSubmitting
|
||||
|
||||
return (
|
||||
<div className="p-3 transition-all">
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className="p-3 transition-all"
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Escape") {
|
||||
handleCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("bg-muted/30 relative rounded-md border", isFetching && "opacity-50")}>
|
||||
<Form {...form}>
|
||||
<form ref={formRef} onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1">
|
||||
@@ -215,7 +212,6 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
<LearningStateSelector
|
||||
value={field.value}
|
||||
onChange={value => {
|
||||
// toggle, if already selected set undefined
|
||||
form.setValue("learningState", field.value === value ? undefined : value)
|
||||
}}
|
||||
showSearch={false}
|
||||
@@ -233,7 +229,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
<TopicSelector
|
||||
{...field}
|
||||
renderSelectedText={() => (
|
||||
<span className="truncate">{selectedTopic?.prettyName || "Select a topic"}</span>
|
||||
<span className="truncate">{selectedTopic?.prettyName || "Topic"}</span>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const NotesSection: React.FC = () => {
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Take a notes..."
|
||||
placeholder="Notes"
|
||||
className={cn("placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0")}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormField, FormItem, FormControl, FormLabel, FormMessage } from "@/comp
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LinkFormValues } from "./schema"
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
||||
import { TooltipArrow } from "@radix-ui/react-tooltip"
|
||||
|
||||
interface UrlInputProps {
|
||||
@@ -38,30 +38,28 @@ export const UrlInput: React.FC<UrlInputProps> = ({ urlFetched, fetchMetadata, i
|
||||
>
|
||||
<FormLabel className="sr-only">Url</FormLabel>
|
||||
<FormControl>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip open={shouldShowTooltip && !isFetchingUrlMetadata}>
|
||||
<TooltipTrigger asChild>
|
||||
<Input
|
||||
{...field}
|
||||
type={urlFetched ? "hidden" : "text"}
|
||||
autoComplete="off"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Paste a link or write a link"
|
||||
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="center" side="top">
|
||||
<TooltipArrow className="text-primary fill-current" />
|
||||
<span>
|
||||
Press <kbd className="px-1.5">Enter</kbd> to fetch metadata
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip open={shouldShowTooltip && !isFetchingUrlMetadata}>
|
||||
<TooltipTrigger asChild>
|
||||
<Input
|
||||
{...field}
|
||||
type={urlFetched ? "hidden" : "text"}
|
||||
autoComplete="off"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Paste a link or write a link"
|
||||
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="center" side="top">
|
||||
<TooltipArrow className="text-primary fill-current" />
|
||||
<span>
|
||||
Press <kbd className="px-1.5">Enter</kbd> to fetch metadata
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
<FormMessage className="px-1.5" />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useMemo } from "react"
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useAtom } from "jotai"
|
||||
@@ -13,107 +11,108 @@ import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover
|
||||
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||
import { PersonalLink } from "@/lib/schema/personal-link"
|
||||
import { LinkForm } from "./form/link-form"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn, ensureUrlProtocol } from "@/lib/utils"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { linkOpenPopoverForIdAtom, linkShowCreateAtom } from "@/store/link"
|
||||
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
||||
|
||||
interface LinkItemProps {
|
||||
interface LinkItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
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
|
||||
editId: string | null
|
||||
isActive: boolean
|
||||
index: number
|
||||
onItemSelected?: (personalLink: PersonalLink) => void
|
||||
onFormClose?: () => void
|
||||
}
|
||||
|
||||
export const LinkItem: React.FC<LinkItemProps> = ({
|
||||
isEditing,
|
||||
setEditId,
|
||||
personalLink,
|
||||
disabled = false,
|
||||
isDragging,
|
||||
isFocused,
|
||||
setFocusedId,
|
||||
registerRef
|
||||
}) => {
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||
export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
({ personalLink, disabled, editId, isActive, index, onItemSelected, onFormClose, ...props }, ref) => {
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
pointerEvents: isDragging ? "none" : "auto"
|
||||
}),
|
||||
[transform, transition, isDragging]
|
||||
)
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition
|
||||
}),
|
||||
[transform, transition]
|
||||
)
|
||||
|
||||
const refCallback = useCallback(
|
||||
(node: HTMLLIElement | null) => {
|
||||
setNodeRef(node)
|
||||
registerRef(personalLink.id, node)
|
||||
},
|
||||
[setNodeRef, registerRef, personalLink.id]
|
||||
)
|
||||
const selectedLearningState = React.useMemo(
|
||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||
[personalLink.learningState]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
setEditId(personalLink.id)
|
||||
}
|
||||
},
|
||||
[setEditId, personalLink.id]
|
||||
)
|
||||
const handleLearningStateSelect = React.useCallback(
|
||||
(value: string) => {
|
||||
const learningState = value as LearningStateValue
|
||||
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
||||
setOpenPopoverForId(null)
|
||||
},
|
||||
[personalLink, setOpenPopoverForId]
|
||||
)
|
||||
|
||||
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
||||
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
||||
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onItemSelected?.(personalLink)
|
||||
}
|
||||
},
|
||||
[personalLink, onItemSelected]
|
||||
)
|
||||
|
||||
const selectedLearningState = useMemo(
|
||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||
[personalLink.learningState]
|
||||
)
|
||||
if (editId === personalLink.id) {
|
||||
return <LinkForm onClose={onFormClose} personalLink={personalLink} onSuccess={onFormClose} onFail={() => {}} />
|
||||
}
|
||||
|
||||
const handleLearningStateSelect = useCallback(
|
||||
(value: string) => {
|
||||
const learningState = value as LearningStateValue
|
||||
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
||||
setOpenPopoverForId(null)
|
||||
},
|
||||
[personalLink, setOpenPopoverForId]
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
return <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={refCallback}
|
||||
style={style as React.CSSProperties}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
onFocus={() => setFocusedId(personalLink.id)}
|
||||
onBlur={() => setFocusedId(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn("relative flex h-14 cursor-default items-center outline-none xl:h-11", {
|
||||
"bg-muted-foreground/10": isFocused,
|
||||
"hover:bg-muted/50": !isFocused
|
||||
})}
|
||||
onDoubleClick={handleRowDoubleClick}
|
||||
>
|
||||
<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">
|
||||
return (
|
||||
<div
|
||||
ref={node => {
|
||||
setNodeRef(node)
|
||||
if (typeof ref === "function") {
|
||||
ref(node)
|
||||
} else if (ref) {
|
||||
ref.current = node
|
||||
}
|
||||
}}
|
||||
style={style as React.CSSProperties}
|
||||
{...props}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
onDoubleClick={() => onItemSelected?.(personalLink)}
|
||||
aria-disabled={disabled}
|
||||
aria-selected={isActive}
|
||||
data-disabled={disabled}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"w-full overflow-visible border-b-[0.5px] border-transparent outline-none",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full grow overflow-visible outline-none",
|
||||
"flex items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2"
|
||||
)}
|
||||
>
|
||||
<Popover
|
||||
open={openPopoverForId === personalLink.id}
|
||||
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onDoubleClick={e => e.stopPropagation()}
|
||||
>
|
||||
{selectedLearningState?.icon ? (
|
||||
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
|
||||
) : (
|
||||
@@ -121,12 +120,7 @@ export const LinkItem: React.FC<LinkItemProps> = ({
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="start">
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
@@ -136,47 +130,51 @@ export const LinkItem: React.FC<LinkItemProps> = ({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{personalLink.icon && (
|
||||
<Image
|
||||
src={personalLink.icon}
|
||||
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.url && (
|
||||
<div className="group flex items-center gap-x-1">
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary flex-none"
|
||||
/>
|
||||
<Link
|
||||
href={personalLink.url}
|
||||
passHref
|
||||
prefetch={false}
|
||||
target="_blank"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<span className="xl:truncate">{personalLink.url}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start gap-y-1.5 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{personalLink.icon && (
|
||||
<Image
|
||||
src={personalLink.icon}
|
||||
alt={personalLink.title}
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
)}
|
||||
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium">{personalLink.title}</p>
|
||||
</div>
|
||||
{personalLink.url && (
|
||||
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
||||
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
|
||||
<Link
|
||||
href={ensureUrlProtocol(personalLink.url)}
|
||||
passHref
|
||||
prefetch={false}
|
||||
target="_blank"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="hover:text-primary mr-1 truncate text-xs"
|
||||
>
|
||||
{personalLink.url}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
{personalLink.topic && (
|
||||
<Badge variant="secondary" className="border-muted-foreground/25">
|
||||
{personalLink.topic.prettyName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-x-4">
|
||||
{personalLink.topic && <Badge variant="secondary">{personalLink.topic.prettyName}</Badge>}
|
||||
</div>
|
||||
<div className="relative h-[0.5px] w-full after:absolute after:left-0 after:right-0 after:block after:h-full after:bg-[var(--link-border-after)]"></div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
LinkItem.displayName = "LinkItem"
|
||||
|
||||
13
web/components/routes/page/PageRoute.tsx
Normal file
13
web/components/routes/page/PageRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { PageHeader } from "./header"
|
||||
import { PageList } from "./list"
|
||||
|
||||
export function PageRoute() {
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<PageHeader />
|
||||
<PageList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useCallback, useRef, useEffect } from "react"
|
||||
import { ID } from "jazz-tools"
|
||||
import { PersonalPage } from "@/lib/schema"
|
||||
import { useCallback, useRef, useEffect } from "react"
|
||||
import { LAEditor, LAEditorRef } from "@/components/la-editor"
|
||||
import { Content, EditorContent, useEditor } from "@tiptap/react"
|
||||
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
||||
@@ -14,13 +13,13 @@ import { Editor } from "@tiptap/core"
|
||||
import { generateUniqueSlug } from "@/lib/utils"
|
||||
import { FocusClasses } from "@tiptap/extension-focus"
|
||||
import { DetailPageHeader } from "./header"
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { TopicSelector } from "@/components/custom/topic-selector"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { usePageActions } from "../hooks/use-page-actions"
|
||||
|
||||
const TITLE_PLACEHOLDER = "Untitled"
|
||||
|
||||
@@ -29,52 +28,24 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
|
||||
const isMobile = useMedia("(max-width: 770px)")
|
||||
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
||||
const router = useRouter()
|
||||
|
||||
const { deletePage } = usePageActions()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = useCallback(async () => {
|
||||
const result = await confirm({
|
||||
title: "Delete page",
|
||||
description: "Are you sure you want to delete this page?",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
cancelButton: {
|
||||
variant: "outline"
|
||||
},
|
||||
confirmButton: {
|
||||
variant: "destructive"
|
||||
}
|
||||
cancelButton: { variant: "outline" },
|
||||
confirmButton: { variant: "destructive" }
|
||||
})
|
||||
|
||||
if (result) {
|
||||
if (!me?.root.personalPages) return
|
||||
|
||||
try {
|
||||
const index = me.root.personalPages.findIndex(item => item?.id === pageId)
|
||||
if (index === -1) {
|
||||
toast.error("Page not found.")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Page deleted.", {
|
||||
position: "bottom-right",
|
||||
description: (
|
||||
<span>
|
||||
<strong>{page?.title}</strong> has been deleted.
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
me.root.personalPages.splice(index, 1)
|
||||
|
||||
// push without history
|
||||
router.replace("/")
|
||||
} catch (error) {
|
||||
console.error("Delete operation fail", { error })
|
||||
return
|
||||
}
|
||||
if (result && me?.root.personalPages) {
|
||||
deletePage(me, pageId as ID<PersonalPage>)
|
||||
router.push("/pages")
|
||||
}
|
||||
}
|
||||
}, [confirm, deletePage, me, pageId, router])
|
||||
|
||||
if (!page) return null
|
||||
|
||||
@@ -82,79 +53,78 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
|
||||
<div className="absolute inset-0 flex flex-row overflow-hidden">
|
||||
<div className="flex h-full w-full">
|
||||
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
||||
<DetailPageHeader page={page} handleDelete={handleDelete} />
|
||||
<DetailPageHeader page={page} handleDelete={handleDelete} isMobile={isMobile} />
|
||||
<DetailPageForm page={page} />
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div className="relative min-w-56 max-w-72 border-l">
|
||||
<div className="flex">
|
||||
<div className="flex h-10 flex-auto flex-row items-center justify-between px-5">
|
||||
<span className="text-left text-[13px] font-medium">Page actions</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={topic => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
variant="ghost"
|
||||
className="-ml-1.5"
|
||||
renderSelectedText={() => (
|
||||
<span className="truncate">{page.topic?.prettyName || "Select a topic"}</span>
|
||||
)}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={handleDelete} className="-ml-1.5">
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
<span className="text-sm">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && <SidebarActions page={page} handleDelete={handleDelete} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
const { me } = useAccount()
|
||||
const SidebarActions = ({ page, handleDelete }: { page: PersonalPage; handleDelete: () => void }) => (
|
||||
<div className="relative min-w-56 max-w-72 border-l">
|
||||
<div className="flex">
|
||||
<div className="flex h-10 flex-auto flex-row items-center justify-between px-5">
|
||||
<span className="text-left text-[13px] font-medium">Page actions</span>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5">
|
||||
<div className="flex flex-row">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={topic => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
variant="ghost"
|
||||
className="-ml-1.5"
|
||||
renderSelectedText={() => <span className="truncate">{page.topic?.prettyName || "Select a topic"}</span>}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<Button size="sm" variant="ghost" onClick={handleDelete} className="-ml-1.5">
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
<span className="text-sm">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
const titleEditorRef = useRef<Editor | null>(null)
|
||||
const contentEditorRef = useRef<LAEditorRef>(null)
|
||||
|
||||
const isTitleInitialMount = useRef(true)
|
||||
const isContentInitialMount = useRef(true)
|
||||
const isInitialFocusApplied = useRef(false)
|
||||
|
||||
const updatePageContent = (content: Content, model: PersonalPage) => {
|
||||
const updatePageContent = useCallback((content: Content, model: PersonalPage) => {
|
||||
if (isContentInitialMount.current) {
|
||||
isContentInitialMount.current = false
|
||||
return
|
||||
}
|
||||
|
||||
model.content = content
|
||||
model.updatedAt = new Date()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleUpdateTitle = (editor: Editor) => {
|
||||
if (isTitleInitialMount.current) {
|
||||
isTitleInitialMount.current = false
|
||||
return
|
||||
}
|
||||
const handleUpdateTitle = useCallback(
|
||||
(editor: Editor) => {
|
||||
if (isTitleInitialMount.current) {
|
||||
isTitleInitialMount.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const personalPages = me?.root?.personalPages?.toJSON() || []
|
||||
const newTitle = editor.getText()
|
||||
|
||||
// Only update if the title has actually changed
|
||||
if (newTitle !== page.title) {
|
||||
const slug = generateUniqueSlug(personalPages, page.slug || "")
|
||||
|
||||
page.title = newTitle
|
||||
page.slug = slug
|
||||
page.updatedAt = new Date()
|
||||
}
|
||||
}
|
||||
const newTitle = editor.getText()
|
||||
if (newTitle !== page.title) {
|
||||
const slug = generateUniqueSlug(page.title?.toString() || "")
|
||||
page.title = newTitle
|
||||
page.slug = slug
|
||||
page.updatedAt = new Date()
|
||||
}
|
||||
},
|
||||
[page]
|
||||
)
|
||||
|
||||
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
||||
const editor = titleEditorRef.current
|
||||
@@ -181,7 +151,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return false
|
||||
}, [])
|
||||
|
||||
@@ -198,13 +167,11 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
titleEditorRef.current?.commands.focus("end")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const titleEditor = useEditor({
|
||||
immediatelyRender: false,
|
||||
autofocus: true,
|
||||
extensions: [
|
||||
FocusClasses,
|
||||
Paragraph,
|
||||
@@ -217,9 +184,7 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
strike: false,
|
||||
focus: false,
|
||||
gapcursor: false,
|
||||
placeholder: {
|
||||
placeholder: TITLE_PLACEHOLDER
|
||||
}
|
||||
placeholder: { placeholder: TITLE_PLACEHOLDER }
|
||||
})
|
||||
],
|
||||
editorProps: {
|
||||
@@ -250,7 +215,16 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
useEffect(() => {
|
||||
isTitleInitialMount.current = true
|
||||
isContentInitialMount.current = true
|
||||
}, [])
|
||||
|
||||
if (!isInitialFocusApplied.current && titleEditor && contentEditorRef.current?.editor) {
|
||||
isInitialFocusApplied.current = true
|
||||
if (!page.title) {
|
||||
titleEditor?.commands.focus()
|
||||
} else {
|
||||
contentEditorRef.current.editor.commands.focus()
|
||||
}
|
||||
}
|
||||
}, [page.title, titleEditor])
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import React from "react"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { PersonalPage } from "@/lib/schema/personal-page"
|
||||
import { useMedia } from "react-use"
|
||||
import { TopicSelector } from "@/components/custom/topic-selector"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
export const DetailPageHeader = ({ page, handleDelete }: { page: PersonalPage; handleDelete: () => void }) => {
|
||||
const isMobile = useMedia("(max-width: 770px)")
|
||||
interface DetailPageHeaderProps {
|
||||
page: PersonalPage
|
||||
handleDelete: () => void
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
export const DetailPageHeader: React.FC<DetailPageHeaderProps> = ({ page, handleDelete, isMobile }) => {
|
||||
if (!isMobile) return null
|
||||
|
||||
return (
|
||||
isMobile && (
|
||||
<>
|
||||
<ContentHeader className="lg:min-h-0">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<SidebarToggleButton />
|
||||
</div>
|
||||
</ContentHeader>
|
||||
|
||||
<div className="flex flex-row items-start gap-1.5 border-b px-6 py-2 max-lg:pl-4">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={topic => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
align="start"
|
||||
variant="outline"
|
||||
renderSelectedText={() => <span className="truncate">{page.topic?.prettyName || "Select a topic"}</span>}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleDelete}>
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
<>
|
||||
<ContentHeader className="lg:min-h-0">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<SidebarToggleButton />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
</ContentHeader>
|
||||
|
||||
<div className="flex flex-row items-start gap-1.5 border-b px-6 py-2 max-lg:pl-4">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={topic => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
align="start"
|
||||
variant="outline"
|
||||
renderSelectedText={() => <span className="truncate">{page.topic?.prettyName || "Select a topic"}</span>}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleDelete}>
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user