import * as React from "react" import { useAccount, useCoState } from "@/lib/providers/jazz-provider" import { PersonalLink, Topic } from "@/lib/schema" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { toast } from "sonner" import { createLinkSchema, LinkFormValues } from "./schema" import { cn, generateUniqueSlug } from "@/lib/utils" import { Form } from "@/components/ui/form" import { Button } from "@/components/ui/button" import { UrlInput } from "./url-input" import { UrlBadge } from "./url-badge" import { TitleInput } from "./title-input" import { NotesSection } from "./notes-section" import { DescriptionInput } from "./description-input" import { atom, useAtom } from "jotai" import { linkLearningStateSelectorAtom } from "@/store/link" 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" export const globalLinkFormExceptionRefsAtom = atom[]>([]) interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> { onClose?: () => void onSuccess?: () => void onFail?: () => void personalLink?: PersonalLink exceptionsRefs?: React.RefObject[] } const defaultValues: Partial = { url: "", icon: "", title: "", description: "", completed: false, notes: "", learningState: undefined, topic: null } export const LinkForm: React.FC = ({ onSuccess, onFail, personalLink, onClose, exceptionsRefs = [] }) => { const [istopicSelectorOpen] = useAtom(topicSelectorAtom) const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom) const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom) const formRef = React.useRef(null) const [isFetching, setIsFetching] = React.useState(false) const [urlFetched, setUrlFetched] = React.useState(null) const { me } = useAccount() const selectedLink = useCoState(PersonalLink, personalLink?.id) const form = useForm({ resolver: zodResolver(createLinkSchema), defaultValues, mode: "all" }) const topicName = form.watch("topic") const findTopic = React.useMemo( () => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me] ) const selectedTopic = useCoState(Topic, findTopic, {}) const allExceptionRefs = React.useMemo( () => [...exceptionsRefs, ...globalExceptionRefs], [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?.() } } document.addEventListener("mousedown", handleClickOutside) return () => { document.removeEventListener("mousedown", handleClickOutside) } }, [islearningStateSelectorOpen, istopicSelectorOpen, allExceptionRefs, onClose]) React.useEffect(() => { if (selectedLink) { setUrlFetched(selectedLink.url) form.reset({ url: selectedLink.url, icon: selectedLink.icon, title: selectedLink.title, description: selectedLink.description, completed: selectedLink.completed, notes: selectedLink.notes, learningState: selectedLink.learningState, topic: selectedLink.topic?.name }) } }, [selectedLink, selectedLink?.topic, form]) const fetchMetadata = async (url: string) => { setIsFetching(true) try { 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, { shouldValidate: true }) form.setValue("icon", data.icon ?? "", { shouldValidate: true }) form.setValue("title", data.title, { shouldValidate: true }) if (!form.getValues("description")) form.setValue("description", data.description, { shouldValidate: true }) form.setFocus("title") } catch (err) { console.error("Failed to fetch metadata", err) } finally { setIsFetching(false) } } const onSubmit = (values: LinkFormValues) => { if (isFetching || !me) return try { const slug = generateUniqueSlug(values.title) if (selectedLink) { const { topic, ...diffValues } = values if (!selectedTopic) { selectedLink.applyDiff({ ...diffValues, slug, updatedAt: new Date() }) } else { selectedLink.applyDiff({ ...values, slug, topic: selectedTopic }) } } else { const newPersonalLink = PersonalLink.create( { ...values, slug, topic: selectedTopic, sequence: me.root?.personalLinks?.length || 1, createdAt: new Date(), updatedAt: new Date() }, { owner: me._owner } ) me.root?.personalLinks?.push(newPersonalLink) } form.reset(defaultValues) onSuccess?.() } catch (error) { onFail?.() console.error("Failed to create/update link", error) toast.error(personalLink ? "Failed to update link" : "Failed to create link") } } const handleCancel = () => { form.reset(defaultValues) onClose?.() } const handleResetUrl = () => { setUrlFetched(null) form.setFocus("url") form.reset({ url: "", title: "", icon: "", description: "" }) } const canSubmit = form.formState.isValid && !form.formState.isSubmitting return (
{isFetching &&
) } LinkForm.displayName = "LinkForm"