11const platformBackground = "https://cdn.hackclub.com/019e3e5a-908f-707d-9790-91f9ec414045/bkg.png" ;
2- import { useEffect , useState } from "react" ;
2+ import { useEffect , useRef , useState } from "react" ;
33import { useAuth } from "../auth/AuthContext.jsx" ;
44import { PlatformStatusBar } from "./PlatformStatusBar.jsx" ;
55const sideBrick = "https://cdn.hackclub.com/019e3e5a-9d8a-7fcb-ad80-d6166cfd97f8/side_brick.png" ;
@@ -98,6 +98,51 @@ const emptyJournalEntry = {
9898 toolsUsed : "" ,
9999} ;
100100
101+ const ALLOWED_UPLOAD_TYPES = new Set ( [
102+ "image/jpeg" ,
103+ "image/png" ,
104+ "image/gif" ,
105+ "image/webp" ,
106+ "image/avif" ,
107+ "video/mp4" ,
108+ "video/webm" ,
109+ "video/ogg" ,
110+ "video/quicktime" ,
111+ ] ) ;
112+
113+ function isVideoUrl ( url ) {
114+ const ext = url . split ( "?" ) [ 0 ] . split ( "." ) . pop ( ) ?. toLowerCase ( ) ;
115+ return [ "mp4" , "webm" , "ogg" , "mov" ] . includes ( ext ) ;
116+ }
117+
118+ function JournalDescriptionRenderer ( { text } ) {
119+ if ( ! text ) return null ;
120+ const parts = [ ] ;
121+ const imgRe = / ! \[ ( [ ^ \] ] * ) \] \( ( h t t p s : \/ \/ c d n \. h a c k c l u b \. c o m \/ [ ^ \s ) ] + ) \) / g;
122+ let last = 0 ;
123+ let match ;
124+ while ( ( match = imgRe . exec ( text ) ) !== null ) {
125+ if ( match . index > last ) parts . push ( { type : "text" , value : text . slice ( last , match . index ) } ) ;
126+ parts . push ( { type : "media" , alt : match [ 1 ] , url : match [ 2 ] } ) ;
127+ last = match . index + match [ 0 ] . length ;
128+ }
129+ if ( last < text . length ) parts . push ( { type : "text" , value : text . slice ( last ) } ) ;
130+
131+ return (
132+ < div className = "journal-description" >
133+ { parts . map ( ( part , i ) =>
134+ part . type === "text" ? (
135+ < span key = { i } style = { { whiteSpace : "pre-wrap" } } > { part . value } </ span >
136+ ) : isVideoUrl ( part . url ) ? (
137+ < video key = { i } className = "journal-media-item" src = { part . url } controls preload = "metadata" />
138+ ) : (
139+ < img key = { i } className = "journal-media-item" src = { part . url } alt = { part . alt } />
140+ )
141+ ) }
142+ </ div >
143+ ) ;
144+ }
145+
101146function displayStatus ( project ) {
102147 if ( project . status === "approved" ) return "Approved" ;
103148 if ( project . status === "rejected" ) return "Rejected" ;
@@ -593,6 +638,71 @@ function ProjectDetailsModal({ project, onClose, onEdit, onJournal, onShip, onDe
593638}
594639
595640function JournalModal ( { project, entries, form, onChange, onClose, onSubmit } ) {
641+ const [ uploading , setUploading ] = useState ( false ) ;
642+ const [ uploadError , setUploadError ] = useState ( "" ) ;
643+ const [ isDraggingOver , setIsDraggingOver ] = useState ( false ) ;
644+ const textareaRef = useRef ( null ) ;
645+ const descriptionRef = useRef ( form . description ) ;
646+ descriptionRef . current = form . description ;
647+
648+ async function uploadAndInsert ( file ) {
649+ if ( ! ALLOWED_UPLOAD_TYPES . has ( file . type ) ) {
650+ setUploadError ( `Unsupported file type: ${ file . type } . Use JPEG, PNG, GIF, WebP, AVIF, MP4, WebM, OGG, or MOV.` ) ;
651+ return ;
652+ }
653+ setUploading ( true ) ;
654+ setUploadError ( "" ) ;
655+
656+ const textarea = textareaRef . current ;
657+ const start = textarea ?. selectionStart ?? descriptionRef . current . length ;
658+ const end = textarea ?. selectionEnd ?? start ;
659+ const placeholder = `![Uploading ${ file . name } …]()` ;
660+ const before = descriptionRef . current . slice ( 0 , start ) ;
661+ const after = descriptionRef . current . slice ( end ) ;
662+ const withPlaceholder = `${ before } ${ placeholder } ${ after } ` ;
663+ onChange ( "description" , withPlaceholder ) ;
664+ descriptionRef . current = withPlaceholder ;
665+
666+ try {
667+ const formData = new FormData ( ) ;
668+ formData . append ( "file" , file ) ;
669+ const response = await fetch ( "/api/cdn/upload" , {
670+ method : "POST" ,
671+ credentials : "include" ,
672+ headers : { "x-file-type" : file . type } ,
673+ body : formData ,
674+ } ) ;
675+ const data = await response . json ( ) ;
676+ if ( ! response . ok ) throw new Error ( data . error || "Upload failed." ) ;
677+
678+ const mdLink = `` ;
679+ const resolved = descriptionRef . current . replace ( placeholder , mdLink ) ;
680+ onChange ( "description" , resolved ) ;
681+ descriptionRef . current = resolved ;
682+ } catch ( err ) {
683+ const cleaned = descriptionRef . current . replace ( placeholder , "" ) ;
684+ onChange ( "description" , cleaned ) ;
685+ descriptionRef . current = cleaned ;
686+ setUploadError ( err . message ) ;
687+ } finally {
688+ setUploading ( false ) ;
689+ }
690+ }
691+
692+ function handleTextareaDrop ( event ) {
693+ event . preventDefault ( ) ;
694+ setIsDraggingOver ( false ) ;
695+ const files = Array . from ( event . dataTransfer . files ) ;
696+ for ( const file of files ) uploadAndInsert ( file ) ;
697+ }
698+
699+ function handleTextareaPaste ( event ) {
700+ const files = Array . from ( event . clipboardData ?. files ?? [ ] ) ;
701+ if ( files . length === 0 ) return ;
702+ event . preventDefault ( ) ;
703+ for ( const file of files ) uploadAndInsert ( file ) ;
704+ }
705+
596706 return (
597707 < div className = "projects-page__modal-overlay" role = "presentation" onClick = { onClose } >
598708 < section className = "projects-page__modal projects-page__modal--journal" role = "dialog" aria-modal = "true" onClick = { ( event ) => event . stopPropagation ( ) } >
@@ -619,13 +729,33 @@ function JournalModal({ project, entries, form, onChange, onClose, onSubmit }) {
619729 </ label >
620730 < label >
621731 Description
622- < textarea value = { form . description } onChange = { ( event ) => onChange ( "description" , event . target . value ) } required />
732+ < div className = { `journal-textarea-wrap${ isDraggingOver ? " journal-textarea-wrap--drag" : "" } ` } >
733+ < textarea
734+ ref = { textareaRef }
735+ value = { form . description }
736+ onChange = { ( event ) => onChange ( "description" , event . target . value ) }
737+ onDragOver = { ( e ) => { e . preventDefault ( ) ; setIsDraggingOver ( true ) ; } }
738+ onDragLeave = { ( ) => setIsDraggingOver ( false ) }
739+ onDrop = { handleTextareaDrop }
740+ onPaste = { handleTextareaPaste }
741+ required
742+ placeholder = "Describe what you worked on… drag & drop or paste images/videos to attach them inline"
743+ />
744+ { isDraggingOver && (
745+ < div className = "journal-textarea-drop-overlay" aria-hidden = "true" > Drop to upload</ div >
746+ ) }
747+ { uploading && (
748+ < div className = "journal-textarea-uploading" aria-live = "polite" > Uploading…</ div >
749+ ) }
750+ </ div >
751+ { uploadError ? < p className = "journal-upload-error" > { uploadError } </ p > : null }
752+ < small className = "journal-upload-hint-text" > Drag & drop or paste images/videos to embed them inline</ small >
623753 </ label >
624754 < label >
625755 Tools Used
626756 < input value = { form . toolsUsed } onChange = { ( event ) => onChange ( "toolsUsed" , event . target . value ) } placeholder = "React, Figma, CAD" />
627757 </ label >
628- < button type = "submit" > Save Entry</ button >
758+ < button type = "submit" disabled = { uploading } > Save Entry</ button >
629759 </ form >
630760
631761 < section className = "projects-page__journal-list" aria-label = "Journal entries" >
@@ -638,7 +768,7 @@ function JournalModal({ project, entries, form, onChange, onClose, onSubmit }) {
638768 < strong >
639769 { entry . timeDone ? new Date ( entry . timeDone ) . toLocaleDateString ( ) : "N/A" } - { entry . hoursWorked || 0 } hrs
640770 </ strong >
641- < p > { entry . description } </ p >
771+ < JournalDescriptionRenderer text = { entry . description } / >
642772 { entry . toolsUsed ?. length ? < small > Tools: { entry . toolsUsed . join ( ", " ) } </ small > : null }
643773 </ article >
644774 ) )
0 commit comments