11"use client" ;
22
3- import { useState , useEffect } from "react" ;
3+ import { useState , useEffect , useRef } from "react" ;
44import { useParams } from "next/navigation" ;
55import { useQuery , useMutation } from "convex/react" ;
66import { api } from "@opencom/convex" ;
@@ -27,8 +27,16 @@ export default function ArticleEditorPage() {
2727 const [ audienceRules , setAudienceRules ] = useState < InlineAudienceRule | null > ( null ) ;
2828 const [ isSaving , setIsSaving ] = useState ( false ) ;
2929 const [ hasChanges , setHasChanges ] = useState ( false ) ;
30+ const [ isUploadingImage , setIsUploadingImage ] = useState ( false ) ;
31+ const [ assetError , setAssetError ] = useState < string | null > ( null ) ;
32+ const [ removingAssetId , setRemovingAssetId ] = useState < Id < "articleAssets" > | null > ( null ) ;
33+ const uploadInputRef = useRef < HTMLInputElement | null > ( null ) ;
3034
3135 const article = useQuery ( api . articles . get , { id : articleId } ) ;
36+ const articleAssets = useQuery (
37+ api . articles . listAssets ,
38+ activeWorkspace ?. _id ? { workspaceId : activeWorkspace . _id , articleId } : "skip"
39+ ) ;
3240 const collections = useQuery (
3341 api . collections . listHierarchy ,
3442 activeWorkspace ?. _id ? { workspaceId : activeWorkspace . _id } : "skip"
@@ -37,6 +45,9 @@ export default function ArticleEditorPage() {
3745 const updateArticle = useMutation ( api . articles . update ) ;
3846 const publishArticle = useMutation ( api . articles . publish ) ;
3947 const unpublishArticle = useMutation ( api . articles . unpublish ) ;
48+ const generateAssetUploadUrl = useMutation ( api . articles . generateAssetUploadUrl ) ;
49+ const saveAsset = useMutation ( api . articles . saveAsset ) ;
50+ const deleteAsset = useMutation ( api . articles . deleteAsset ) ;
4051
4152 useEffect ( ( ) => {
4253 if ( article ) {
@@ -98,6 +109,80 @@ export default function ArticleEditorPage() {
98109 setHasChanges ( true ) ;
99110 } ;
100111
112+ const appendAssetReference = ( reference : string , altText : string ) => {
113+ const safeAlt = altText . replace ( / \. ( p n g | j p e ? g | g i f | w e b p | a v i f ) $ / i, "" ) . replace ( / [ - _ ] + / g, " " ) ;
114+ const snippet = `\n\n\n` ;
115+ setContent ( ( current ) => `${ current } ${ snippet } ` ) ;
116+ setHasChanges ( true ) ;
117+ } ;
118+
119+ const handleImageUpload = async ( event : React . ChangeEvent < HTMLInputElement > ) => {
120+ const file = event . target . files ?. [ 0 ] ;
121+ if ( ! file || ! activeWorkspace ?. _id ) {
122+ return ;
123+ }
124+
125+ setAssetError ( null ) ;
126+ setIsUploadingImage ( true ) ;
127+ try {
128+ const uploadUrl = await generateAssetUploadUrl ( { workspaceId : activeWorkspace . _id } ) ;
129+ const uploadResponse = await fetch ( uploadUrl , {
130+ method : "POST" ,
131+ headers : {
132+ "Content-Type" : file . type || "application/octet-stream" ,
133+ } ,
134+ body : file ,
135+ } ) ;
136+ if ( ! uploadResponse . ok ) {
137+ throw new Error ( "Image upload failed" ) ;
138+ }
139+
140+ const uploadPayload = ( await uploadResponse . json ( ) ) as { storageId ?: Id < "_storage" > } ;
141+ if ( ! uploadPayload . storageId ) {
142+ throw new Error ( "Upload response missing storage id" ) ;
143+ }
144+
145+ const savedAsset = await saveAsset ( {
146+ workspaceId : activeWorkspace . _id ,
147+ articleId,
148+ storageId : uploadPayload . storageId ,
149+ fileName : file . name ,
150+ } ) ;
151+ appendAssetReference ( savedAsset . reference , savedAsset . fileName ?? file . name ) ;
152+ } catch ( error ) {
153+ console . error ( "Failed to upload article image:" , error ) ;
154+ setAssetError ( error instanceof Error ? error . message : "Failed to upload image." ) ;
155+ } finally {
156+ setIsUploadingImage ( false ) ;
157+ if ( uploadInputRef . current ) {
158+ uploadInputRef . current . value = "" ;
159+ }
160+ }
161+ } ;
162+
163+ const handleDeleteAsset = async ( assetId : Id < "articleAssets" > ) => {
164+ if ( ! activeWorkspace ?. _id ) {
165+ return ;
166+ }
167+ setAssetError ( null ) ;
168+ setRemovingAssetId ( assetId ) ;
169+ try {
170+ await deleteAsset ( {
171+ workspaceId : activeWorkspace . _id ,
172+ assetId,
173+ } ) ;
174+ } catch ( error ) {
175+ console . error ( "Failed to delete article asset:" , error ) ;
176+ setAssetError (
177+ error instanceof Error
178+ ? error . message
179+ : "Failed to delete image. Remove markdown references first."
180+ ) ;
181+ } finally {
182+ setRemovingAssetId ( null ) ;
183+ }
184+ } ;
185+
101186 if ( ! article ) {
102187 return (
103188 < div className = "flex items-center justify-center h-screen" >
@@ -171,7 +256,7 @@ export default function ArticleEditorPage() {
171256 onChange = { ( e ) => handleCollectionChange ( e . target . value ) }
172257 className = "w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
173258 >
174- < option value = "" > Uncategorized </ option >
259+ < option value = "" > General </ option >
175260 { collections ?. map ( ( collection : NonNullable < typeof collections > [ number ] ) => (
176261 < option key = { collection . _id } value = { collection . _id } >
177262 { collection . name }
@@ -190,6 +275,77 @@ export default function ArticleEditorPage() {
190275 />
191276 < p className = "text-xs text-gray-500 mt-2" > Supports Markdown formatting</ p >
192277 </ div >
278+
279+ < div className = "rounded-md border border-gray-200 bg-gray-50 p-4 space-y-3" >
280+ < div className = "flex items-center justify-between gap-3 flex-wrap" >
281+ < div >
282+ < p className = "text-sm font-medium text-gray-900" > Images</ p >
283+ < p className = "text-xs text-gray-600" >
284+ Upload an image to insert markdown like{ " " }
285+ < code className = "font-mono" > </ code >
286+ </ p >
287+ </ div >
288+ < input
289+ ref = { uploadInputRef }
290+ type = "file"
291+ accept = ".png,.jpg,.jpeg,.gif,.webp,.avif,image/png,image/jpeg,image/gif,image/webp,image/avif"
292+ className = "hidden"
293+ onChange = { handleImageUpload }
294+ />
295+ < Button
296+ variant = "outline"
297+ size = "sm"
298+ onClick = { ( ) => uploadInputRef . current ?. click ( ) }
299+ disabled = { isUploadingImage }
300+ >
301+ { isUploadingImage ? "Uploading..." : "Upload Image" }
302+ </ Button >
303+ </ div >
304+
305+ { assetError && (
306+ < div className = "text-xs text-red-700 rounded border border-red-200 bg-red-50 px-2 py-1" >
307+ { assetError }
308+ </ div >
309+ ) }
310+
311+ { ( articleAssets ?. length ?? 0 ) > 0 && (
312+ < div className = "space-y-2" >
313+ { articleAssets ?. map ( ( asset : NonNullable < typeof articleAssets > [ number ] ) => (
314+ < div
315+ key = { asset . _id }
316+ className = "flex items-center justify-between gap-2 rounded border border-gray-200 bg-white px-3 py-2"
317+ >
318+ < div className = "min-w-0" >
319+ < div className = "text-sm font-medium text-gray-900 truncate" >
320+ { asset . fileName }
321+ </ div >
322+ < div className = "text-xs text-gray-500 font-mono truncate" >
323+ { asset . reference }
324+ </ div >
325+ </ div >
326+ < div className = "flex items-center gap-2" >
327+ < Button
328+ variant = "outline"
329+ size = "sm"
330+ onClick = { ( ) => appendAssetReference ( asset . reference , asset . fileName ) }
331+ >
332+ Insert
333+ </ Button >
334+ < Button
335+ variant = "outline"
336+ size = "sm"
337+ onClick = { ( ) => handleDeleteAsset ( asset . _id ) }
338+ disabled = { removingAssetId === asset . _id }
339+ className = "text-red-700 border-red-200 hover:text-red-800"
340+ >
341+ { removingAssetId === asset . _id ? "Deleting..." : "Delete" }
342+ </ Button >
343+ </ div >
344+ </ div >
345+ ) ) }
346+ </ div >
347+ ) }
348+ </ div >
193349 </ div >
194350
195351 < div className = "bg-white rounded-lg border p-6 mt-6" >
0 commit comments