diff --git a/apps/web/components/add-document/index.tsx b/apps/web/components/add-document/index.tsx index 22b87e09..1c231f7a 100644 --- a/apps/web/components/add-document/index.tsx +++ b/apps/web/components/add-document/index.tsx @@ -147,9 +147,10 @@ export function AddDocument({ const fileDataRef = useRef(fileData) fileDataRef.current = fileData - const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({ - onClose, - }) + const { noteMutation, linkMutation, bulkLinkMutation, fileMutation } = + useDocumentMutations({ + onClose, + }) const autumn = useCustomer() const { @@ -187,13 +188,30 @@ export function AddDocument({ const handleLinkSubmit = useCallback( (data: LinkData) => { + // In bulk mode the selection is the source of truth, not the raw textarea. + if (data.bulkUrls) { + if (data.bulkUrls.length >= 2) { + bulkLinkMutation.mutate({ + urls: data.bulkUrls, + project: localSelectedProject, + }) + return + } + const [onlyUrl] = data.bulkUrls + if (onlyUrl) { + linkMutation.mutate({ url: onlyUrl, project: localSelectedProject }) + return + } + toast.error("Select at least one link") + return + } if (!data.url.trim()) { toast.error("Please enter a URL") return } linkMutation.mutate({ url: data.url, project: localSelectedProject }) }, - [linkMutation, localSelectedProject], + [linkMutation, bulkLinkMutation, localSelectedProject], ) const handleFileSubmit = useCallback( @@ -263,6 +281,14 @@ export function AddDocument({ setFileData(data) }, []) + const handleNoteImportLinks = useCallback( + (urls: string[]) => { + setLinkData({ url: urls.join("\n"), title: "", description: "" }) + setActiveTab("link") + }, + [setActiveTab], + ) + const handleButtonClick = () => { switch (activeTab) { case "note": @@ -278,12 +304,21 @@ export function AddDocument({ } const isSubmitting = - noteMutation.isPending || linkMutation.isPending || fileMutation.isPending + noteMutation.isPending || + linkMutation.isPending || + bulkLinkMutation.isPending || + fileMutation.isPending const fileTabHasPending = fileData.items.some((i) => i.status === "pending") const fileTabSubmitDisabled = activeTab === "file" && (!fileTabHasPending || isSubmitting) + const linkBulkCount = linkData.bulkUrls?.length ?? 0 + const linkTabSubmitDisabled = + activeTab === "link" && + linkData.bulkUrls !== undefined && + linkBulkCount === 0 + const spaceSelector = ( @@ -539,7 +577,9 @@ export function AddDocument({ variant="insideOut" onClick={handleButtonClick} disabled={ - activeTab === "file" ? fileTabSubmitDisabled : isSubmitting + activeTab === "file" + ? fileTabSubmitDisabled + : isSubmitting || linkTabSubmitDisabled } className={cn(isMobile && "h-12 flex-1 px-5 text-[15px]")} > @@ -548,6 +588,8 @@ export function AddDocument({ Adding… + ) : activeTab === "link" && linkBulkCount >= 2 ? ( + `+ Add ${linkBulkCount} memories` ) : ( <> + Add {activeTab}{" "} diff --git a/apps/web/components/add-document/link.tsx b/apps/web/components/add-document/link.tsx index f2c7bc16..1f478dee 100644 --- a/apps/web/components/add-document/link.tsx +++ b/apps/web/components/add-document/link.tsx @@ -1,18 +1,20 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useMemo } from "react" import { cn } from "@lib/utils" import { Button } from "@ui/components/button" import { dmSansClassName } from "@/lib/fonts" import { useHotkeys } from "react-hotkeys-hook" import { Image as ImageIcon, Loader2 } from "lucide-react" import { toast } from "sonner" +import { extractUrls, getFaviconUrl, normalizeUrl } from "@/lib/url-helpers" export interface LinkData { url: string title: string description: string image?: string + bulkUrls?: string[] } interface LinkContentProps { @@ -23,6 +25,9 @@ interface LinkContentProps { initialData?: LinkData } +const prettyUrl = (url: string) => + url.replace(/^https?:\/\//, "").replace(/\/$/, "") + export function LinkContent({ onSubmit, onDataChange, @@ -35,50 +40,48 @@ export function LinkContent({ const [description, setDescription] = useState(initialData?.description ?? "") const [image, setImage] = useState(initialData?.image) const [isPreviewLoading, setIsPreviewLoading] = useState(false) + const [deselected, setDeselected] = useState>(new Set()) - const canSubmit = url.trim().length > 0 && !isSubmitting + const { urls: detectedUrls, duplicates } = useMemo( + () => extractUrls(url), + [url], + ) + const isBulk = detectedUrls.length >= 2 + const selectedUrls = useMemo( + () => detectedUrls.filter((u) => !deselected.has(u)), + [detectedUrls, deselected], + ) + + const canSubmit = + (isBulk ? selectedUrls.length > 0 : url.trim().length > 0) && !isSubmitting + + useEffect(() => { + const selected = detectedUrls.filter((u) => !deselected.has(u)) + onDataChange?.({ + url: url.trim(), + title, + description, + ...(image && { image }), + ...(detectedUrls.length >= 2 && { bulkUrls: selected }), + }) + }, [url, title, description, image, detectedUrls, deselected, onDataChange]) const handleSubmit = () => { - if (canSubmit && onSubmit) { - let normalizedUrl = url.trim() - if ( - !normalizedUrl.startsWith("http://") && - !normalizedUrl.startsWith("https://") - ) { - normalizedUrl = `https://${normalizedUrl}` - } - onSubmit({ url: normalizedUrl, title, description }) + if (!canSubmit || !onSubmit) return + if (isBulk) { + onSubmit({ url: "", title, description, bulkUrls: selectedUrls }) + return } + onSubmit({ url: normalizeUrl(url.trim()), title, description }) } - const updateData = ( - newUrl: string, - newTitle: string, - newDescription: string, - newImage?: string, - ) => { - onDataChange?.({ - url: newUrl, - title: newTitle, - description: newDescription, - ...(newImage && { image: newImage }), + const toggleUrl = (u: string) => + setDeselected((prev) => { + const next = new Set(prev) + if (next.has(u)) next.delete(u) + else next.add(u) + return next }) - } - - const handleUrlChange = (newUrl: string) => { - setUrl(newUrl) - updateData(newUrl, title, description, image) - } - - const handleTitleChange = (newTitle: string) => { - setTitle(newTitle) - updateData(url, newTitle, description) - } - - const handleDescriptionChange = (newDescription: string) => { - setDescription(newDescription) - updateData(url, title, newDescription, image) - } const handlePreviewLink = async () => { if (!url.trim()) { @@ -86,15 +89,8 @@ export function LinkContent({ return } - let normalizedUrl = url.trim() - if ( - !normalizedUrl.startsWith("http://") && - !normalizedUrl.startsWith("https://") - ) { - normalizedUrl = `https://${normalizedUrl}` - setUrl(normalizedUrl) - updateData(normalizedUrl, title, description, image) - } + const normalizedUrl = normalizeUrl(url.trim()) + if (normalizedUrl !== url) setUrl(normalizedUrl) setIsPreviewLoading(true) try { @@ -109,16 +105,11 @@ export function LinkContent({ const data = await response.json() - const newTitle = data.title || "" - const newDescription = data.description || "" - const newImage = data.image || undefined + setTitle(data.title || "") + setDescription(data.description || "") + setImage(data.image || undefined) - setTitle(newTitle) - setDescription(newDescription) - setImage(newImage) - updateData(url, newTitle, newDescription, newImage) - - if (!newTitle && !newDescription && !newImage) { + if (!data.title && !data.description && !data.image) { toast.info("No Open Graph data found for this URL") } else { toast.success("Preview loaded successfully") @@ -138,13 +129,13 @@ export function LinkContent({ enableOnFormTags: ["INPUT", "TEXTAREA"], }) - // Reset content when modal closes useEffect(() => { if (!isOpen) { setUrl("") setTitle("") setDescription("") setImage(undefined) + setDeselected(new Set()) onDataChange?.({ url: "", title: "", description: "" }) } }, [isOpen, onDataChange]) @@ -160,86 +151,160 @@ export function LinkContent({

- Paste a link to turn it into a memory + Paste one link — or many — to turn them into memories

- handleUrlChange(e.target.value)} - placeholder="https://example.com" - disabled={isSubmitting} - className="w-full p-4 rounded-xl bg-[#14161A] shadow-inside-out disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]" - /> - -
- -
-
-

- Link title -

- handleTitleChange(e.target.value)} - placeholder="Mahesh Sanikommu - Portfolio" - disabled - className="w-full px-4 py-3 bg-[#0F1217] rounded-xl disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]" - /> -
-
-

- Link description -