mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-23 16:58:38 +02:00
Setup (#112)
* wip * wip * wip3 * chore: utils * feat: add command * wip * fix: key duplicate * fix: move and check * fix: use react-use instead * fix: sidebar * chore: make dynamic * chore: tablet mode * chore: fix padding * chore: link instead of inbox * fix: use dnd kit * feat: add select component * chore: use atom * refactor: remove dnd provider * feat: disabled drag when sort is not manual * search route * . * feat: accessibility * fix: search * . * . * . * fix: sidebar collapsed * ai search layout * . * . * . * . * ai responsible content * . * . * . * . * . * global topic route * global topic correct route * topic buttons * sidebar search navigation * ai * Update jazz * . * . * . * . * . * learning status * . * . * chore: content header * fix: pointer none when dragging. prevent auto click after drag end * fix: confirm * fix: prevent drag when editing * chore: remove unused fn * fix: check propagation * chore: list * chore: tweak sonner * chore: update stuff * feat: add badge * chore: close edit when create * chore: escape on manage form * refactor: remove learn path * css: responsive item * chore: separate pages and topic * reafactor: remove new-schema * feat(types): extend jazz type so it can be nullable * chore: use new types * fix: missing deps * fix: link * fix: sidebar in layout * fix: quotes * css: use medium instead semi * Actual streaming and rendering markdown response * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * chore: metadata * feat: la-editor * . * fix: editor and page * . * . * . * . * . * . * fix: remove link * chore: page sidebar * fix: remove 'replace with learning status' * fix: link * fix: link * chore: update schema * chore: use new schema * fix: instead of showing error, just do unique slug * feat: create slug * refactor apply * update package json * fix: schema personal page * chore: editor * feat: pages * fix: metadata * fix: jazz provider * feat: handling data * feat: page detail * chore: server page to id * chore: use id instead of slug * chore: update content header * chore: update link header implementation * refactor: global.css * fix: la editor use animation frame * fix: editor export ref * refactor: page detail * chore: tidy up schema * chore: adapt to new schema * fix: wrap using settimeout * fix: wrap using settimeout * . * . --------- Co-authored-by: marshennikovaolga <marshennikova@gmail.com> Co-authored-by: Nikita <github@nikiv.dev> Co-authored-by: Anselm <anselm.eickhoff@gmail.com> Co-authored-by: Damian Tarnawski <gthetarnav@gmail.com>
This commit is contained in:
158
web/components/routes/globalTopic/globalTopic.tsx
Normal file
158
web/components/routes/globalTopic/globalTopic.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
import React, { useState } from "react"
|
||||
import { ContentHeader } from "@/components/custom/content-header"
|
||||
import { PiLinkSimple } from "react-icons/pi"
|
||||
import { Bookmark, GraduationCap, Check } from "lucide-react"
|
||||
|
||||
interface LinkProps {
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const links = [
|
||||
{ title: "JavaScript", url: "https://justjavascript.com" },
|
||||
{ title: "TypeScript", url: "https://www.typescriptlang.org/" },
|
||||
{ title: "React", url: "https://reactjs.org/" }
|
||||
]
|
||||
|
||||
const LinkItem: React.FC<LinkProps> = ({ title, url }) => (
|
||||
<div className="mb-1 flex flex-row items-center justify-between rounded-xl bg-[#121212] px-2 py-4 hover:cursor-pointer">
|
||||
<div className="flex items-center space-x-4">
|
||||
<p>{title}</p>
|
||||
<span className="text-md flex flex-row items-center space-x-1 font-medium tracking-wide text-white/20 hover:opacity-50">
|
||||
<PiLinkSimple size={20} className="text-white/20" />
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
{new URL(url).hostname}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
interface ButtonProps {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
className?: string
|
||||
color?: string
|
||||
icon?: React.ReactNode
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({ children, onClick, className = "", color = "", icon, fullWidth = false }) => {
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center justify-start rounded px-3 py-1 text-sm font-medium ${
|
||||
fullWidth ? "w-full" : ""
|
||||
} ${className} ${color}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && <span className="mr-2 flex items-center">{icon}</span>}
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GlobalTopic({ topic }: { topic: string }) {
|
||||
const [showOptions, setShowOptions] = useState(false)
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState("Guide")
|
||||
|
||||
const decodedTopic = decodeURIComponent(topic)
|
||||
|
||||
const learningOptions = [
|
||||
{ text: "To Learn", icon: <Bookmark size={18} /> },
|
||||
{ text: "Learning", icon: <GraduationCap size={18} /> },
|
||||
{ text: "Learned", icon: <Check size={18} /> }
|
||||
]
|
||||
|
||||
const learningStatusColor = (option: string) => {
|
||||
switch (option) {
|
||||
case "To Learn":
|
||||
return "text-white/70"
|
||||
case "Learning":
|
||||
return "text-[#D29752]"
|
||||
case "Learned":
|
||||
return "text-[#708F51]"
|
||||
default:
|
||||
return "text-white/70"
|
||||
}
|
||||
}
|
||||
|
||||
const selectedStatus = (option: string) => {
|
||||
setSelectedOption(option)
|
||||
setShowOptions(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<ContentHeader>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{decodedTopic}</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex rounded-lg bg-neutral-800 bg-opacity-60">
|
||||
<button
|
||||
onClick={() => setActiveTab("Guide")}
|
||||
className={`px-4 py-2 text-[16px] font-semibold transition-colors ${
|
||||
activeTab === "Guide"
|
||||
? "rounded-lg bg-neutral-800 shadow-inner shadow-neutral-700/70"
|
||||
: "text-white/70"
|
||||
}`}
|
||||
>
|
||||
Guide
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("All links")}
|
||||
className={`px-4 py-2 text-[16px] font-semibold transition-colors ${
|
||||
activeTab === "All links"
|
||||
? "rounded-lg bg-neutral-800 shadow-inner shadow-neutral-700/70"
|
||||
: "text-white/70"
|
||||
}`}
|
||||
>
|
||||
All links
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => setShowOptions(!showOptions)}
|
||||
className="w-[150px] whitespace-nowrap rounded-[7px] bg-neutral-800 px-4 py-2 text-[17px] font-semibold shadow-inner shadow-neutral-700/50 transition-colors hover:bg-neutral-700"
|
||||
color={learningStatusColor(selectedOption || "")}
|
||||
icon={selectedOption && learningOptions.find(opt => opt.text === selectedOption)?.icon}
|
||||
>
|
||||
{selectedOption || "Add to my profile"}
|
||||
</Button>
|
||||
{showOptions && (
|
||||
<div className="absolute left-1/2 mt-1 w-40 -translate-x-1/2 rounded-lg bg-neutral-800 shadow-lg">
|
||||
{learningOptions.map(option => (
|
||||
<Button
|
||||
key={option.text}
|
||||
onClick={() => selectedStatus(option.text)}
|
||||
className="space-x-1 px-2 py-2 text-left text-[14px] font-semibold hover:bg-neutral-700"
|
||||
color={learningStatusColor(option.text)}
|
||||
icon={option.icon}
|
||||
fullWidth
|
||||
>
|
||||
{option.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContentHeader>
|
||||
<div className="px-5 py-3">
|
||||
<h2 className="mb-3 text-white/60">Intro</h2>
|
||||
{links.map((link, index) => (
|
||||
<LinkItem key={index} title={link.title} url={link.url} />
|
||||
))}
|
||||
</div>
|
||||
<div className="px-5 py-3">
|
||||
<h2 className="mb-3 text-opacity-60">Other</h2>
|
||||
{links.map((link, index) => (
|
||||
<LinkItem key={index} title={link.title} url={link.url} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
438
web/components/routes/link/form/manage.tsx
Normal file
438
web/components/routes/link/form/manage.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useDebounce } from "react-use"
|
||||
import { toast } from "sonner"
|
||||
import Image from "next/image"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Form, FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
|
||||
import { BoxIcon, PlusIcon, Trash2Icon, PieChartIcon, Bookmark, GraduationCap, Check } from "lucide-react"
|
||||
import { cn, ensureUrlProtocol, generateUniqueSlug, isUrl as LibIsUrl } from "@/lib/utils"
|
||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { LinkMetadata, PersonalLink } from "@/lib/schema/personal-link"
|
||||
import { createLinkSchema } from "./schema"
|
||||
import { TopicSelector } from "./partial/topic-section"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkEditIdAtom, linkShowCreateAtom } from "@/store/link"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useKey } from "react-use"
|
||||
|
||||
export type LinkFormValues = z.infer<typeof createLinkSchema>
|
||||
|
||||
const DEFAULT_FORM_VALUES: Partial<LinkFormValues> = {
|
||||
title: "",
|
||||
description: "",
|
||||
topic: "",
|
||||
isLink: false,
|
||||
meta: null
|
||||
}
|
||||
|
||||
const LinkManage: React.FC = () => {
|
||||
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
|
||||
const [, setEditId] = useAtom(linkEditIdAtom)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const toggleForm = (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
setShowCreate(prev => !prev)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCreate) {
|
||||
formRef.current?.reset()
|
||||
setEditId(null)
|
||||
}
|
||||
}, [showCreate, setEditId])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (
|
||||
formRef.current &&
|
||||
!formRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowCreate(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreate) {
|
||||
document.addEventListener("mousedown", handleOutsideClick)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick)
|
||||
}
|
||||
}, [showCreate, setShowCreate])
|
||||
|
||||
useKey("Escape", () => {
|
||||
setShowCreate(false)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCreate && (
|
||||
<div className="z-50">
|
||||
<LinkForm ref={formRef} onSuccess={() => setShowCreate(false)} onCancel={() => setShowCreate(false)} />
|
||||
</div>
|
||||
)}
|
||||
<CreateButton ref={buttonRef} onClick={toggleForm} isOpen={showCreate} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CreateButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
onClick: (event: React.MouseEvent) => void
|
||||
isOpen: boolean
|
||||
}
|
||||
>(({ onClick, isOpen }, ref) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute bottom-4 right-4 size-12 rounded-full bg-[#274079] p-0 text-white transition-transform hover:bg-[#274079]/90",
|
||||
{ "rotate-45 transform": isOpen }
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<PlusIcon className="size-6" />
|
||||
</Button>
|
||||
))
|
||||
|
||||
CreateButton.displayName = "CreateButton"
|
||||
|
||||
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
personalLink?: PersonalLink
|
||||
}
|
||||
|
||||
const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess, onCancel, personalLink }, ref) => {
|
||||
const selectedLink = useCoState(PersonalLink, personalLink?.id)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const { me } = useAccount()
|
||||
const form = useForm<LinkFormValues>({
|
||||
resolver: zodResolver(createLinkSchema),
|
||||
defaultValues: DEFAULT_FORM_VALUES
|
||||
})
|
||||
|
||||
const title = form.watch("title")
|
||||
const [originalLink, setOriginalLink] = useState<string>("")
|
||||
const [linkEntered, setLinkEntered] = useState(false)
|
||||
const [debouncedText, setDebouncedText] = useState<string>("")
|
||||
useDebounce(() => setDebouncedText(title), 300, [title])
|
||||
|
||||
const [showStatusOptions, setShowStatusOptions] = useState(false)
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
|
||||
|
||||
const statusOptions = [
|
||||
{
|
||||
text: "To Learn",
|
||||
icon: <Bookmark size={16} />,
|
||||
color: "text-white/70"
|
||||
},
|
||||
{
|
||||
text: "Learning",
|
||||
icon: <GraduationCap size={16} />,
|
||||
color: "text-[#D29752]"
|
||||
},
|
||||
{ text: "Learned", icon: <Check size={16} />, color: "text-[#708F51]" }
|
||||
]
|
||||
|
||||
const statusSelect = (status: string) => {
|
||||
setSelectedStatus(status === selectedStatus ? null : status)
|
||||
setShowStatusOptions(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedLink) {
|
||||
form.setValue("title", selectedLink.title)
|
||||
form.setValue("description", selectedLink.description ?? "")
|
||||
form.setValue("isLink", selectedLink.isLink)
|
||||
form.setValue("meta", selectedLink.meta)
|
||||
}
|
||||
}, [selectedLink, form])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async (url: string) => {
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "no-store" })
|
||||
if (!res.ok) throw new Error("Failed to fetch metadata")
|
||||
const data = await res.json()
|
||||
form.setValue("isLink", true)
|
||||
form.setValue("meta", data)
|
||||
form.setValue("title", data.title)
|
||||
form.setValue("description", data.description)
|
||||
setOriginalLink(url)
|
||||
} catch (err) {
|
||||
form.setValue("isLink", false)
|
||||
form.setValue("meta", null)
|
||||
form.setValue("title", debouncedText)
|
||||
form.setValue("description", "")
|
||||
setOriginalLink("")
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const lowerText = debouncedText.toLowerCase()
|
||||
if (linkEntered && LibIsUrl(lowerText)) {
|
||||
fetchMetadata(ensureUrlProtocol(lowerText))
|
||||
}
|
||||
}, [debouncedText, form, linkEntered])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && LibIsUrl(e.currentTarget.value.toLowerCase())) {
|
||||
e.preventDefault()
|
||||
setLinkEntered(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = (values: LinkFormValues) => {
|
||||
if (isFetching) return
|
||||
|
||||
try {
|
||||
let linkMetadata: LinkMetadata | undefined
|
||||
|
||||
const personalLinks = me.root?.personalLinks?.toJSON() || []
|
||||
const slug = generateUniqueSlug(personalLinks, values.title)
|
||||
|
||||
if (values.isLink && values.meta) {
|
||||
linkMetadata = LinkMetadata.create(values.meta, { owner: me._owner })
|
||||
}
|
||||
|
||||
if (selectedLink) {
|
||||
selectedLink.title = values.title
|
||||
selectedLink.slug = slug
|
||||
selectedLink.description = values.description ?? ""
|
||||
selectedLink.isLink = values.isLink
|
||||
|
||||
if (selectedLink.meta) {
|
||||
Object.assign(selectedLink.meta, values.meta)
|
||||
}
|
||||
|
||||
// toast.success("Todo updated")
|
||||
} else {
|
||||
const newPersonalLink = PersonalLink.create(
|
||||
{
|
||||
title: values.title,
|
||||
slug,
|
||||
description: values.description,
|
||||
sequence: me.root?.personalLinks?.length || 1,
|
||||
completed: false,
|
||||
isLink: values.isLink,
|
||||
meta: linkMetadata
|
||||
// topic: values.topic
|
||||
},
|
||||
{ owner: me._owner }
|
||||
)
|
||||
|
||||
me.root?.personalLinks?.push(newPersonalLink)
|
||||
}
|
||||
|
||||
form.reset(DEFAULT_FORM_VALUES)
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
console.error("Failed to create/update link", error)
|
||||
toast.error(personalLink ? "Failed to update link" : "Failed to create link")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel: () => void = () => {
|
||||
form.reset(DEFAULT_FORM_VALUES)
|
||||
onCancel?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 transition-all">
|
||||
<div className="bg-muted/50 rounded-md border">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1" ref={ref}>
|
||||
<div className="flex flex-row p-3">
|
||||
<div className="flex flex-auto flex-col gap-1.5">
|
||||
<div className="flex flex-row items-start justify-between">
|
||||
<div className="flex grow flex-row items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Choose icon"
|
||||
className="text-primary/60 size-7"
|
||||
>
|
||||
{form.watch("isLink") ? (
|
||||
<Image
|
||||
src={form.watch("meta")?.favicon || ""}
|
||||
alt={form.watch("meta")?.title || ""}
|
||||
className="size-5 rounded-md"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
) : (
|
||||
<BoxIcon size={16} />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow space-y-0">
|
||||
<FormLabel className="sr-only">Text</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Paste a link or write a link"
|
||||
className="placeholder:text-primary/40 h-6 border-none p-1.5 font-medium focus-visible:outline-none focus-visible:ring-0"
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<span className="mr-5 max-w-[200px] truncate text-xs text-white/60">
|
||||
{linkEntered
|
||||
? originalLink
|
||||
: LibIsUrl(form.watch("title").toLowerCase())
|
||||
? 'Press "Enter" to confirm URL'
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* <Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="size-7 gap-x-2 text-sm"
|
||||
>
|
||||
<EllipsisIcon
|
||||
size={16}
|
||||
className="text-primary/60"
|
||||
/>
|
||||
</Button> */}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem className="group">
|
||||
<Trash2Icon size={16} className="text-destructive mr-2 group-hover:text-red-500" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="relative">
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="size-7 gap-x-2 text-sm"
|
||||
onClick={() => setShowStatusOptions(!showStatusOptions)}
|
||||
>
|
||||
{selectedStatus ? (
|
||||
(() => {
|
||||
const option = statusOptions.find(opt => opt.text === selectedStatus)
|
||||
return option
|
||||
? React.cloneElement(option.icon, {
|
||||
size: 16,
|
||||
className: option.color
|
||||
})
|
||||
: null
|
||||
})()
|
||||
) : (
|
||||
<PieChartIcon size={16} className="text-primary/60" />
|
||||
)}
|
||||
</Button>
|
||||
{showStatusOptions && (
|
||||
<div className="absolute right-0 mt-1 w-40 rounded-md bg-neutral-800 shadow-lg">
|
||||
{statusOptions.map(option => (
|
||||
<Button
|
||||
key={option.text}
|
||||
onClick={() => statusSelect(option.text)}
|
||||
className={`flex w-full items-center justify-start space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-neutral-700 ${option.color} bg-inherit`}
|
||||
>
|
||||
{React.cloneElement(option.icon, {
|
||||
size: 16,
|
||||
className: option.color
|
||||
})}
|
||||
<span>{option.text}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-1.5 pl-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow space-y-0">
|
||||
<FormLabel className="sr-only">Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Description (optional)"
|
||||
className="placeholder:text-primary/40 min-h-[24px] resize-none overflow-y-auto border-none p-1.5 text-xs font-medium shadow-none focus-visible:outline-none focus-visible:ring-0"
|
||||
onInput={e => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = "auto"
|
||||
target.style.height = `${target.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-auto flex-row items-center justify-between gap-2 rounded-b-md border border-t px-3 py-2">
|
||||
<div className="flex flex-row items-center gap-0.5">
|
||||
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
|
||||
<TopicSelector />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-auto items-center justify-end">
|
||||
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row gap-x-2">
|
||||
<Button size="sm" type="button" variant="ghost" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" disabled={isFetching}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LinkManage.displayName = "LinkManage"
|
||||
LinkForm.displayName = "LinkForm"
|
||||
|
||||
export { LinkManage, LinkForm }
|
||||
74
web/components/routes/link/form/partial/topic-section.tsx
Normal file
74
web/components/routes/link/form/partial/topic-section.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Command, CommandInput, CommandList, CommandItem } from "@/components/ui/command"
|
||||
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
||||
import { useState } from "react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { LinkFormValues } from "../manage"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TOPICS = [
|
||||
{ id: "1", name: "Work" },
|
||||
{ id: "2", name: "Personal" }
|
||||
]
|
||||
|
||||
export const TopicSelector: React.FC = () => {
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="sr-only">Topic</FormLabel>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button size="sm" type="button" role="combobox" variant="secondary" className="!mt-0 gap-x-2 text-sm">
|
||||
<span className="truncate">
|
||||
{field.value ? TOPICS.find(topic => topic.name === field.value)?.name : "Select topic"}
|
||||
</span>
|
||||
<ChevronDownIcon size={16} />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side="right" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search topic..." className="h-9" />
|
||||
<CommandList>
|
||||
<ScrollArea>
|
||||
{TOPICS.map(topic => (
|
||||
<CommandItem
|
||||
className="cursor-pointer"
|
||||
key={topic.id}
|
||||
value={topic.name}
|
||||
onSelect={value => {
|
||||
setValue("topic", value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{topic.name}
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className={cn(
|
||||
"absolute right-3",
|
||||
topic.name === field.value ? "text-primary" : "text-transparent"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
24
web/components/routes/link/form/schema.ts
Normal file
24
web/components/routes/link/form/schema.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const createLinkSchema = z.object({
|
||||
title: z
|
||||
.string({
|
||||
message: "Please enter a valid title"
|
||||
})
|
||||
.min(1, {
|
||||
message: "Please enter a valid title"
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
topic: z.string().optional(),
|
||||
isLink: z.boolean().default(false),
|
||||
meta: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
favicon: z.string(),
|
||||
description: z.string().optional().nullable()
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
completed: z.boolean().default(false)
|
||||
})
|
||||
126
web/components/routes/link/header.tsx
Normal file
126
web/components/routes/link/header.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ListFilterIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { useMedia } from "react-use"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkSortAtom } from "@/store/link"
|
||||
|
||||
interface TabItemProps {
|
||||
url: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const TABS = ["All", "Learning", "To Learn", "Learned"]
|
||||
|
||||
export const LinkHeader = () => {
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="p-4">
|
||||
{/* Toggle and Title */}
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left text-xl font-bold">Links</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isTablet && <Tabs />}
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
|
||||
<FilterAndSort />
|
||||
</ContentHeader>
|
||||
|
||||
{isTablet && (
|
||||
<div className="border-b-primary/5 flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
|
||||
<Tabs />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Tabs = () => {
|
||||
return (
|
||||
<div className="bg-secondary/50 flex items-baseline overflow-x-hidden rounded-md">
|
||||
{TABS.map(tab => (
|
||||
<TabItem key={tab} url="#" label={tab} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TabItem = ({ url, label }: TabItemProps) => {
|
||||
const [isActive, setIsActive] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div tabIndex={-1} className="rounded-md">
|
||||
<div className="flex flex-row">
|
||||
<div aria-label={label}>
|
||||
<Link href={url}>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={`gap-x-2 truncate text-sm ${isActive ? "bg-accent text-accent-foreground" : ""}`}
|
||||
onClick={() => setIsActive(true)}
|
||||
onBlur={() => setIsActive(false)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FilterAndSort = () => {
|
||||
const [sort, setSort] = useAtom(linkSortAtom)
|
||||
|
||||
const getFilterText = () => {
|
||||
return sort.charAt(0).toUpperCase() + sort.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-auto items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" type="button" variant="secondary" className="gap-x-2 text-sm">
|
||||
<ListFilterIcon size={16} className="text-primary/60" />
|
||||
<span className="hidden md:block">Filter: {getFilterText()}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-8 flex-row items-center">
|
||||
<Label>Sort by</Label>
|
||||
<div className="flex flex-auto flex-row items-center justify-end">
|
||||
<Select value={sort} onValueChange={setSort}>
|
||||
<SelectTrigger className="h-6 w-auto">
|
||||
<SelectValue placeholder="Select"></SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="title">Title</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
191
web/components/routes/link/list-item.tsx
Normal file
191
web/components/routes/link/list-item.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { LinkIcon, Trash2Icon } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { PersonalLink } from "@/lib/schema/personal-link"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LinkForm } from "./form/manage"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ConfirmOptions } from "@omit/react-confirm-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface ListItemProps {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||
personalLink: PersonalLink
|
||||
disabled?: boolean
|
||||
isEditing: boolean
|
||||
setEditId: (id: string | null) => void
|
||||
isDragging: boolean
|
||||
isFocused: boolean
|
||||
setFocusedId: (id: string | null) => void
|
||||
registerRef: (id: string, ref: HTMLLIElement | null) => void
|
||||
onDelete?: (personalLink: PersonalLink) => void
|
||||
}
|
||||
|
||||
export const ListItem: React.FC<ListItemProps> = ({
|
||||
confirm,
|
||||
isEditing,
|
||||
setEditId,
|
||||
personalLink,
|
||||
disabled = false,
|
||||
isDragging,
|
||||
isFocused,
|
||||
setFocusedId,
|
||||
registerRef,
|
||||
onDelete
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||
const formRef = React.useRef<HTMLFormElement>(null)
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
pointerEvents: isDragging ? "none" : "auto"
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEditing) {
|
||||
formRef.current?.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const refCallback = React.useCallback(
|
||||
(node: HTMLLIElement | null) => {
|
||||
setNodeRef(node)
|
||||
registerRef(personalLink.id, node)
|
||||
},
|
||||
[setNodeRef, registerRef, personalLink.id]
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
setEditId(personalLink.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuccess = () => {
|
||||
setEditId(null)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditId(null)
|
||||
}
|
||||
|
||||
const handleRowClick = () => {
|
||||
console.log("Row clicked", personalLink.id)
|
||||
setEditId(personalLink.id)
|
||||
}
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, personalLink: PersonalLink) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const result = await confirm({
|
||||
title: `Delete "${personalLink.title}"?`,
|
||||
description: "This action cannot be undone.",
|
||||
alertDialogTitle: {
|
||||
className: "text-base"
|
||||
},
|
||||
customActions: (onConfirm, onCancel) => (
|
||||
<>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
if (result) {
|
||||
onDelete?.(personalLink)
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return <LinkForm ref={formRef} personalLink={personalLink} onSuccess={handleSuccess} onCancel={handleCancel} />
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={refCallback}
|
||||
style={style as React.CSSProperties}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
onFocus={() => setFocusedId(personalLink.id)}
|
||||
onBlur={() => setFocusedId(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11", {
|
||||
"bg-muted/50": isFocused
|
||||
})}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
|
||||
<div className="flex min-w-0 items-center gap-x-4">
|
||||
<Checkbox
|
||||
checked={personalLink.completed}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onCheckedChange={() => {
|
||||
personalLink.completed = !personalLink.completed
|
||||
}}
|
||||
className="border-muted-foreground border"
|
||||
/>
|
||||
{personalLink.isLink && personalLink.meta && (
|
||||
<Image
|
||||
src={personalLink.meta.favicon}
|
||||
alt={personalLink.title}
|
||||
className="size-5 rounded-full"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
)}
|
||||
<div className="w-full min-w-0 flex-auto">
|
||||
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
|
||||
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate">
|
||||
{personalLink.title}
|
||||
</p>
|
||||
{personalLink.isLink && personalLink.meta && (
|
||||
<div className="group flex items-center gap-x-1">
|
||||
<LinkIcon
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary size-3 flex-none"
|
||||
/>
|
||||
<Link
|
||||
href={personalLink.meta.url}
|
||||
passHref
|
||||
prefetch={false}
|
||||
target="_blank"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<span className="xl:truncate">{personalLink.meta.url}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-x-4">
|
||||
<Badge variant="secondary">Topic Name</Badge>
|
||||
<Button
|
||||
size="icon"
|
||||
className="text-destructive h-auto w-auto bg-transparent hover:bg-transparent hover:text-red-500"
|
||||
onClick={e => handleDelete(e, personalLink)}
|
||||
>
|
||||
<Trash2Icon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
232
web/components/routes/link/list.tsx
Normal file
232
web/components/routes/link/list.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent
|
||||
} from "@dnd-kit/core"
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||
import { PersonalLink } from "@/lib/schema/personal-link"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkEditIdAtom, linkSortAtom } from "@/store/link"
|
||||
import { useKey } from "react-use"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { ListItem } from "./list-item"
|
||||
import { useRef, useState, useCallback, useEffect } from "react"
|
||||
|
||||
const LinkList = () => {
|
||||
const confirm = useConfirm()
|
||||
const { me } = useAccount({
|
||||
root: { personalLinks: [] }
|
||||
})
|
||||
const personalLinks = me?.root?.personalLinks || []
|
||||
|
||||
const [editId, setEditId] = useAtom(linkEditIdAtom)
|
||||
const [sort] = useAtom(linkSortAtom)
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const linkRefs = useRef<{ [key: string]: HTMLLIElement | null }>({})
|
||||
|
||||
let sortedLinks =
|
||||
sort === "title" && personalLinks
|
||||
? [...personalLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
|
||||
: personalLinks
|
||||
sortedLinks = sortedLinks || []
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8
|
||||
}
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
)
|
||||
|
||||
const overlayClick = () => {
|
||||
setEditId(null)
|
||||
}
|
||||
|
||||
const registerRef = useCallback((id: string, ref: HTMLLIElement | null) => {
|
||||
linkRefs.current[id] = ref
|
||||
}, [])
|
||||
|
||||
useKey("Escape", () => {
|
||||
if (editId) {
|
||||
setEditId(null)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
|
||||
|
||||
const currentIndex = sortedLinks.findIndex(link => link?.id === focusedId)
|
||||
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
const newIndex =
|
||||
e.key === "ArrowUp" ? Math.max(0, currentIndex - 1) : Math.min(sortedLinks.length - 1, currentIndex + 1)
|
||||
|
||||
if (e.metaKey && sort === "manual") {
|
||||
const currentLink = me.root.personalLinks[currentIndex]
|
||||
if (!currentLink) return
|
||||
|
||||
const linksArray = [...me.root.personalLinks]
|
||||
const newLinks = arrayMove(linksArray, currentIndex, newIndex)
|
||||
|
||||
while (me.root.personalLinks.length > 0) {
|
||||
me.root.personalLinks.pop()
|
||||
}
|
||||
|
||||
newLinks.forEach(link => {
|
||||
if (link) {
|
||||
me.root.personalLinks.push(link)
|
||||
}
|
||||
})
|
||||
|
||||
updateSequences(me.root.personalLinks)
|
||||
|
||||
const newFocusedLink = me.root.personalLinks[newIndex]
|
||||
if (newFocusedLink) {
|
||||
setFocusedId(newFocusedLink.id)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
linkRefs.current[newFocusedLink.id]?.focus()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const newFocusedLink = sortedLinks[newIndex]
|
||||
if (newFocusedLink) {
|
||||
setFocusedId(newFocusedLink.id)
|
||||
requestAnimationFrame(() => {
|
||||
linkRefs.current[newFocusedLink.id]?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [me?.root?.personalLinks, sortedLinks, focusedId, editId, sort])
|
||||
|
||||
const updateSequences = (links: PersonalLinkLists) => {
|
||||
links.forEach((link, index) => {
|
||||
if (link) {
|
||||
link.sequence = index
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragStart = (event: any) => {
|
||||
if (sort !== "manual") return
|
||||
const { active } = event
|
||||
setDraggingId(active.id)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (!active || !over || !me?.root?.personalLinks) {
|
||||
console.error("Drag operation fail", { active, over })
|
||||
return
|
||||
}
|
||||
|
||||
const oldIndex = me.root.personalLinks.findIndex(item => item?.id === active.id)
|
||||
const newIndex = me.root.personalLinks.findIndex(item => item?.id === over.id)
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
console.error("Drag operation fail", {
|
||||
oldIndex,
|
||||
newIndex,
|
||||
activeId: active.id,
|
||||
overId: over.id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (oldIndex !== newIndex) {
|
||||
try {
|
||||
const personalLinksArray = [...me.root.personalLinks]
|
||||
const updatedLinks = arrayMove(personalLinksArray, oldIndex, newIndex)
|
||||
|
||||
while (me.root.personalLinks.length > 0) {
|
||||
me.root.personalLinks.pop()
|
||||
}
|
||||
|
||||
updatedLinks.forEach(link => {
|
||||
if (link) {
|
||||
me.root.personalLinks.push(link)
|
||||
}
|
||||
})
|
||||
|
||||
updateSequences(me.root.personalLinks)
|
||||
} catch (error) {
|
||||
console.error("Error during link reordering:", error)
|
||||
}
|
||||
}
|
||||
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const handleDelete = (linkItem: PersonalLink) => {
|
||||
if (!me?.root?.personalLinks) return
|
||||
|
||||
const index = me.root.personalLinks.findIndex(item => item?.id === linkItem.id)
|
||||
if (index === -1) {
|
||||
console.error("Delete operation fail", { index, linkItem })
|
||||
return
|
||||
}
|
||||
|
||||
me.root.personalLinks.splice(index, 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{editId && <div className="fixed inset-0 z-10" onClick={overlayClick} />}
|
||||
<div className="relative z-20">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
|
||||
<ul role="list" className="divide-primary/5 divide-y">
|
||||
{sortedLinks.map(
|
||||
linkItem =>
|
||||
linkItem && (
|
||||
<ListItem
|
||||
key={linkItem.id}
|
||||
confirm={confirm}
|
||||
isEditing={editId === linkItem.id}
|
||||
setEditId={setEditId}
|
||||
personalLink={linkItem}
|
||||
disabled={sort !== "manual" || editId !== null}
|
||||
registerRef={registerRef}
|
||||
isDragging={draggingId === linkItem.id}
|
||||
isFocused={focusedId === linkItem.id}
|
||||
setFocusedId={setFocusedId}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
LinkList.displayName = "LinkList"
|
||||
|
||||
export { LinkList }
|
||||
19
web/components/routes/link/wrapper.tsx
Normal file
19
web/components/routes/link/wrapper.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { LinkHeader } from "@/components/routes/link/header"
|
||||
import { LinkList } from "@/components/routes/link/list"
|
||||
import { LinkManage } from "@/components/routes/link/form/manage"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkEditIdAtom } from "@/store/link"
|
||||
|
||||
export function LinkWrapper() {
|
||||
const [editId] = useAtom(linkEditIdAtom)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<LinkHeader />
|
||||
<LinkManage />
|
||||
<LinkList key={editId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
web/components/routes/page/detail/header.tsx
Normal file
34
web/components/routes/page/detail/header.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalPage } from "@/lib/schema/personal-page"
|
||||
import { ID } from "jazz-tools"
|
||||
|
||||
export const DetailPageHeader = ({ pageId }: { pageId: ID<PersonalPage> }) => {
|
||||
const page = useCoState(PersonalPage, pageId)
|
||||
|
||||
return (
|
||||
<ContentHeader>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<SidebarToggleButton />
|
||||
|
||||
<Breadcrumb className="flex flex-row items-center">
|
||||
<BreadcrumbList className="sm:gap-2">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-foreground font-medium">Pages</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</ContentHeader>
|
||||
)
|
||||
}
|
||||
139
web/components/routes/page/detail/wrapper.tsx
Normal file
139
web/components/routes/page/detail/wrapper.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { LAEditor, LAEditorRef } from "@/components/la-editor"
|
||||
import { DetailPageHeader } from "./header"
|
||||
import { ID } from "jazz-tools"
|
||||
import { PersonalPage } from "@/lib/schema/personal-page"
|
||||
import { Content, EditorContent, useEditor } from "@tiptap/react"
|
||||
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
||||
import { Paragraph } from "@/components/la-editor/extensions/paragraph"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { toast } from "sonner"
|
||||
import { EditorView } from "prosemirror-view"
|
||||
|
||||
const configureStarterKit = () =>
|
||||
StarterKit.configure({
|
||||
bold: false,
|
||||
italic: false,
|
||||
typography: false,
|
||||
hardBreak: false,
|
||||
listItem: false,
|
||||
strike: false,
|
||||
focus: false,
|
||||
gapcursor: false,
|
||||
history: false,
|
||||
placeholder: {
|
||||
placeholder: "Page title"
|
||||
}
|
||||
})
|
||||
|
||||
const editorProps = {
|
||||
attributes: {
|
||||
spellcheck: "true",
|
||||
role: "textbox",
|
||||
"aria-readonly": "false",
|
||||
"aria-multiline": "false",
|
||||
"aria-label": "Page title",
|
||||
translate: "no"
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailPageWrapper({ pageId }: { pageId: string }) {
|
||||
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
||||
const contentEditorRef = useRef<LAEditorRef>(null)
|
||||
|
||||
const handleKeyDown = (view: EditorView, event: KeyboardEvent) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
contentEditorRef.current?.focus()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const handleTitleBlur = (title: string) => {
|
||||
if (page && editor) {
|
||||
if (!title) {
|
||||
toast.error("Update failed", {
|
||||
description: "Title must be longer than or equal to 1 character"
|
||||
})
|
||||
|
||||
// https://github.com/ueberdosis/tiptap/issues/3764
|
||||
setTimeout(() => {
|
||||
editor.commands.setContent(`<p>${page.title}</p>`)
|
||||
})
|
||||
} else {
|
||||
page.title = title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [configureStarterKit(), Paragraph],
|
||||
editorProps: {
|
||||
...editorProps,
|
||||
handleKeyDown: handleKeyDown as unknown as (view: EditorView, event: KeyboardEvent) => boolean | void
|
||||
},
|
||||
onBlur: ({ editor }) => handleTitleBlur(editor.getText())
|
||||
})
|
||||
|
||||
const handleContentUpdate = (content: Content) => {
|
||||
console.log("content", content)
|
||||
}
|
||||
|
||||
const updatePageContent = (content: Content) => {
|
||||
if (page) {
|
||||
page.content = content
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (page && editor) {
|
||||
setTimeout(() => {
|
||||
editor.commands.setContent(`<p>${page.title}</p>`)
|
||||
})
|
||||
}
|
||||
}, [page, editor])
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<div className="flex h-full w-full">
|
||||
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
||||
<DetailPageHeader pageId={pageId as ID<PersonalPage>} />
|
||||
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
|
||||
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
|
||||
<form className="flex shrink-0 flex-col">
|
||||
<div className="mb-2 mt-8 py-1.5">
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="la-editor cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-auto flex-col">
|
||||
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
|
||||
<LAEditor
|
||||
ref={contentEditorRef}
|
||||
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto"
|
||||
initialContent={page?.content}
|
||||
placeholder="Add content..."
|
||||
output="json"
|
||||
throttleDelay={3000}
|
||||
onUpdate={handleContentUpdate}
|
||||
onBlur={updatePageContent}
|
||||
onNewBlock={updatePageContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
web/components/routes/search/header.tsx
Normal file
10
web/components/routes/search/header.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ContentHeader } from "@/components/custom/content-header"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export const SearchHeader = () => {
|
||||
return (
|
||||
<ContentHeader title="Search">
|
||||
<Input placeholder="Search something..." />
|
||||
</ContentHeader>
|
||||
)
|
||||
}
|
||||
131
web/components/routes/search/wrapper.tsx
Normal file
131
web/components/routes/search/wrapper.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
// import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { IoSearch, IoCloseOutline, IoChevronForward } from "react-icons/io5"
|
||||
import AiSearch from "../../custom/ai-search"
|
||||
|
||||
interface ProfileTopicsProps {
|
||||
topic: string
|
||||
}
|
||||
|
||||
const ProfileTopics: React.FC<ProfileTopicsProps> = ({ topic }) => {
|
||||
return (
|
||||
<div className="flex cursor-pointer flex-row items-center justify-between rounded-lg bg-[#121212] p-3">
|
||||
<p>{topic}</p>
|
||||
<IoChevronForward className="text-white" size={20} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileLinksProps {
|
||||
linklabel: string
|
||||
link: string
|
||||
topic: string
|
||||
}
|
||||
|
||||
interface ProfileTitleProps {
|
||||
topicTitle: string
|
||||
spanNumber: number
|
||||
}
|
||||
|
||||
const ProfileTitle: React.FC<ProfileTitleProps> = ({ topicTitle, spanNumber }) => {
|
||||
return (
|
||||
<p className="pb-3 pl-2 text-base font-light text-white/50">
|
||||
{topicTitle} <span className="text-white">{spanNumber}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const ProfileLinks: React.FC<ProfileLinksProps> = ({ linklabel, link, topic }) => {
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between rounded-lg bg-[#121212] p-3 text-white">
|
||||
<div className="flex flex-row items-center space-x-3">
|
||||
<p className="text-base text-white">{linklabel}</p>
|
||||
<div className="flex cursor-pointer flex-row items-center gap-1">
|
||||
<p className="text-md text-white/10 transition-colors duration-300 hover:text-white/30">{link}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cursor-default rounded-lg bg-[#1a1a1a] p-2 text-white/60">{topic}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SearchWrapper = () => {
|
||||
// const account = useAccount()
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [aiSearch, setAiSearch] = useState("")
|
||||
const [showAiSearch, setShowAiSearch] = useState(false)
|
||||
const [showAiPlaceholder, setShowAiPlaceholder] = useState(false)
|
||||
|
||||
const inputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(e.target.value)
|
||||
if (e.target.value.trim() !== "") {
|
||||
setShowAiPlaceholder(false)
|
||||
setTimeout(() => setShowAiPlaceholder(true), 1000)
|
||||
} else {
|
||||
setShowAiPlaceholder(false)
|
||||
setShowAiSearch(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchText("")
|
||||
setShowAiSearch(false)
|
||||
setShowAiPlaceholder(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && searchText.trim() !== "") {
|
||||
setShowAiSearch(true)
|
||||
setAiSearch(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<div className="flex h-full w-full justify-center overflow-hidden">
|
||||
<div className="w-full max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="relative mb-2 mt-5 flex w-full flex-row items-center transition-colors duration-300 hover:text-white/60">
|
||||
<IoSearch className="absolute left-3 text-white/30" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={searchText}
|
||||
onChange={inputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full rounded-[10px] bg-[#16181d] p-10 py-3 pl-10 pr-3 font-semibold tracking-wider text-white outline-none placeholder:font-light placeholder:text-white/30"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
{showAiPlaceholder && searchText && !showAiSearch && (
|
||||
<div className="absolute right-10 text-sm text-white/30">press "Enter" for AI search</div>
|
||||
)}
|
||||
{searchText && (
|
||||
<IoCloseOutline className="absolute right-3 cursor-pointer opacity-30" size={20} onClick={clearSearch} />
|
||||
)}
|
||||
</div>
|
||||
{showAiSearch ? (
|
||||
<div className="relative w-full">
|
||||
<div className="absolute left-1/2 w-[110%] -translate-x-1/2">
|
||||
<AiSearch searchQuery={searchText} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="my-5 space-y-1">
|
||||
<ProfileTitle topicTitle="Topics" spanNumber={1} />
|
||||
<ProfileTopics topic="Figma" />
|
||||
</div>
|
||||
|
||||
<div className="my-5 space-y-1">
|
||||
<ProfileTitle topicTitle="Links" spanNumber={3} />
|
||||
<ProfileLinks linklabel="Figma" link="https://figma.com" topic="Figma" />
|
||||
<ProfileLinks linklabel="Figma" link="https://figma.com" topic="Figma" />
|
||||
<ProfileLinks linklabel="Figma" link="https://figma.com" topic="Figma" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user