fix: conflict

This commit is contained in:
Aslam H
2024-09-28 19:53:32 +07:00
205 changed files with 8806 additions and 2121 deletions

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

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

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

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

View File

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

View File

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

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

View 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")
}
]
}
]
})

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

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

View File

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

View File

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

View 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>
)

View 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} />
</>
)
)
}

View 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&apos;ve been working hard on this new version, which
addresses previous issues and offers more features. As an early customer, you&apos;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&apos;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

View File

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

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

View 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>
)

View 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>
)

View File

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

View File

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

View 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>
)

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
() => ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View 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

View 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

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

View 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

View 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

View File

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

View File

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

View File

@@ -0,0 +1,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
}
}
})
]
}
})

View File

@@ -0,0 +1 @@
export * from './color'

View File

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

View File

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

View File

@@ -0,0 +1,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 }

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

View File

@@ -0,0 +1 @@
export * from './image'

View 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'

View File

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

View 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

View File

@@ -0,0 +1 @@
export * from './reset-marks-on-enter'

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './unset-all-marks'

View File

@@ -0,0 +1,9 @@
import { Extension } from '@tiptap/core'
export const UnsetAllMarks = Extension.create({
addKeyboardShortcuts() {
return {
'Mod-\\': () => this.editor.commands.unsetAllMarks()
}
}
})

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

View 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

View 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

View 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]
)
}

View File

@@ -0,0 +1 @@
export * from './minimal-tiptap'

View 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

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

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

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

View File

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

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

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

View 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()
}

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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