diff --git a/src/webapp/features/cards/components/addCardDrawer/AddCardForm.tsx b/src/webapp/features/cards/components/addCardDrawer/AddCardForm.tsx index 52f51c07..486f54af 100644 --- a/src/webapp/features/cards/components/addCardDrawer/AddCardForm.tsx +++ b/src/webapp/features/cards/components/addCardDrawer/AddCardForm.tsx @@ -9,7 +9,6 @@ import { Stack, Text, Textarea, - TextInput, ThemeIcon, VisuallyHidden, Container, @@ -19,7 +18,7 @@ import { useForm } from '@mantine/form'; import { notifications } from '@mantine/notifications'; import useAddCard from '../../lib/mutations/useAddCard'; import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; import { useDisclosure } from '@mantine/hooks'; import { BiCollection } from 'react-icons/bi'; @@ -33,6 +32,7 @@ import { Collection, CollectionAccessType } from '@semble/types'; import { FaSeedling } from 'react-icons/fa6'; import { CardSaveSource } from '@/features/analytics/types'; import { usePathname } from 'next/navigation'; +import UrlSearchInput from '@/features/connections/components/addConnectionDrawer/UrlSearchInput'; interface Props { onClose: () => void; @@ -67,12 +67,29 @@ export default function AddCardForm(props: Props) { pagePath: pathname, }); + const rawUrlInput = useRef(props.initialUrl ?? ''); + const form = useForm({ initialValues: { url: props.initialUrl || '', note: '', collections: selectedCollections, }, + validateInputOnChange: false, + validateInputOnBlur: true, + validate: { + url: (value) => { + if (!value || value.trim() === '') { + return 'URL is required'; + } + try { + new URL(value); + return null; + } catch { + return 'Please enter a valid URL'; + } + }, + }, }); const MAX_NOTE_LENGTH = 500; @@ -80,11 +97,26 @@ export default function AddCardForm(props: Props) { useEffect(() => { if (props.initialUrl) { form.setValues({ url: props.initialUrl }); + rawUrlInput.current = props.initialUrl; } }, [props.initialUrl]); const handleAddCard = (e: React.FormEvent) => { e.preventDefault(); + + // Auto-confirm a valid URL that was typed/pasted but not explicitly selected + if (!form.values.url && rawUrlInput.current) { + try { + new URL(rawUrlInput.current); + form.setFieldValue('url', rawUrlInput.current); + } catch { + // let validation handle it + } + } + + const validation = form.validate(); + if (validation.hasErrors) return; + track('add new card'); // Capture values before any state changes @@ -109,6 +141,7 @@ export default function AddCardForm(props: Props) { // Close drawer immediately props.onClose(); setSelectedCollections(initialCollections); + rawUrlInput.current = ''; form.reset(); addCard.mutate({ ...cardData, notificationId }); @@ -118,17 +151,26 @@ export default function AddCardForm(props: Props) { <>
- } - key={form.key('url')} - {...form.getInputProps('url')} + placeholder="Search or paste a link" + value={form.values.url} + error={form.errors.url} + onUrlSelect={(url) => form.setFieldValue('url', url)} + onUrlClear={() => { + rawUrlInput.current = ''; + form.setFieldValue('url', ''); + }} + onInputChange={(raw) => { + rawUrlInput.current = raw; + }} + inputProps={{ + variant: 'filled', + size: 'md', + leftSection: , + 'data-autofocus': true, + }} /> diff --git a/src/webapp/features/composer/components/Composer.tsx b/src/webapp/features/composer/components/Composer.tsx index 9f9f5bcd..8cfb96d7 100644 --- a/src/webapp/features/composer/components/Composer.tsx +++ b/src/webapp/features/composer/components/Composer.tsx @@ -18,7 +18,7 @@ import { VisuallyHidden, Scroller, } from '@mantine/core'; -import { useState, useEffect, Suspense } from 'react'; +import { useState, useEffect, useRef, Suspense } from 'react'; import { Collection, CollectionAccessType } from '@semble/types'; import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; import { useForm } from '@mantine/form'; @@ -30,6 +30,7 @@ import CollectionSelectorSkeleton from '@/features/collections/components/collec import { useDisclosure } from '@mantine/hooks'; import { BiCollection } from 'react-icons/bi'; import { IoMdCheckmark, IoMdLink } from 'react-icons/io'; +import UrlSearchInput from '@/features/connections/components/addConnectionDrawer/UrlSearchInput'; import { track } from '@vercel/analytics'; import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; import { isMarginUri, getMarginUrl } from '@/lib/utils/margin'; @@ -84,12 +85,29 @@ export default function Composer(props: Props) { pagePath: pathname, }); + const rawUrlInput = useRef(props.initialUrl ?? ''); + const cardForm = useForm({ initialValues: { url: props.initialUrl || '', note: '', collections: selectedCollections, }, + validateInputOnChange: false, + validateInputOnBlur: true, + validate: { + url: (value) => { + if (!value || value.trim() === '') { + return 'URL is required'; + } + try { + new URL(value); + return null; + } catch { + return 'Please enter a valid URL'; + } + }, + }, }); // Collection form state @@ -114,6 +132,7 @@ export default function Composer(props: Props) { useEffect(() => { if (props.initialUrl) { cardForm.setValues({ url: props.initialUrl }); + rawUrlInput.current = props.initialUrl; } }, [props.initialUrl]); @@ -124,11 +143,26 @@ export default function Composer(props: Props) { collectionForm.reset(); setSelectedCollections(initialCollections); setMode('card'); + rawUrlInput.current = ''; } }, [props.isOpen]); const handleAddCard = (e: React.FormEvent) => { e.preventDefault(); + + // Auto-confirm a valid URL that was typed/pasted but not explicitly selected + if (!cardForm.values.url && rawUrlInput.current) { + try { + new URL(rawUrlInput.current); + cardForm.setFieldValue('url', rawUrlInput.current); + } catch { + // let validation handle it + } + } + + const validation = cardForm.validate(); + if (validation.hasErrors) return; + track('add new card'); // Capture values before any state changes @@ -154,6 +188,7 @@ export default function Composer(props: Props) { props.onClose(); setSelectedCollections(initialCollections); window.history.replaceState({}, '', window.location.pathname); + rawUrlInput.current = ''; cardForm.reset(); addCard.mutate({ ...cardData, notificationId }); @@ -268,18 +303,26 @@ export default function Composer(props: Props) { style={{ flex: 1, display: 'flex', flexDirection: 'column' }} > - } - data-autofocus - key={cardForm.key('url')} - {...cardForm.getInputProps('url')} + placeholder="Search of paste a link" + value={cardForm.values.url} + error={cardForm.errors.url} + onUrlSelect={(url) => cardForm.setFieldValue('url', url)} + onUrlClear={() => { + rawUrlInput.current = ''; + cardForm.setFieldValue('url', ''); + }} + onInputChange={(raw) => { + rawUrlInput.current = raw; + }} + inputProps={{ + variant: 'filled', + size: 'md', + leftSection: , + 'data-autofocus': true, + }} /> diff --git a/src/webapp/features/connections/components/addConnectionDrawer/SourceCardPreview.tsx b/src/webapp/features/connections/components/addConnectionDrawer/SourceCardPreview.tsx index 11e05b65..5ac1243c 100644 --- a/src/webapp/features/connections/components/addConnectionDrawer/SourceCardPreview.tsx +++ b/src/webapp/features/connections/components/addConnectionDrawer/SourceCardPreview.tsx @@ -19,7 +19,7 @@ function SourceCardPreviewSkeleton() { return ( - + @@ -56,8 +56,8 @@ export default function SourceCardPreview(props: Props) { src={data.metadata.imageUrl} alt={`${data.metadata.title} social preview image`} radius={'md'} - w={45} - h={45} + w={42} + h={42} style={{ flexShrink: 0 }} /> )} diff --git a/src/webapp/features/connections/components/addConnectionDrawer/UrlSearchInput.tsx b/src/webapp/features/connections/components/addConnectionDrawer/UrlSearchInput.tsx index 3fa55e7a..0e3e2b3e 100644 --- a/src/webapp/features/connections/components/addConnectionDrawer/UrlSearchInput.tsx +++ b/src/webapp/features/connections/components/addConnectionDrawer/UrlSearchInput.tsx @@ -13,6 +13,8 @@ import { Skeleton, Stack, Text, + TextInput, + type TextInputProps, ThemeIcon, useCombobox, VisuallyHidden, @@ -61,6 +63,24 @@ interface Props { onUrlSelect: (url: string) => void; onUrlClear?: () => void; onInputChange?: (rawValue: string) => void; + /** + * When provided, the input is rendered as a standard Mantine `TextInput` + * (with a visible label) and these props are spread onto it. Use this to + * customize variant, size, leftSection, etc. When omitted, the input keeps + * the legacy card-wrapped unstyled layout used by the connection form. + */ + inputProps?: Omit< + TextInputProps, + | 'id' + | 'label' + | 'placeholder' + | 'value' + | 'onChange' + | 'onFocus' + | 'onBlur' + | 'error' + | 'required' + > & { [K in `data-${string}`]?: unknown }; } export default function UrlSearchInput(props: Props) { @@ -216,83 +236,198 @@ export default function UrlSearchInput(props: Props) { const currentError = searchFilter === 'cards' ? error : collectionSearchError; - return ( - - - { - props.onUrlSelect(url); - setInputValue(url); - if (isValidUrl(url)) { - setConfirmedUrl(url); - // Look up metadata from search results or recent cards - const searchMatch = urls.find((u) => u.url === url); - const recentMatch = recentCardsList.find((c) => c.url === url); - setConfirmedMetadata( - searchMatch?.metadata ?? recentMatch?.cardContent, - ); - } - combobox.closeDropdown(); - }} - > - - { - const val = e.currentTarget.value; - setInputValue(val); - props.onInputChange?.(val); - combobox.openDropdown(); - }} - onFocus={() => { - setIsInputFocused(true); - combobox.openDropdown(); - }} - onBlur={() => { - setIsInputFocused(false); - }} - rightSection={null} - variant="unstyled" - size="md" - required - error={props.error} - /> - - - + ) : collectionOptions.filter(Boolean).length > 0 ? ( + {collectionOptions} + ) : ( + debounced.trim().length > 0 && ( + No collections found + ) )} )} - - - - + + )} + + + + + ); + + if (isPlainLayout) { + return comboboxBlock; + } + + return ( + + + {comboboxBlock} {props.label}