1+ import { useTemplates } from '@/hooks/use-templates' ;
2+ import { useTRPC } from '@/providers/query-provider' ;
3+ import { Editor } from '@tiptap/react' ;
4+ import { Button } from '@/components/ui/button' ;
5+ import {
6+ DropdownMenu ,
7+ DropdownMenuContent ,
8+ DropdownMenuItem ,
9+ DropdownMenuSub ,
10+ DropdownMenuSubContent ,
11+ DropdownMenuSubTrigger ,
12+ DropdownMenuTrigger ,
13+ } from '@/components/ui/dropdown-menu' ;
14+ import { toast } from 'sonner' ;
15+ import { useMutation , useQueryClient } from '@tanstack/react-query' ;
16+ import { FileText , Save , Trash2 } from 'lucide-react' ;
17+ import React , { useState , useMemo , useDeferredValue , useCallback , startTransition } from 'react' ;
18+ import {
19+ Dialog ,
20+ DialogContent ,
21+ DialogFooter ,
22+ DialogHeader ,
23+ DialogTitle ,
24+ } from '@/components/ui/dialog' ;
25+ import { Input } from '@/components/ui/input' ;
26+ import { TRPCClientError } from '@trpc/client' ;
27+
28+ type RecipientField = 'to' | 'cc' | 'bcc' ;
29+
30+ type Template = {
31+ id : string ;
32+ name : string ;
33+ subject ?: string | null ;
34+ body ?: string | null ;
35+ to ?: string [ ] | null ;
36+ cc ?: string [ ] | null ;
37+ bcc ?: string [ ] | null ;
38+ } ;
39+
40+ type TemplatesQueryData = {
41+ templates : Template [ ] ;
42+ } | undefined ;
43+
44+ interface TemplateButtonProps {
45+ editor : Editor | null ;
46+ subject : string ;
47+ setSubject : ( value : string ) => void ;
48+ to : string [ ] ;
49+ cc : string [ ] ;
50+ bcc : string [ ] ;
51+ setRecipients : ( field : RecipientField , value : string [ ] ) => void ;
52+ }
53+
54+ const TemplateButtonComponent : React . FC < TemplateButtonProps > = ( {
55+ editor,
56+ subject,
57+ setSubject,
58+ to,
59+ cc,
60+ bcc,
61+ setRecipients,
62+ } ) => {
63+ const trpc = useTRPC ( ) ;
64+ const queryClient = useQueryClient ( ) ;
65+ const { data } = useTemplates ( ) ;
66+
67+ const templates : Template [ ] = data ?. templates ?? [ ] ;
68+
69+ const [ menuOpen , setMenuOpen ] = useState ( false ) ;
70+ const [ isSaving , setIsSaving ] = useState ( false ) ;
71+ const [ saveDialogOpen , setSaveDialogOpen ] = useState ( false ) ;
72+ const [ templateName , setTemplateName ] = useState ( '' ) ;
73+ const [ search , setSearch ] = useState ( '' ) ;
74+
75+ const deferredSearch = useDeferredValue ( search ) ;
76+
77+ const filteredTemplates = useMemo ( ( ) => {
78+ if ( ! deferredSearch . trim ( ) ) return templates ;
79+ return templates . filter ( ( t ) =>
80+ t . name . toLowerCase ( ) . includes ( deferredSearch . toLowerCase ( ) ) ,
81+ ) ;
82+ } , [ deferredSearch , templates ] ) ;
83+
84+ const { mutateAsync : createTemplate } = useMutation ( trpc . templates . create . mutationOptions ( ) ) ;
85+ const { mutateAsync : deleteTemplateMutation } = useMutation (
86+ trpc . templates . delete . mutationOptions ( ) ,
87+ ) ;
88+
89+ const handleSaveTemplate = async ( ) => {
90+ if ( ! editor ) return ;
91+ if ( ! templateName . trim ( ) ) {
92+ toast . error ( 'Please provide a name' ) ;
93+ return ;
94+ }
95+
96+ setIsSaving ( true ) ;
97+ try {
98+ const newTemplate = await createTemplate ( {
99+ name : templateName . trim ( ) ,
100+ subject : subject || '' ,
101+ body : editor . getHTML ( ) ,
102+ to : to . length ? to : undefined ,
103+ cc : cc . length ? cc : undefined ,
104+ bcc : bcc . length ? bcc : undefined ,
105+ } ) ;
106+ queryClient . setQueryData ( trpc . templates . list . queryKey ( ) , ( old : TemplatesQueryData ) => {
107+ if ( ! old ?. templates ) return old ;
108+ return {
109+ templates : [ newTemplate . template , ...old . templates ] ,
110+ } ;
111+ } ) ;
112+ toast . success ( 'Template saved' ) ;
113+ setTemplateName ( '' ) ;
114+ setSaveDialogOpen ( false ) ;
115+ } catch ( error ) {
116+ if ( error instanceof TRPCClientError ) {
117+ toast . error ( error . message ) ;
118+ } else {
119+ toast . error ( 'Failed to save template' ) ;
120+ }
121+ } finally {
122+ setIsSaving ( false ) ;
123+ }
124+ } ;
125+
126+ const handleApplyTemplate = useCallback ( ( template : Template ) => {
127+ if ( ! editor ) return ;
128+ startTransition ( ( ) => {
129+ if ( template . subject ) setSubject ( template . subject ) ;
130+ if ( template . body ) editor . commands . setContent ( template . body , false ) ;
131+ if ( template . to ) setRecipients ( 'to' , template . to ) ;
132+ if ( template . cc ) setRecipients ( 'cc' , template . cc ) ;
133+ if ( template . bcc ) setRecipients ( 'bcc' , template . bcc ) ;
134+ } ) ;
135+ } , [ editor , setSubject , setRecipients ] ) ;
136+
137+ const handleDeleteTemplate = useCallback (
138+ async ( templateId : string ) => {
139+ try {
140+ await deleteTemplateMutation ( { id : templateId } ) ;
141+ await queryClient . invalidateQueries ( {
142+ queryKey : trpc . templates . list . queryKey ( ) ,
143+ } ) ;
144+ toast . success ( 'Template deleted' ) ;
145+ } catch ( err ) {
146+ if ( err instanceof TRPCClientError ) {
147+ toast . error ( err . message ) ;
148+ } else {
149+ toast . error ( 'Failed to delete template' ) ;
150+ }
151+ }
152+ } ,
153+ [ deleteTemplateMutation , queryClient , trpc . templates . list ] ,
154+ ) ;
155+
156+ return (
157+ < >
158+ < DropdownMenu open = { menuOpen } onOpenChange = { setMenuOpen } >
159+ < DropdownMenuTrigger asChild >
160+ < Button size = { 'xs' } variant = { 'secondary' } disabled = { isSaving } >
161+ Templates
162+ </ Button >
163+ </ DropdownMenuTrigger >
164+ < DropdownMenuContent className = "z-[99999] w-60" align = "start" sideOffset = { 6 } >
165+ < DropdownMenuItem
166+ onSelect = { ( ) => {
167+ setMenuOpen ( false ) ;
168+ setSaveDialogOpen ( true ) ;
169+ } }
170+ disabled = { isSaving }
171+ >
172+ < Save className = "mr-2 h-3.5 w-3.5" /> Save current as template
173+ </ DropdownMenuItem >
174+ { templates . length > 0 ? (
175+ < DropdownMenuSub >
176+ < DropdownMenuSubTrigger >
177+ < FileText className = "mr-2 h-3.5 w-3.5" /> Use template
178+ </ DropdownMenuSubTrigger >
179+ < DropdownMenuSubContent className = "z-[99999] w-60" >
180+ < div className = "p-2 border-b border-border sticky top-0 bg-background" >
181+ < Input
182+ placeholder = "Search..."
183+ value = { search }
184+ onChange = { ( e ) => setSearch ( e . target . value ) }
185+ className = "h-8 text-sm"
186+ autoFocus
187+ />
188+ </ div >
189+ < div className = "max-h-30 overflow-y-auto" >
190+ { filteredTemplates . map ( ( t : Template ) => (
191+ < DropdownMenuItem
192+ key = { t . id }
193+ className = "flex items-center justify-between gap-2"
194+ onClick = { ( ) => handleApplyTemplate ( t ) }
195+ >
196+ < span className = "flex-1 truncate text-left" > { t . name } </ span >
197+ < button
198+ className = "p-0.5 text-muted-foreground hover:text-destructive"
199+ onClick = { ( e ) => {
200+ e . stopPropagation ( ) ;
201+ setMenuOpen ( false ) ;
202+ toast ( `Delete template "${ t . name } "?` , {
203+ duration : 10000 ,
204+ action : {
205+ label : 'Delete' ,
206+ onClick : ( ) => handleDeleteTemplate ( t . id ) ,
207+ } ,
208+ className : 'pointer-events-auto' ,
209+ style : {
210+ pointerEvents : 'auto' ,
211+ } ,
212+ } ) ;
213+ } }
214+ >
215+ < Trash2 className = "h-3.5 w-3.5" />
216+ </ button >
217+ </ DropdownMenuItem >
218+ ) ) }
219+ { filteredTemplates . length === 0 && (
220+ < div className = "p-2 text-xs text-muted-foreground" > No templates</ div >
221+ ) }
222+ </ div >
223+ </ DropdownMenuSubContent >
224+ </ DropdownMenuSub >
225+ ) : null }
226+ </ DropdownMenuContent >
227+ </ DropdownMenu >
228+
229+ < Dialog open = { saveDialogOpen } onOpenChange = { setSaveDialogOpen } >
230+ < DialogContent showOverlay >
231+ < DialogHeader >
232+ < DialogTitle > Save as Template</ DialogTitle >
233+ </ DialogHeader >
234+ < div className = "py-4 space-y-2" >
235+ < Input
236+ placeholder = "Template name"
237+ value = { templateName }
238+ onChange = { ( e ) => setTemplateName ( e . target . value ) }
239+ autoFocus
240+ />
241+ </ div >
242+ < DialogFooter className = "flex justify-end gap-2" >
243+ < Button
244+ variant = "ghost"
245+ size = "sm"
246+ onClick = { ( ) => setSaveDialogOpen ( false ) }
247+ >
248+ Cancel
249+ </ Button >
250+ < Button size = "sm" onClick = { handleSaveTemplate } disabled = { isSaving } >
251+ Save
252+ </ Button >
253+ </ DialogFooter >
254+ </ DialogContent >
255+ </ Dialog >
256+ </ >
257+ ) ;
258+ } ;
259+
260+ export const TemplateButton = React . memo ( TemplateButtonComponent ) ;
0 commit comments