From 7750af188b68e3e9d42b36e13f3687e64a6c1df7 Mon Sep 17 00:00:00 2001 From: Danilo Sekulovikj Date: Sun, 22 Feb 2026 18:13:25 +0100 Subject: [PATCH] feat: add ability to create new collections directly from popup Allows users to create new collections directly from the extension popup and options page, significantly reducing friction when saving links. Features and improvements: - Lazily creates the collection upon saving the bookmark to prevent orphaned empty collections if the user cancels. - Implements a pinned footer inside the collection dropdown for creating a new collection based on the search input. - Displays a clear, centered 'Create' button in the empty state when no existing collections match the search query. - Uses trimmed, case-insensitive checks to prevent creating duplicate collections or whitespace-only names. - Adds specific error handling to distinguish between link save failures and collection creation failures. - Fixes dark mode styling for the dropdown overlay. - Refactors the dropdown collection list to eliminate code duplication across rendering paths. --- src/@/components/BookmarkForm.tsx | 338 ++++++++++++++++++------------ src/@/lib/actions/collections.ts | 23 ++ 2 files changed, 223 insertions(+), 138 deletions(-) diff --git a/src/@/components/BookmarkForm.tsx b/src/@/components/BookmarkForm.tsx index f3ddb46..aee2ba2 100644 --- a/src/@/components/BookmarkForm.tsx +++ b/src/@/components/BookmarkForm.tsx @@ -24,9 +24,12 @@ import { checkLinkExists, postLink } from '../lib/actions/links.ts'; import { AxiosError } from 'axios'; import { toast } from '../../hooks/use-toast.ts'; import { Toaster } from './ui/Toaster.tsx'; -import { getCollections } from '../lib/actions/collections.ts'; +import { + getCollections, + createCollection, +} from '../lib/actions/collections.ts'; import { getTags } from '../lib/actions/tags.ts'; -import { ExternalLink, X } from 'lucide-react'; +import { ExternalLink, X, Plus } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from './ui/Popover.tsx'; import { CaretSortIcon } from '@radix-ui/react-icons'; import { @@ -44,6 +47,10 @@ const BookmarkForm = () => { const [openCollections, setOpenCollections] = useState(false); const [uploadImage, setUploadImage] = useState(false); const [state, setState] = useState<'capturing' | 'uploading' | null>(null); + const [pendingNewCollection, setPendingNewCollection] = useState< + string | null + >(null); + const [searchValue, setSearchValue] = useState(''); const [isConfigured, setIsConfigured] = useState(false); const [isDuplicate, setIsDuplicate] = useState(false); @@ -82,10 +89,29 @@ const BookmarkForm = () => { const { mutate: onSubmit, isLoading } = useMutation({ mutationFn: async (values: bookmarkFormValues) => { + let finalValues = values; + + if (pendingNewCollection) { + const response = await createCollection( + config?.baseUrl as string, + config?.apiKey as string, + pendingNewCollection + ); + const newCollection = response.data.response; + finalValues = { + ...values, + collection: { + id: newCollection.id, + ownerId: newCollection.ownerId, + name: newCollection.name, + }, + }; + } + await postLink( config?.baseUrl as string, uploadImage, - values, + finalValues, setState, config?.apiKey as string ); @@ -94,19 +120,20 @@ const BookmarkForm = () => { }, onError: (error) => { console.error(error); + const isCollectionCreationError = pendingNewCollection !== null; + const fallbackMessage = isCollectionCreationError + ? 'There was an error while trying to create the collection. Please try again.' + : 'There was an error while trying to save the link. Please try again.'; if (error instanceof AxiosError) { toast({ title: 'Error', - description: - error.response?.data.response || - 'There was an error while trying to save the link. Please try again.', + description: error.response?.data.response || fallbackMessage, variant: 'destructive', }); } else { toast({ title: 'Error', - description: - 'There was an error while trying to save the link. Please try again.', + description: fallbackMessage, variant: 'destructive', }); } @@ -151,7 +178,7 @@ const BookmarkForm = () => { }; setTabInformation(); - }, []); + }, [form]); const { handleSubmit, control } = form; @@ -195,6 +222,19 @@ const BookmarkForm = () => { enabled: isConfigured, }); + const trimmedSearch = searchValue.trim(); + const hasExactMatch = + trimmedSearch && + collections?.some( + (c: { name: string }) => + c.name.toLowerCase() === trimmedSearch.toLowerCase() + ); + const hasPartialMatch = + trimmedSearch && + collections?.some((c: { name: string }) => + c.name.toLowerCase().includes(trimmedSearch.toLowerCase()) + ); + const { isLoading: loadingTags, data: tags, @@ -214,6 +254,125 @@ const BookmarkForm = () => { enabled: isConfigured, }); + const renderCollectionCommand = (extraClassName?: string) => ( + { + if (value.toLowerCase().includes(search.trim().toLowerCase())) return 1; + return 0; + }} + > + + + {loadingCollections ? ( +

Loading...

+ ) : ( + <> + + {!trimmedSearch || hasPartialMatch ? ( + 'No collection found.' + ) : ( +
+ No collection found. + +
+ )} +
+ {Array.isArray(collections) && ( + + {isLoading ? ( + { + form.setValue('collection', { name: 'Unorganized' }); + setPendingNewCollection(null); + setSearchValue(''); + setOpenCollections(false); + }} + > + Unorganized + + ) : ( + collections?.map( + (collection: { + name: string; + id: number; + ownerId: number; + pathname: string; + }) => ( + { + form.setValue('collection', { + ownerId: collection.ownerId, + id: collection.id, + name: collection.name, + }); + setPendingNewCollection(null); + setSearchValue(''); + setOpenCollections(false); + }} + > +

{collection.name}

+

+ {collection.pathname} +

+
+ ) + ) + )} +
+ )} + + )} +
+ ); + + const renderCreateFooter = () => { + if (!trimmedSearch || hasExactMatch || !hasPartialMatch) return null; + return ( +
{ + form.setValue('collection', { name: trimmedSearch }); + setPendingNewCollection(trimmedSearch); + setSearchValue(''); + setOpenCollections(false); + }} + > + +
+ + Create "{trimmedSearch}" + + + New collection + +
+
+ ); + }; + return (
@@ -235,7 +394,10 @@ const BookmarkForm = () => {
{ + setOpenCollections(open); + if (!open) setSearchValue(''); + }} > @@ -247,14 +409,16 @@ const BookmarkForm = () => { 'w-full justify-between bg-neutral-100 dark:bg-neutral-900' } > - {loadingCollections - ? 'Unorganized' - : field.value?.name - ? collections?.find( - (collection: { name: string }) => - collection.name === field.value?.name - )?.name || form.getValues('collection')?.name - : 'Select a collection...'} + {pendingNewCollection + ? `${pendingNewCollection} (new)` + : loadingCollections + ? 'Unorganized' + : field.value?.name + ? collections?.find( + (collection: { name: string }) => + collection.name === field.value?.name + )?.name || form.getValues('collection')?.name + : 'Select a collection...'} @@ -262,138 +426,36 @@ const BookmarkForm = () => { {!openOptions && openCollections ? (
- - - - {loadingCollections ? ( -

- Loading... -

- ) : ( - <> - No Collection found. - {Array.isArray(collections) && ( - - {isLoading ? ( - { - form.setValue('collection', { - name: 'Unorganized', - }); - setOpenCollections(false); - }} - > - Unorganized - - ) : ( - collections?.map( - (collection: { - name: string; - id: number; - ownerId: number; - pathname: string; - }) => ( - { - form.setValue('collection', { - ownerId: collection.ownerId, - id: collection.id, - name: collection.name, - }); - setOpenCollections(false); - }} - > -

{collection.name}

-

- {collection.pathname} -

-
- ) - ) - )} -
- )} - - )} -
+ {renderCollectionCommand('rounded-none')} + {renderCreateFooter()}
) : openOptions && openCollections ? ( - - - No Collection found. - {Array.isArray(collections) && ( - - {isLoading ? ( - { - form.setValue('collection', { - name: 'Unorganized', - }); - setOpenCollections(false); - }} - > - Unorganized - - ) : ( - collections?.map( - (collection: { - name: string; - id: number; - ownerId: number; - pathname: string; - }) => ( - { - form.setValue('collection', { - ownerId: collection.ownerId, - id: collection.id, - name: collection.name, - }); - setOpenCollections(false); - }} - > -

{collection.name}

-

- {collection.pathname} -

-
- ) - ) - )} -
- )} -
+ {renderCollectionCommand()} + {renderCreateFooter()}
) : undefined}
diff --git a/src/@/lib/actions/collections.ts b/src/@/lib/actions/collections.ts index 27754a5..436e940 100644 --- a/src/@/lib/actions/collections.ts +++ b/src/@/lib/actions/collections.ts @@ -60,3 +60,26 @@ export async function getCollections(baseUrl: string, apiKey: string) { }, }; } + +export async function createCollection( + baseUrl: string, + apiKey: string, + name: string +) { + const url = `${baseUrl}/api/v1/collections`; + + const response = await axios.post< + { response: ResponseCollections } + >( + url, + { name }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + } + ); + + return response; +}