mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
fix: detail topic (#117)
* feat: keyboard nav * fix: link update * feat: reusable learning state * chore: use new learning state * feat: add to my profile * . * . * feat: on enter open the link * fix: lint * fix: use eslint v8 instead of v9 * fix: add to my profile * chore: update personal link schema * chore: update personal page schema * fix: update detail wrapper * fix: update page section * removing option for learning status * removing option for learning status for topic * feat: add createdAt and updatedAt for personal Page * chore: update page section component * chore: remove chevron from sub menu * fix: sidebar * chore: add focus and disable toast * feat: la editor add execption for no command class * fix: la editor style and fix page detail * fix: title * fix: topic learning state * chore: add showSearch for learning state * fix: bunch stuff * chore: link list and item handle learning state * chore: set expand to false * feat: personal link for topic detail * chore: hook use topic data * chore: go to list * fix: link and topic * feat(utils): new keyboard utils * feat(store): add linkOpenPopoverForIdAtom for link * chore: using memo for use topic data * fix: remove duplicate component * chore: performance for topic detail lint item * refactor: remove LinkOptions component * chore: improve performance for list * feat: added LinkRoute copmonent * chore: link manage * feat: bottom bar * fix: link * fix: page wrapper * fix: import thing * chore: added a displayname * refactor: page detail * refactor: page detail * fix: add topic to personal link form link * fix: only show page count if more than zero * fix: sidebar topic section --------- Co-authored-by: Nikita <github@nikiv.dev> Co-authored-by: marshennikovaolga <marshennikova@gmail.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
|
||||
import { TextareaAutosize } from "@/components/custom/textarea-autosize"
|
||||
import { LinkFormValues } from "./schema"
|
||||
|
||||
interface DescriptionInputProps {}
|
||||
|
||||
export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow space-y-0">
|
||||
<FormLabel className="sr-only">Description</FormLabel>
|
||||
<FormControl>
|
||||
<TextareaAutosize
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Description (optional)"
|
||||
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>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
278
web/components/routes/link/partials/form/link-form.tsx
Normal file
278
web/components/routes/link/partials/form/link-form.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
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 { TopicSelector } from "./topic-selector"
|
||||
import { DescriptionInput } from "./description-input"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { linkLearningStateSelectorAtom, linkTopicSelectorAtom } from "@/store/link"
|
||||
import { FormField, FormItem, FormLabel } from "@/components/ui/form"
|
||||
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
|
||||
|
||||
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
|
||||
|
||||
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
|
||||
onClose?: () => void
|
||||
onSuccess?: () => void
|
||||
onFail?: () => void
|
||||
personalLink?: PersonalLink
|
||||
exceptionsRefs?: React.RefObject<HTMLElement>[]
|
||||
}
|
||||
|
||||
const defaultValues: Partial<LinkFormValues> = {
|
||||
url: "",
|
||||
icon: "",
|
||||
title: "",
|
||||
description: "",
|
||||
completed: false,
|
||||
notes: "",
|
||||
learningState: undefined,
|
||||
topic: null
|
||||
}
|
||||
|
||||
export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
onSuccess,
|
||||
onFail,
|
||||
personalLink,
|
||||
onClose,
|
||||
exceptionsRefs = []
|
||||
}) => {
|
||||
const [selectedTopic, setSelectedTopic] = React.useState<Topic | undefined>()
|
||||
const [istopicSelectorOpen] = useAtom(linkTopicSelectorAtom)
|
||||
const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
|
||||
const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null)
|
||||
|
||||
const [isFetching, setIsFetching] = React.useState(false)
|
||||
const [urlFetched, setUrlFetched] = React.useState<string | null>(null)
|
||||
const { me } = useAccount()
|
||||
const selectedLink = useCoState(PersonalLink, personalLink?.id)
|
||||
|
||||
const form = useForm<LinkFormValues>({
|
||||
resolver: zodResolver(createLinkSchema),
|
||||
defaultValues,
|
||||
mode: "all"
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}, [selectedLink, form])
|
||||
|
||||
const fetchMetadata = async (url: string) => {
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "no-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")
|
||||
console.log(form.formState.isValid, "form state after....")
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch metadata", err)
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = (values: LinkFormValues) => {
|
||||
if (isFetching) return
|
||||
|
||||
try {
|
||||
const personalLinks = me.root?.personalLinks?.toJSON() || []
|
||||
const slug = generateUniqueSlug(personalLinks, 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 (
|
||||
<div className="p-3 transition-all">
|
||||
<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">
|
||||
{isFetching && <div className="absolute inset-0 z-10 bg-transparent" aria-hidden="true" />}
|
||||
<div className="flex flex-col gap-1.5 p-3">
|
||||
<div className="flex flex-row items-start justify-between">
|
||||
<UrlInput urlFetched={urlFetched} fetchMetadata={fetchMetadata} isFetchingUrlMetadata={isFetching} />
|
||||
{urlFetched && <TitleInput urlFetched={urlFetched} />}
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="learningState"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormLabel className="sr-only">Topic</FormLabel>
|
||||
<LearningStateSelector
|
||||
value={field.value}
|
||||
onChange={value => {
|
||||
// toggle, if already selected set undefined
|
||||
form.setValue("learningState", field.value === value ? undefined : value)
|
||||
}}
|
||||
showSearch={false}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TopicSelector onSelect={topic => setSelectedTopic(topic)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DescriptionInput />
|
||||
<UrlBadge urlFetched={urlFetched} handleResetUrl={handleResetUrl} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-row items-center justify-between gap-2 rounded-b-md border-t px-3 py-2"
|
||||
onClick={e => {
|
||||
if (!(e.target as HTMLElement).closest("button")) {
|
||||
const notesInput = e.currentTarget.querySelector("input")
|
||||
if (notesInput) {
|
||||
notesInput.focus()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NotesSection />
|
||||
|
||||
{isFetching ? (
|
||||
<div className="flex w-auto items-center justify-end gap-x-2">
|
||||
<span className="text-muted-foreground flex items-center text-sm">
|
||||
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
|
||||
<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>
|
||||
Fetching metadata...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-auto items-center justify-end gap-x-2">
|
||||
<Button size="sm" type="button" variant="ghost" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" type="submit" disabled={!canSubmit}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
LinkForm.displayName = "LinkForm"
|
||||
36
web/components/routes/link/partials/form/notes-section.tsx
Normal file
36
web/components/routes/link/partials/form/notes-section.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { LinkFormValues } from "./schema"
|
||||
|
||||
export const NotesSection: React.FC = () => {
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative space-y-0">
|
||||
<FormLabel className="sr-only">Note</FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<LaIcon name="Pencil" aria-hidden="true" className="text-muted-foreground/70" />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Take a notes..."
|
||||
className={cn("placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0")}
|
||||
/>
|
||||
</>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
web/components/routes/link/partials/form/schema.ts
Normal file
15
web/components/routes/link/partials/form/schema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { urlSchema } from "@/lib/utils/schema"
|
||||
import { z } from "zod"
|
||||
|
||||
export const createLinkSchema = z.object({
|
||||
url: urlSchema,
|
||||
icon: z.string().optional(),
|
||||
title: z.string().min(1, { message: "Title can't be empty" }),
|
||||
description: z.string().optional(),
|
||||
completed: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
learningState: z.enum(["wantToLearn", "learning", "learned"]).optional(),
|
||||
topic: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
export type LinkFormValues = z.infer<typeof createLinkSchema>
|
||||
36
web/components/routes/link/partials/form/title-input.tsx
Normal file
36
web/components/routes/link/partials/form/title-input.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { LinkFormValues } from "./schema"
|
||||
|
||||
interface TitleInputProps {
|
||||
urlFetched: string | null
|
||||
}
|
||||
|
||||
export const TitleInput: React.FC<TitleInputProps> = ({ urlFetched }) => {
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow space-y-0">
|
||||
<FormLabel className="sr-only">Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type={urlFetched ? "text" : "hidden"}
|
||||
autoComplete="off"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Title"
|
||||
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
90
web/components/routes/link/partials/form/topic-selector.tsx
Normal file
90
web/components/routes/link/partials/form/topic-selector.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkTopicSelectorAtom } from "@/store/link"
|
||||
import { LinkFormValues } from "./schema"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||
import { ID } from "jazz-tools"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { Topic } from "@/lib/schema"
|
||||
|
||||
interface TopicSelectorProps {
|
||||
onSelect?: (value: Topic) => void
|
||||
}
|
||||
|
||||
export const TopicSelector: React.FC<TopicSelectorProps> = ({ onSelect }) => {
|
||||
const globalGroup = useCoState(
|
||||
PublicGlobalGroup,
|
||||
process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>,
|
||||
{
|
||||
root: {
|
||||
topics: []
|
||||
}
|
||||
}
|
||||
)
|
||||
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useAtom(linkTopicSelectorAtom)
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
const topic = globalGroup?.root.topics.find(topic => topic?.name === value)
|
||||
if (topic) {
|
||||
onSelect?.(topic)
|
||||
form?.setValue("topic", value)
|
||||
}
|
||||
setIsTopicSelectorOpen(false)
|
||||
}
|
||||
|
||||
const selectedValue = form ? form.watch("topic") : null
|
||||
|
||||
return (
|
||||
<Popover open={isTopicSelectorOpen} onOpenChange={setIsTopicSelectorOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" type="button" role="combobox" variant="secondary" className="gap-x-2 text-sm">
|
||||
<span className="truncate">
|
||||
{selectedValue
|
||||
? globalGroup?.root.topics.find(topic => topic?.id && topic.name === selectedValue)?.prettyName
|
||||
: "Topic"}
|
||||
</span>
|
||||
<LaIcon name="ChevronDown" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="z-50 w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="end"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search topic..." className="h-9" />
|
||||
<CommandList>
|
||||
<ScrollArea>
|
||||
<CommandGroup>
|
||||
{globalGroup?.root.topics.map(
|
||||
topic =>
|
||||
topic?.id && (
|
||||
<CommandItem key={topic.id} value={topic.name} onSelect={handleSelect}>
|
||||
{topic.prettyName}
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className={cn(
|
||||
"absolute right-3",
|
||||
topic.name === selectedValue ? "text-primary" : "text-transparent"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
35
web/components/routes/link/partials/form/url-badge.tsx
Normal file
35
web/components/routes/link/partials/form/url-badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { LinkFormValues } from "./schema"
|
||||
|
||||
interface UrlBadgeProps {
|
||||
urlFetched: string | null
|
||||
handleResetUrl: () => void
|
||||
}
|
||||
|
||||
export const UrlBadge: React.FC<UrlBadgeProps> = ({ urlFetched, handleResetUrl }) => {
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
if (!urlFetched) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<div className="flex min-w-0 flex-row items-center gap-1.5">
|
||||
<Badge variant="secondary" className="relative truncate py-1 text-xs">
|
||||
{form.getValues("url")}
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleResetUrl}
|
||||
className="text-muted-foreground hover:text-foreground ml-2 size-4 rounded-full bg-transparent hover:bg-transparent"
|
||||
>
|
||||
<LaIcon name="X" className="size-3.5" />
|
||||
</Button>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
web/components/routes/link/partials/form/url-input.tsx
Normal file
71
web/components/routes/link/partials/form/url-input.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { FormField, FormItem, FormControl, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
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 { TooltipArrow } from "@radix-ui/react-tooltip"
|
||||
|
||||
interface UrlInputProps {
|
||||
urlFetched: string | null
|
||||
fetchMetadata: (url: string) => Promise<void>
|
||||
isFetchingUrlMetadata: boolean
|
||||
}
|
||||
|
||||
export const UrlInput: React.FC<UrlInputProps> = ({ urlFetched, fetchMetadata, isFetchingUrlMetadata }) => {
|
||||
const [isFocused, setIsFocused] = React.useState(false)
|
||||
const form = useFormContext<LinkFormValues>()
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && form.getValues("url")) {
|
||||
e.preventDefault()
|
||||
fetchMetadata(form.getValues("url"))
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowTooltip = isFocused && !form.formState.errors.url && !!form.getValues("url") && !urlFetched
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn("grow space-y-0", {
|
||||
"hidden select-none": urlFetched
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
</FormControl>
|
||||
<FormMessage className="px-1.5" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user