1+ "use client" ;
2+
3+ import React , { useState , useRef , useEffect } from "react" ;
4+ import { createPortal } from "react-dom" ;
5+ import { X , Upload , Save } from "lucide-react" ;
6+ import { AnnouncementPage } from "./types" ;
7+ import { createClientClientComponent } from "@/utils/supabase/client" ;
8+ import AnnouncementRichTextEditor from "./AnnouncementRichTextEditor" ;
9+
10+ interface AnnouncementEditorProps {
11+ page : AnnouncementPage ;
12+ isOpen : boolean ;
13+ onClose : ( ) => void ;
14+ onSave : ( updatedPage : AnnouncementPage ) => Promise < void > ;
15+ }
16+
17+ export const AnnouncementEditor : React . FC < AnnouncementEditorProps > = ( {
18+ page,
19+ isOpen,
20+ onClose,
21+ onSave,
22+ } ) => {
23+ const [ title , setTitle ] = useState ( page . title ) ;
24+ const [ content , setContent ] = useState ( page . content ) ;
25+ const [ bannerImage , setBannerImage ] = useState ( page . banner_image ) ;
26+ const [ imageFile , setImageFile ] = useState < File | null > ( null ) ;
27+ const [ imagePreview , setImagePreview ] = useState < string | null > ( null ) ;
28+ const [ isSaving , setIsSaving ] = useState ( false ) ;
29+ const [ isUploading , setIsUploading ] = useState ( false ) ;
30+ const fileInputRef = useRef < HTMLInputElement > ( null ) ;
31+ const supabase = createClientClientComponent ( ) ;
32+
33+ // Reset state when page changes
34+ useEffect ( ( ) => {
35+ setTitle ( page . title ) ;
36+ setContent ( page . content ) ;
37+ setBannerImage ( page . banner_image ) ;
38+ setImageFile ( null ) ;
39+ setImagePreview ( null ) ;
40+ } , [ page ] ) ;
41+
42+ // Handle image file selection
43+ const handleImageSelect = ( e : React . ChangeEvent < HTMLInputElement > ) => {
44+ const file = e . target . files ?. [ 0 ] ;
45+ if ( file ) {
46+ // Validate file type
47+ if ( ! file . type . startsWith ( "image/" ) ) {
48+ alert ( "Please select an image file" ) ;
49+ return ;
50+ }
51+
52+ // Validate file size (max 5MB)
53+ if ( file . size > 5 * 1024 * 1024 ) {
54+ alert ( "Image size should be less than 5MB" ) ;
55+ return ;
56+ }
57+
58+ setImageFile ( file ) ;
59+
60+ // Create preview
61+ const reader = new FileReader ( ) ;
62+ reader . onloadend = ( ) => {
63+ setImagePreview ( reader . result as string ) ;
64+ } ;
65+ reader . readAsDataURL ( file ) ;
66+ }
67+ } ;
68+
69+ // Upload image to Supabase storage
70+ const uploadImage = async ( ) : Promise < string | null > => {
71+ if ( ! imageFile ) return null ;
72+ if ( ! supabase ) throw new Error ( 'Supabase client not initialized' ) ;
73+
74+ try {
75+ setIsUploading ( true ) ;
76+
77+ // Create a unique filename
78+ const fileExt = imageFile . name . split ( "." ) . pop ( ) ;
79+ const fileName = `${ page . category } -${ Date . now ( ) } .${ fileExt } ` ;
80+ const filePath = `announcementBanner/${ fileName } ` ;
81+
82+ // Upload to Supabase storage
83+ const { error : uploadError } = await supabase . storage
84+ . from ( "codebility" )
85+ . upload ( filePath , imageFile , {
86+ cacheControl : "3600" ,
87+ upsert : true ,
88+ } ) ;
89+
90+ if ( uploadError ) throw uploadError ;
91+
92+ // Return the public URL
93+ const publicUrl = `${ process . env . NEXT_PUBLIC_SUPABASE_URL } /storage/v1/object/public/codebility/${ filePath } ` ;
94+
95+ return publicUrl ;
96+ } catch ( error ) {
97+ console . error ( "Error uploading image:" , error ) ;
98+ throw error ;
99+ } finally {
100+ setIsUploading ( false ) ;
101+ }
102+ } ;
103+
104+ // Handle save
105+ const handleSave = async ( ) => {
106+ try {
107+ setIsSaving ( true ) ;
108+
109+ let finalBannerImage = bannerImage ;
110+
111+ // Upload new image if selected
112+ if ( imageFile ) {
113+ const uploadedUrl = await uploadImage ( ) ;
114+ if ( uploadedUrl ) {
115+ finalBannerImage = uploadedUrl ;
116+ } else {
117+ throw new Error ( "Failed to upload image" ) ;
118+ }
119+ }
120+
121+ // Create updated page object
122+ const updatedPage : AnnouncementPage = {
123+ ...page ,
124+ title,
125+ content,
126+ banner_image : finalBannerImage ,
127+ last_updated : new Date ( ) . toISOString ( ) ,
128+ } ;
129+
130+ // Call the onSave prop
131+ await onSave ( updatedPage ) ;
132+
133+ // Close the editor
134+ onClose ( ) ;
135+ } catch ( error ) {
136+ console . error ( "Error saving announcement:" , error ) ;
137+ throw error ;
138+ } finally {
139+ setIsSaving ( false ) ;
140+ }
141+ } ;
142+
143+ // Handle cancel
144+ const handleCancel = ( ) => {
145+ setTitle ( page . title ) ;
146+ setContent ( page . content ) ;
147+ setBannerImage ( page . banner_image ) ;
148+ setImageFile ( null ) ;
149+ setImagePreview ( null ) ;
150+ onClose ( ) ;
151+ } ;
152+
153+ if ( ! isOpen ) return null ;
154+
155+ return createPortal (
156+ < >
157+ { /* Backdrop */ }
158+ < div
159+ className = "fixed inset-0 z-[150] bg-black/50 backdrop-blur-sm"
160+ onClick = { handleCancel }
161+ aria-hidden = "true"
162+ />
163+
164+ { /* Editor Modal */ }
165+ < div
166+ className = "fixed inset-0 z-[150] flex items-center justify-center p-4 pointer-events-none"
167+ role = "dialog"
168+ aria-modal = "true"
169+ aria-labelledby = "editor-modal-title"
170+ >
171+ < div className = "flex h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-900 pointer-events-auto" >
172+ { /* Header */ }
173+ < div className = "flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800" >
174+ < h2
175+ id = "editor-modal-title"
176+ className = "text-2xl font-bold text-gray-900 dark:text-gray-100"
177+ >
178+ Edit Announcement
179+ </ h2 >
180+ < button
181+ onClick = { handleCancel }
182+ className = "rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
183+ aria-label = "Close editor"
184+ >
185+ < X className = "h-6 w-6" />
186+ </ button >
187+ </ div >
188+
189+ { /* Editor Content */ }
190+ < div className = "flex-1 overflow-y-auto p-6 space-y-6" >
191+ { /* Title Input */ }
192+ < div >
193+ < label
194+ htmlFor = "announcement-title"
195+ className = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
196+ >
197+ Title
198+ </ label >
199+ < input
200+ id = "announcement-title"
201+ type = "text"
202+ value = { title }
203+ onChange = { ( e ) => setTitle ( e . target . value ) }
204+ className = "w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
205+ placeholder = "Enter announcement title"
206+ />
207+ </ div >
208+
209+ { /* Banner Image Upload */ }
210+ < div >
211+ < label className = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" >
212+ Banner Image
213+ </ label >
214+
215+ { /* Current/Preview Image */ }
216+ < div className = "mb-3" >
217+ < img
218+ src = { imagePreview || bannerImage }
219+ alt = "Banner preview"
220+ className = "w-full h-48 object-cover rounded-lg border border-gray-200 dark:border-gray-700"
221+ />
222+ </ div >
223+
224+ { /* Upload Button */ }
225+ < div className = "flex items-center gap-3" >
226+ < input
227+ ref = { fileInputRef }
228+ type = "file"
229+ accept = "image/*"
230+ onChange = { handleImageSelect }
231+ className = "hidden"
232+ />
233+ < button
234+ onClick = { ( ) => fileInputRef . current ?. click ( ) }
235+ disabled = { isUploading }
236+ className = "flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
237+ >
238+ < Upload className = "h-4 w-4" />
239+ { isUploading ? "Uploading..." : "Change Image" }
240+ </ button >
241+ { imageFile && (
242+ < span className = "text-sm text-gray-600 dark:text-gray-400" >
243+ { imageFile . name }
244+ </ span >
245+ ) }
246+ </ div >
247+ < p className = "mt-2 text-xs text-gray-500 dark:text-gray-400" >
248+ Recommended: 1200x400px, Max size: 5MB
249+ </ p >
250+ </ div >
251+
252+ { /* Rich Text Content Editor with Tiptap */ }
253+ < div >
254+ < label className = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" >
255+ Content
256+ </ label >
257+ < AnnouncementRichTextEditor
258+ value = { content }
259+ onChange = { setContent }
260+ />
261+ </ div >
262+ </ div >
263+
264+ { /* Footer */ }
265+ < div className = "flex items-center justify-end gap-3 border-t border-gray-200 p-6 dark:border-gray-800" >
266+ < button
267+ onClick = { handleCancel }
268+ disabled = { isSaving || isUploading }
269+ className = "rounded-lg px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
270+ >
271+ Cancel
272+ </ button >
273+ < button
274+ onClick = { handleSave }
275+ disabled = { isSaving || isUploading || ! title . trim ( ) || ! content . trim ( ) }
276+ className = "flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
277+ >
278+ { isSaving ? (
279+ < >
280+ < div className = "animate-spin rounded-full h-4 w-4 border-b-2 border-white" > </ div >
281+ Saving...
282+ </ >
283+ ) : (
284+ < >
285+ < Save className = "h-4 w-4" />
286+ Save Changes
287+ </ >
288+ ) }
289+ </ button >
290+ </ div >
291+ </ div >
292+ </ div >
293+ </ > ,
294+ document . body
295+ ) ;
296+ } ;
0 commit comments