mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-26 02:08:40 +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:
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)
|
||||
})
|
||||
Reference in New Issue
Block a user