11import {
22 BookText ,
3+ Bookmark ,
34 CheckIcon ,
45 ChevronsUpDown ,
56 Globe ,
@@ -82,6 +83,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
8283 const [ open , setOpen ] = useState ( false ) ;
8384 const [ viewMode , setViewMode ] = useState < "detailed" | "icon" > ( "detailed" ) ;
8485 const [ selectedTags , setSelectedTags ] = useState < string [ ] > ( [ ] ) ;
86+ const [ showBookmarksOnly , setShowBookmarksOnly ] = useState ( false ) ;
8587 const [ customBaseUrl , setCustomBaseUrl ] = useState < string | undefined > ( ( ) => {
8688 // Try to get from props first, then localStorage
8789 if ( baseUrl ) return baseUrl ;
@@ -122,8 +124,45 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
122124 enabled : open ,
123125 } ,
124126 ) ;
127+
128+ const { data : bookmarkIds = [ ] , isLoading : isLoadingBookmarks } =
129+ api . user . getBookmarkedTemplates . useQuery ( undefined , {
130+ enabled : open ,
131+ } ) ;
132+
125133 const utils = api . useUtils ( ) ;
126134
135+ const { mutateAsync : toggleBookmark } =
136+ api . user . toggleTemplateBookmark . useMutation ( {
137+ onMutate : async ( { templateId } ) => {
138+ await utils . user . getBookmarkedTemplates . cancel ( ) ;
139+ const previousBookmarks = utils . user . getBookmarkedTemplates . getData ( ) ;
140+
141+ utils . user . getBookmarkedTemplates . setData ( undefined , ( old = [ ] ) => {
142+ if ( old . includes ( templateId ) ) {
143+ return old . filter ( ( id ) => id !== templateId ) ;
144+ }
145+ return [ ...old , templateId ] ;
146+ } ) ;
147+
148+ return { previousBookmarks } ;
149+ } ,
150+ onError : ( err , variables , context ) => {
151+ if ( context ?. previousBookmarks ) {
152+ utils . user . getBookmarkedTemplates . setData (
153+ undefined ,
154+ context . previousBookmarks ,
155+ ) ;
156+ }
157+ toast . error ( "Failed to update bookmark" ) ;
158+ } ,
159+ onSuccess : ( data ) => {
160+ toast . success (
161+ data . isBookmarked ? "Added to bookmarks" : "Removed from bookmarks" ,
162+ ) ;
163+ } ,
164+ } ) ;
165+
127166 const [ serverId , setServerId ] = useState < string | undefined > ( undefined ) ;
128167 const { mutateAsync, isPending, error, isError } =
129168 api . compose . deployTemplate . useMutation ( ) ;
@@ -137,7 +176,9 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
137176 query === "" ||
138177 template . name . toLowerCase ( ) . includes ( query . toLowerCase ( ) ) ||
139178 template . description . toLowerCase ( ) . includes ( query . toLowerCase ( ) ) ;
140- return matchesTags && matchesQuery ;
179+ const matchesBookmarks =
180+ ! showBookmarksOnly || bookmarkIds . includes ( template . id ) ;
181+ return matchesTags && matchesQuery && matchesBookmarks ;
141182 } ) || [ ] ;
142183
143184 const hasServers = servers && servers . length > 0 ;
@@ -146,6 +187,14 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
146187 // Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
147188 const shouldShowServerDropdown = hasServers ;
148189
190+ const handleToggleBookmark = async (
191+ e : React . MouseEvent ,
192+ templateId : string ,
193+ ) => {
194+ e . stopPropagation ( ) ;
195+ await toggleBookmark ( { templateId } ) ;
196+ } ;
197+
149198 return (
150199 < Dialog open = { open } onOpenChange = { setOpen } >
151200 < DialogTrigger className = "w-full" >
@@ -243,6 +292,20 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
243292 </ Command >
244293 </ PopoverContent >
245294 </ Popover >
295+ < Button
296+ variant = { showBookmarksOnly ? "default" : "outline" }
297+ size = "icon"
298+ onClick = { ( ) => setShowBookmarksOnly ( ! showBookmarksOnly ) }
299+ className = "h-9 w-9 flex-shrink-0"
300+ disabled = { isLoadingBookmarks }
301+ >
302+ < Bookmark
303+ className = { cn (
304+ "size-4" ,
305+ showBookmarksOnly && "fill-current" ,
306+ ) }
307+ />
308+ </ Button >
246309 < Button
247310 size = "icon"
248311 onClick = { ( ) =>
@@ -299,11 +362,19 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
299362 </ div >
300363 </ div >
301364 ) : templates . length === 0 ? (
302- < div className = "flex justify-center items-center w-full gap-2 min-h-[50vh]" >
365+ < div className = "flex flex-col justify-center items-center w-full gap-2 min-h-[50vh]" >
303366 < SearchIcon className = "text-muted-foreground size-6" />
304367 < div className = "text-xl font-medium text-muted-foreground" >
305- No templates found
368+ { showBookmarksOnly
369+ ? "No bookmarked templates found"
370+ : "No templates found" }
306371 </ div >
372+ { showBookmarksOnly && (
373+ < p className = "text-sm text-muted-foreground" >
374+ Click the bookmark icon on templates to add them to
375+ bookmarks
376+ </ p >
377+ ) }
307378 </ div >
308379 ) : (
309380 < div
@@ -323,9 +394,25 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
323394 viewMode === "detailed" && "h-[400px]" ,
324395 ) }
325396 >
326- < Badge className = "absolute top-2 right-2" variant = "blue" >
327- { template ?. version }
328- </ Badge >
397+ < div className = "absolute top-2 left-2 z-10" >
398+ < Button
399+ variant = "ghost"
400+ size = "icon"
401+ className = "h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
402+ onClick = { ( e ) => handleToggleBookmark ( e , template . id ) }
403+ >
404+ < Bookmark
405+ className = { cn (
406+ "size-4" ,
407+ bookmarkIds . includes ( template . id ) &&
408+ "fill-yellow-400 text-yellow-400" ,
409+ ) }
410+ />
411+ </ Button >
412+ </ div >
413+ < div className = "absolute top-2 right-2" >
414+ < Badge variant = "blue" > { template ?. version } </ Badge >
415+ </ div >
329416 < div
330417 className = { cn (
331418 "flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30" ,
0 commit comments