mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Merge branch 'main' of github.com:learn-anything/learn-anything.xyz
This commit is contained in:
@@ -121,20 +121,24 @@ interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess, onCancel, personalLink }, ref) => {
|
const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess, onCancel, personalLink }, ref) => {
|
||||||
const selectedLink = useCoState(PersonalLink, personalLink?.id)
|
|
||||||
const [isFetching, setIsFetching] = useState(false)
|
const [isFetching, setIsFetching] = useState(false)
|
||||||
const { me } = useAccount()
|
const { me } = useAccount()
|
||||||
const form = useForm<LinkFormValues>({
|
const form = useForm<LinkFormValues>({
|
||||||
resolver: zodResolver(createLinkSchema),
|
resolver: zodResolver(createLinkSchema),
|
||||||
defaultValues: DEFAULT_FORM_VALUES
|
defaultValues: {
|
||||||
|
...DEFAULT_FORM_VALUES,
|
||||||
|
isLink: true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
const selectedLink = useCoState(PersonalLink, personalLink?.id)
|
||||||
const title = form.watch("title")
|
const title = form.watch("title")
|
||||||
|
const [inputValue, setInputValue] = useState("")
|
||||||
const [originalLink, setOriginalLink] = useState<string>("")
|
const [originalLink, setOriginalLink] = useState<string>("")
|
||||||
const [linkEntered, setLinkEntered] = useState(false)
|
const [linkValidation, setLinkValidation] = useState<string | null>(null)
|
||||||
|
const [invalidLink, setInvalidLink] = useState(false)
|
||||||
|
const [showLink, setShowLink] = useState(false)
|
||||||
const [debouncedText, setDebouncedText] = useState<string>("")
|
const [debouncedText, setDebouncedText] = useState<string>("")
|
||||||
useDebounce(() => setDebouncedText(title), 300, [title])
|
useDebounce(() => setDebouncedText(title), 300, [title])
|
||||||
|
|
||||||
const [showStatusOptions, setShowStatusOptions] = useState(false)
|
const [showStatusOptions, setShowStatusOptions] = useState(false)
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
|
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -166,11 +170,35 @@ const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess,
|
|||||||
}
|
}
|
||||||
}, [selectedLink, form])
|
}, [selectedLink, form])
|
||||||
|
|
||||||
|
const changeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value
|
||||||
|
setInputValue(value)
|
||||||
|
form.setValue("title", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pressEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter" && !showLink) {
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmedValue = inputValue.trim().toLowerCase()
|
||||||
|
if (LibIsUrl(trimmedValue)) {
|
||||||
|
setShowLink(true)
|
||||||
|
setInvalidLink(false)
|
||||||
|
setLinkValidation(trimmedValue)
|
||||||
|
setInputValue(trimmedValue)
|
||||||
|
form.setValue("title", trimmedValue)
|
||||||
|
} else {
|
||||||
|
setInvalidLink(true)
|
||||||
|
setShowLink(true)
|
||||||
|
setLinkValidation(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMetadata = async (url: string) => {
|
const fetchMetadata = async (url: string) => {
|
||||||
setIsFetching(true)
|
setIsFetching(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "no-store" })
|
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "force-cache" })
|
||||||
if (!res.ok) throw new Error("Failed to fetch metadata")
|
if (!res.ok) throw new Error("Failed to fetch metadata")
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
form.setValue("isLink", true)
|
form.setValue("isLink", true)
|
||||||
@@ -188,19 +216,10 @@ const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess,
|
|||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (showLink && !invalidLink && LibIsUrl(form.getValues("title").toLowerCase())) {
|
||||||
const lowerText = debouncedText.toLowerCase()
|
fetchMetadata(ensureUrlProtocol(form.getValues("title").toLowerCase()))
|
||||||
if (linkEntered && LibIsUrl(lowerText)) {
|
|
||||||
fetchMetadata(ensureUrlProtocol(lowerText))
|
|
||||||
}
|
}
|
||||||
}, [debouncedText, form, linkEntered])
|
}, [showLink, invalidLink, form])
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter" && LibIsUrl(e.currentTarget.value.toLowerCase())) {
|
|
||||||
e.preventDefault()
|
|
||||||
setLinkEntered(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = (values: LinkFormValues) => {
|
const onSubmit = (values: LinkFormValues) => {
|
||||||
if (isFetching) return
|
if (isFetching) return
|
||||||
@@ -221,11 +240,9 @@ const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess,
|
|||||||
selectedLink.description = values.description ?? ""
|
selectedLink.description = values.description ?? ""
|
||||||
selectedLink.isLink = values.isLink
|
selectedLink.isLink = values.isLink
|
||||||
|
|
||||||
if (selectedLink.meta) {
|
if (values.isLink && values.meta) {
|
||||||
Object.assign(selectedLink.meta, values.meta)
|
linkMetadata = LinkMetadata.create(values.meta, { owner: me._owner })
|
||||||
}
|
}
|
||||||
|
|
||||||
// toast.success("Todo updated")
|
|
||||||
} else {
|
} else {
|
||||||
const newPersonalLink = PersonalLink.create(
|
const newPersonalLink = PersonalLink.create(
|
||||||
{
|
{
|
||||||
@@ -252,26 +269,26 @@ const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel: () => void = () => {
|
const undoEditing: () => void = () => {
|
||||||
form.reset(DEFAULT_FORM_VALUES)
|
form.reset(DEFAULT_FORM_VALUES)
|
||||||
onCancel?.()
|
onCancel?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 transition-all">
|
<div className="p-3 transition-all">
|
||||||
<div className="bg-muted/50 rounded-md border">
|
<div className="rounded-md border bg-muted/50">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1" ref={ref}>
|
<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-row p-3">
|
||||||
<div className="flex flex-auto flex-col gap-1.5">
|
<div className="flex flex-auto flex-col gap-1.5">
|
||||||
<div className="flex flex-row items-start justify-between">
|
<div className="flex flex-row items-start justify-between">
|
||||||
<div className="flex grow flex-row items-center gap-1.5">
|
<div className="flex grow flex-row items-center gap-1.5">
|
||||||
<Button
|
{/* <Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Choose icon"
|
aria-label="Choose icon"
|
||||||
className="text-primary/60 size-7"
|
className="size-7 text-primary/60"
|
||||||
>
|
>
|
||||||
{form.watch("isLink") ? (
|
{form.watch("isLink") ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -284,7 +301,7 @@ const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess,
|
|||||||
) : (
|
) : (
|
||||||
<BoxIcon size={16} />
|
<BoxIcon size={16} />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button> */}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -295,45 +312,36 @@ const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess,
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
|
value={inputValue}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Paste a link or write a link"
|
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"
|
className={cn(
|
||||||
onKeyDown={handleKeyDown}
|
"h-6 border-none p-1.5 font-medium placeholder:text-primary/40 focus-visible:outline-none focus-visible:ring-0",
|
||||||
|
invalidLink ? "text-red-500" : ""
|
||||||
|
)}
|
||||||
|
onKeyDown={pressEnter}
|
||||||
|
onChange={changeInput}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="mr-5 max-w-[200px] truncate text-xs text-white/60">
|
{showLink && (
|
||||||
{linkEntered
|
<span className={cn("mr-5 max-w-[200px] truncate text-xs", invalidLink ? "text-red-500" : "")}>
|
||||||
? originalLink
|
{invalidLink ? "Only links are allowed" : linkValidation || originalLink || ""}
|
||||||
: LibIsUrl(form.watch("title").toLowerCase())
|
</span>
|
||||||
? 'Press "Enter" to confirm URL'
|
)}
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
|
<div className="flex min-w-0 shrink-0 cursor-pointer select-none flex-row">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild></DropdownMenuTrigger>
|
||||||
{/* <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">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
<DropdownMenuItem className="group">
|
<DropdownMenuItem className="group">
|
||||||
<Trash2Icon size={16} className="text-destructive mr-2 group-hover:text-red-500" />
|
<Trash2Icon size={16} className="mr-2 text-destructive group-hover:text-red-500" />
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -393,7 +401,7 @@ const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess,
|
|||||||
{...field}
|
{...field}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="Description (optional)"
|
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"
|
className="min-h-[24px] resize-none overflow-y-auto border-none p-1.5 text-xs font-medium shadow-none placeholder:text-primary/40 focus-visible:outline-none focus-visible:ring-0"
|
||||||
onInput={e => {
|
onInput={e => {
|
||||||
const target = e.target as HTMLTextAreaElement
|
const target = e.target as HTMLTextAreaElement
|
||||||
target.style.height = "auto"
|
target.style.height = "auto"
|
||||||
@@ -416,10 +424,10 @@ const LinkForm = React.forwardRef<HTMLFormElement, LinkFormProps>(({ onSuccess,
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-auto items-center justify-end">
|
<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">
|
<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}>
|
<Button size="sm" type="button" variant="ghost" onClick={undoEditing}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" disabled={isFetching}>
|
<Button size="sm" type="submit" disabled={isFetching}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import { isUrl } from "@/lib/utils"
|
||||||
|
|
||||||
export const createLinkSchema = z.object({
|
export const createLinkSchema = z.object({
|
||||||
title: z
|
title: z.string().min(1, { message: "Title can't be empty" }),
|
||||||
.string({
|
originalUrl: z.string().refine(isUrl, { message: "Only links are allowed" }),
|
||||||
message: "Please enter a valid title"
|
|
||||||
})
|
|
||||||
.min(1, {
|
|
||||||
message: "Please enter a valid title"
|
|
||||||
}),
|
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
topic: z.string().optional(),
|
topic: z.string().optional(),
|
||||||
isLink: z.boolean().default(false),
|
isLink: z.boolean().default(true),
|
||||||
meta: z
|
meta: z
|
||||||
.object({
|
.object({
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const LinkHeader = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContentHeader className="p-4">
|
<ContentHeader className="p-4">
|
||||||
{/* Toggle and Title */}
|
|
||||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||||
<SidebarToggleButton />
|
<SidebarToggleButton />
|
||||||
<div className="flex min-h-0 items-center">
|
<div className="flex min-h-0 items-center">
|
||||||
@@ -50,18 +49,25 @@ export const LinkHeader = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Tabs = () => {
|
const Tabs = () => {
|
||||||
|
const [activeTab, setActiveTab] = React.useState(TABS[0])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-secondary/50 flex items-baseline overflow-x-hidden rounded-md">
|
<div className="bg-secondary/50 flex items-baseline overflow-x-hidden rounded-md">
|
||||||
{TABS.map(tab => (
|
{TABS.map(tab => (
|
||||||
<TabItem key={tab} url="#" label={tab} />
|
<TabItem key={tab} url="#" label={tab} isActive={activeTab === tab} onClick={() => setActiveTab(tab)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabItem = ({ url, label }: TabItemProps) => {
|
interface TabItemProps {
|
||||||
const [isActive, setIsActive] = React.useState(false)
|
url: string
|
||||||
|
label: string
|
||||||
|
isActive: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabItem = ({ url, label, isActive, onClick }: TabItemProps) => {
|
||||||
return (
|
return (
|
||||||
<div tabIndex={-1} className="rounded-md">
|
<div tabIndex={-1} className="rounded-md">
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
@@ -72,8 +78,7 @@ const TabItem = ({ url, label }: TabItemProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={`gap-x-2 truncate text-sm ${isActive ? "bg-accent text-accent-foreground" : ""}`}
|
className={`gap-x-2 truncate text-sm ${isActive ? "bg-accent text-accent-foreground" : ""}`}
|
||||||
onClick={() => setIsActive(true)}
|
onClick={onClick}
|
||||||
onBlur={() => setIsActive(false)}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||||
const formRef = React.useRef<HTMLFormElement>(null)
|
const formRef = React.useRef<HTMLFormElement>(null)
|
||||||
|
const [showDeleteIcon, setShowDeleteIcon] = React.useState(false)
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -77,8 +78,15 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
setEditId(null)
|
setEditId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// const handleRowClick = () => {
|
||||||
|
// console.log("Row clicked", personalLink.id)
|
||||||
|
// setEditId(personalLink.id)
|
||||||
|
// }
|
||||||
const handleRowClick = () => {
|
const handleRowClick = () => {
|
||||||
console.log("Row clicked", personalLink.id)
|
setShowDeleteIcon(!showDeleteIcon)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDoubleClick = () => {
|
||||||
setEditId(personalLink.id)
|
setEditId(personalLink.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +134,7 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
"bg-muted/50": isFocused
|
"bg-muted/50": isFocused
|
||||||
})}
|
})}
|
||||||
onClick={handleRowClick}
|
onClick={handleRowClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
|
<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">
|
<div className="flex min-w-0 items-center gap-x-4">
|
||||||
@@ -177,13 +186,15 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-x-4">
|
<div className="flex shrink-0 items-center gap-x-4">
|
||||||
<Badge variant="secondary">Topic Name</Badge>
|
<Badge variant="secondary">Topic Name</Badge>
|
||||||
<Button
|
{showDeleteIcon && (
|
||||||
size="icon"
|
<Button
|
||||||
className="text-destructive h-auto w-auto bg-transparent hover:bg-transparent hover:text-red-500"
|
size="icon"
|
||||||
onClick={e => handleDelete(e, personalLink)}
|
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>
|
<Trash2Icon size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class LinkMetadata extends CoMap {
|
|||||||
export class PersonalLink extends CoMap {
|
export class PersonalLink extends CoMap {
|
||||||
title = co.string
|
title = co.string
|
||||||
slug = co.string
|
slug = co.string
|
||||||
description = nullable(co.string)
|
description = co.optional.string
|
||||||
completed = co.boolean
|
completed = co.boolean
|
||||||
sequence = co.number
|
sequence = co.number
|
||||||
isLink = co.boolean
|
isLink = co.boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user