@@ -50,8 +50,11 @@ type UserData = {
5050
5151type OpinionLayout = {
5252 column1 : ( number | null ) [ ] ;
53+ column1Images : boolean [ ] ;
5354 column2 : ( number | null ) [ ] ;
55+ column2Images : boolean [ ] ;
5456 column3 : ( number | null ) [ ] ;
57+ column3Images : boolean [ ] ;
5558 editorsChoice : ( number | null ) [ ] ;
5659 editorsChoiceLabel : string ;
5760 spotlight : SpotlightEntry [ ] ;
@@ -69,8 +72,11 @@ const pointerThenCenter: CollisionDetection = (args) => {
6972
7073const EMPTY_LAYOUT : OpinionLayout = {
7174 column1 : [ null , null , null , null , null ] ,
75+ column1Images : [ false , false , false , false , false ] ,
7276 column2 : [ null , null , null , null ] ,
77+ column2Images : [ false , false , false , false ] ,
7378 column3 : [ null , null , null , null ] ,
79+ column3Images : [ false , false , false , false ] ,
7480 editorsChoice : [ null , null , null ] ,
7581 editorsChoiceLabel : "Opinion\u2019s Choice" ,
7682 spotlight : [ ] ,
@@ -179,14 +185,15 @@ function SlotPreview({ article, showImage }: { article: ArticleData; showImage?:
179185// ---------------------------------------------------------------------------
180186
181187function DropSlot ( {
182- slotId, label, article, showImage, onClear, isImageSlot,
188+ slotId, label, article, showImage, onClear, isImageSlot, imageToggle ,
183189} : {
184190 slotId : string ;
185191 label : string ;
186192 article : ArticleData | null ;
187193 showImage ?: boolean ;
188194 onClear : ( ) => void ;
189195 isImageSlot ?: boolean ;
196+ imageToggle ?: React . ReactNode ;
190197} ) {
191198 const { isOver, setNodeRef } = useDroppable ( {
192199 id : `drop-${ slotId } ` ,
@@ -200,9 +207,12 @@ function DropSlot({
200207 >
201208 < div className = "ole-slot-header" >
202209 < span className = "ole-slot-label" > { label } </ span >
203- { article && (
204- < button className = "ole-slot-clear" onClick = { onClear } title = "Remove article" > ×</ button >
205- ) }
210+ < div style = { { display : 'flex' , alignItems : 'center' , gap : 6 } } >
211+ { imageToggle }
212+ { article && (
213+ < button className = "ole-slot-clear" onClick = { onClear } title = "Remove article" > ×</ button >
214+ ) }
215+ </ div >
206216 </ div >
207217 { article ? (
208218 < DraggableSlotArticle article = { article } slotId = { slotId } showImage = { showImage } />
@@ -313,13 +323,8 @@ function ArticlePool({
313323 return (
314324 < div
315325 ref = { setNodeRef }
326+ className = "ole-pool"
316327 style = { {
317- borderLeft : '1px solid #e5e5e5' ,
318- display : 'flex' ,
319- flexDirection : 'column' ,
320- height : 'calc(100vh - 53px)' ,
321- position : 'sticky' ,
322- top : '53px' ,
323328 background : isOver ? 'rgba(59,130,246,0.04)' : undefined ,
324329 outline : isOver ? '2px dashed #3b82f6' : undefined ,
325330 outlineOffset : isOver ? '-2px' : undefined ,
@@ -332,25 +337,14 @@ function ArticlePool({
332337 placeholder = "Search articles..."
333338 value = { search }
334339 onChange = { ( e ) => onSearch ( e . target . value ) }
335- style = { {
336- display : 'block' ,
337- width : '100%' ,
338- boxSizing : 'border-box' ,
339- padding : '6px 10px' ,
340- fontSize : '13px' ,
341- lineHeight : '1.4' ,
342- border : '1px solid #ccc' ,
343- borderRadius : '6px' ,
344- outline : 'none' ,
345- background : '#f5f5f5' ,
346- } }
340+ className = "ole-pool-search"
347341 />
348342 </ div >
349343 < div style = { { flex : 1 , overflowY : 'auto' , padding : '6px 10px 16px' , display : 'flex' , flexDirection : 'column' , gap : '1px' } } >
350344 { filtered . length === 0 && (
351345 < div style = { { textAlign : 'center' , padding : '32px 16px' , opacity : 0.3 , fontSize : '0.75rem' } } > No articles found</ div >
352346 ) }
353- { filtered . slice ( 0 , 10 ) . map ( ( article ) => (
347+ { filtered . slice ( 0 , 20 ) . map ( ( article ) => (
354348 < DraggablePoolCard key = { article . id } article = { article } isUsed = { usedIds . has ( article . id ) } />
355349 ) ) }
356350 </ div >
@@ -435,10 +429,22 @@ export function OpinionLayoutEditor() {
435429 if ( layoutData ) {
436430 const savedLayout = layoutData . layout as OpinionLayout | undefined ;
437431 if ( savedLayout && typeof savedLayout === 'object' ) {
432+ const c1 = padArray ( savedLayout . column1 , 5 ) ;
433+ const c2 = padArray ( savedLayout . column2 , 4 ) ;
434+ const c3 = padArray ( savedLayout . column3 , 4 ) ;
438435 setLayout ( {
439- column1 : padArray ( savedLayout . column1 , 5 ) ,
440- column2 : padArray ( savedLayout . column2 , 4 ) ,
441- column3 : padArray ( savedLayout . column3 , 4 ) ,
436+ column1 : c1 ,
437+ column1Images : savedLayout . column1Images
438+ ? padBoolArray ( savedLayout . column1Images , c1 . length )
439+ : padBoolArray ( [ true ] , c1 . length ) , // col1[0] previously always had image
440+ column2 : c2 ,
441+ column2Images : savedLayout . column2Images
442+ ? padBoolArray ( savedLayout . column2Images , c2 . length )
443+ : padBoolArray ( [ false , false , true ] , c2 . length ) , // col2[2] previously always had image
444+ column3 : c3 ,
445+ column3Images : savedLayout . column3Images
446+ ? padBoolArray ( savedLayout . column3Images , c3 . length )
447+ : padBoolArray ( [ false , false , false , true ] , c3 . length ) , // col3[3] previously always had image
442448 editorsChoice : padArray ( savedLayout . editorsChoice , 3 ) ,
443449 editorsChoiceLabel : savedLayout . editorsChoiceLabel || "Opinion\u2019s Choice" ,
444450 spotlight : ( savedLayout as OpinionLayout ) . spotlight || [ ] ,
@@ -488,6 +494,27 @@ export function OpinionLayoutEditor() {
488494 setSlot ( parsed . column , parsed . index , null ) ;
489495 } , [ setSlot ] ) ;
490496
497+ const toggleColumnImage = useCallback ( ( column : 'column1' | 'column2' | 'column3' , index : number ) => {
498+ const imagesKey = ( column + 'Images' ) as 'column1Images' | 'column2Images' | 'column3Images' ;
499+ setLayout ( ( prev ) => {
500+ const imgs = [ ...prev [ imagesKey ] ] ;
501+ while ( imgs . length <= index ) imgs . push ( false ) ;
502+ imgs [ index ] = ! imgs [ index ] ;
503+ return { ...prev , [ imagesKey ] : imgs } ;
504+ } ) ;
505+ markDirty ( ) ;
506+ } , [ markDirty ] ) ;
507+
508+ const addSlot = useCallback ( ( column : 'column1' | 'column2' | 'column3' ) => {
509+ const imagesKey = ( column + 'Images' ) as 'column1Images' | 'column2Images' | 'column3Images' ;
510+ setLayout ( ( prev ) => ( {
511+ ...prev ,
512+ [ column ] : [ ...prev [ column ] , null ] ,
513+ [ imagesKey ] : [ ...prev [ imagesKey ] , false ] ,
514+ } ) ) ;
515+ markDirty ( ) ;
516+ } , [ markDirty ] ) ;
517+
491518 // ---- Spotlight ----
492519 const addSpotlight = useCallback ( ( userId : number ) => {
493520 setLayout ( ( prev ) => {
@@ -594,10 +621,16 @@ export function OpinionLayoutEditor() {
594621 setError ( null ) ;
595622 try {
596623 // Trim trailing nulls from columns
624+ const tc1 = trimTrailingNulls ( layout . column1 ) ;
625+ const tc2 = trimTrailingNulls ( layout . column2 ) ;
626+ const tc3 = trimTrailingNulls ( layout . column3 ) ;
597627 const trimmed = {
598- column1 : trimTrailingNulls ( layout . column1 ) ,
599- column2 : trimTrailingNulls ( layout . column2 ) ,
600- column3 : trimTrailingNulls ( layout . column3 ) ,
628+ column1 : tc1 ,
629+ column1Images : layout . column1Images . slice ( 0 , tc1 . length ) ,
630+ column2 : tc2 ,
631+ column2Images : layout . column2Images . slice ( 0 , tc2 . length ) ,
632+ column3 : tc3 ,
633+ column3Images : layout . column3Images . slice ( 0 , tc3 . length ) ,
601634 editorsChoice : trimTrailingNulls ( layout . editorsChoice ) ,
602635 editorsChoiceLabel : layout . editorsChoiceLabel ,
603636 spotlight : layout . spotlight || [ ] ,
@@ -666,7 +699,7 @@ export function OpinionLayoutEditor() {
666699 </ button >
667700 </ div >
668701 </ div >
669- < div className = "ole-body" style = { { display : 'grid' , gridTemplateColumns : '1fr 320px' , gap : 0 , minHeight : 'calc(100vh - 53px)' } } >
702+ < div className = "ole-body" >
670703 { /* Left: canvas */ }
671704 < div className = "ole-canvas-wrap" >
672705 { /* 3-column layout canvas */ }
@@ -676,9 +709,23 @@ export function OpinionLayoutEditor() {
676709 < div className = "ole-column-header" >
677710 < span className = "ole-column-title" > Column 1</ span >
678711 </ div >
679- < DropSlot slotId = "col1-0" label = { COL1_LABELS [ 0 ] } article = { getArticle ( 'column1' , 0 ) } showImage onClear = { ( ) => clearSlot ( 'col1-0' ) } isImageSlot />
680- < DropSlot slotId = "col1-1" label = { COL1_LABELS [ 1 ] } article = { getArticle ( 'column1' , 1 ) } onClear = { ( ) => clearSlot ( 'col1-1' ) } />
681- < DropSlot slotId = "col1-2" label = { COL1_LABELS [ 2 ] } article = { getArticle ( 'column1' , 2 ) } onClear = { ( ) => clearSlot ( 'col1-2' ) } />
712+ { layout . column1 . slice ( 0 , 3 ) . map ( ( _ , i ) => (
713+ < DropSlot
714+ key = { `col1-${ i } ` }
715+ slotId = { `col1-${ i } ` }
716+ label = { COL1_LABELS [ i ] ?? 'Article' }
717+ article = { getArticle ( 'column1' , i ) }
718+ showImage = { layout . column1Images [ i ] }
719+ isImageSlot = { layout . column1Images [ i ] }
720+ onClear = { ( ) => clearSlot ( `col1-${ i } ` ) }
721+ imageToggle = {
722+ < label className = "ole-image-toggle" title = "Toggle image" >
723+ < input type = "checkbox" checked = { ! ! layout . column1Images [ i ] } onChange = { ( ) => toggleColumnImage ( 'column1' , i ) } style = { { position : 'relative' , top : '1px' } } />
724+ < span style = { { textTransform : 'uppercase' , color : '#999' , fontSize : '0.68rem' , fontWeight : 600 , letterSpacing : '0.06em' , position : 'relative' , top : '-1px' } } > IMG</ span >
725+ </ label >
726+ }
727+ />
728+ ) ) }
682729
683730 { /* Fixed CTA */ }
684731 < div className = "ole-cta-block" >
@@ -687,16 +734,48 @@ export function OpinionLayoutEditor() {
687734 </ p >
688735 </ div >
689736
690- < DropSlot slotId = "col1-3" label = { COL1_LABELS [ 3 ] } article = { getArticle ( 'column1' , 3 ) } onClear = { ( ) => clearSlot ( 'col1-3' ) } />
691- < DropSlot slotId = "col1-4" label = { COL1_LABELS [ 4 ] } article = { getArticle ( 'column1' , 4 ) } onClear = { ( ) => clearSlot ( 'col1-4' ) } />
737+ { layout . column1 . slice ( 3 ) . map ( ( _ , ii ) => {
738+ const i = ii + 3 ;
739+ return (
740+ < DropSlot
741+ key = { `col1-${ i } ` }
742+ slotId = { `col1-${ i } ` }
743+ label = { COL1_LABELS [ i ] ?? 'Article' }
744+ article = { getArticle ( 'column1' , i ) }
745+ showImage = { layout . column1Images [ i ] }
746+ isImageSlot = { layout . column1Images [ i ] }
747+ onClear = { ( ) => clearSlot ( `col1-${ i } ` ) }
748+ imageToggle = {
749+ < label className = "ole-image-toggle" title = "Toggle image" >
750+ < input type = "checkbox" checked = { ! ! layout . column1Images [ i ] } onChange = { ( ) => toggleColumnImage ( 'column1' , i ) } style = { { position : 'relative' , top : '1px' } } />
751+ < span style = { { textTransform : 'uppercase' , color : '#999' , fontSize : '0.68rem' , fontWeight : 600 , letterSpacing : '0.06em' , position : 'relative' , top : '-1px' } } > IMG</ span >
752+ </ label >
753+ }
754+ />
755+ ) ;
756+ } ) }
757+ < button className = "ole-add-slot-btn" onClick = { ( ) => addSlot ( 'column1' ) } > + Add slot</ button >
692758 </ div >
693759
694760 { /* Column 2 */ }
695761 < div className = "ole-column" >
696762 < div className = "ole-column-header" >
697763 < span className = "ole-column-title" > Column 2</ span >
698764 </ div >
699- < DropSlot slotId = "col2-0" label = { COL2_LABELS [ 0 ] } article = { getArticle ( 'column2' , 0 ) } onClear = { ( ) => clearSlot ( 'col2-0' ) } />
765+ < DropSlot
766+ slotId = "col2-0"
767+ label = { COL2_LABELS [ 0 ] }
768+ article = { getArticle ( 'column2' , 0 ) }
769+ showImage = { layout . column2Images [ 0 ] }
770+ isImageSlot = { layout . column2Images [ 0 ] }
771+ onClear = { ( ) => clearSlot ( 'col2-0' ) }
772+ imageToggle = {
773+ < label className = "ole-image-toggle" title = "Toggle image" >
774+ < input type = "checkbox" checked = { ! ! layout . column2Images [ 0 ] } onChange = { ( ) => toggleColumnImage ( 'column2' , 0 ) } style = { { position : 'relative' , top : '1px' } } />
775+ < span style = { { textTransform : 'uppercase' , color : '#999' , fontSize : '0.68rem' , fontWeight : 600 , letterSpacing : '0.06em' , position : 'relative' , top : '-1px' } } > IMG</ span >
776+ </ label >
777+ }
778+ />
700779
701780 { /* Author Spotlight Carousel — inline editor */ }
702781 < div className = "ole-spotlight-section" >
@@ -763,9 +842,27 @@ export function OpinionLayoutEditor() {
763842 </ div >
764843 </ div >
765844
766- < DropSlot slotId = "col2-1" label = { COL2_LABELS [ 1 ] } article = { getArticle ( 'column2' , 1 ) } onClear = { ( ) => clearSlot ( 'col2-1' ) } />
767- < DropSlot slotId = "col2-2" label = { COL2_LABELS [ 2 ] } article = { getArticle ( 'column2' , 2 ) } showImage onClear = { ( ) => clearSlot ( 'col2-2' ) } isImageSlot />
768- < DropSlot slotId = "col2-3" label = { COL2_LABELS [ 3 ] } article = { getArticle ( 'column2' , 3 ) } onClear = { ( ) => clearSlot ( 'col2-3' ) } />
845+ { layout . column2 . slice ( 1 ) . map ( ( _ , ii ) => {
846+ const i = ii + 1 ;
847+ return (
848+ < DropSlot
849+ key = { `col2-${ i } ` }
850+ slotId = { `col2-${ i } ` }
851+ label = { COL2_LABELS [ i ] ?? 'Article' }
852+ article = { getArticle ( 'column2' , i ) }
853+ showImage = { layout . column2Images [ i ] }
854+ isImageSlot = { layout . column2Images [ i ] }
855+ onClear = { ( ) => clearSlot ( `col2-${ i } ` ) }
856+ imageToggle = {
857+ < label className = "ole-image-toggle" title = "Toggle image" >
858+ < input type = "checkbox" checked = { ! ! layout . column2Images [ i ] } onChange = { ( ) => toggleColumnImage ( 'column2' , i ) } style = { { position : 'relative' , top : '1px' } } />
859+ < span style = { { textTransform : 'uppercase' , color : '#999' , fontSize : '0.68rem' , fontWeight : 600 , letterSpacing : '0.06em' , position : 'relative' , top : '-1px' } } > IMG</ span >
860+ </ label >
861+ }
862+ />
863+ ) ;
864+ } ) }
865+ < button className = "ole-add-slot-btn" onClick = { ( ) => addSlot ( 'column2' ) } > + Add slot</ button >
769866 </ div >
770867
771868 { /* Column 3 */ }
@@ -788,10 +885,24 @@ export function OpinionLayoutEditor() {
788885 ) ) }
789886 </ div >
790887
791- < DropSlot slotId = "col3-0" label = { COL3_LABELS [ 0 ] } article = { getArticle ( 'column3' , 0 ) } onClear = { ( ) => clearSlot ( 'col3-0' ) } />
792- < DropSlot slotId = "col3-1" label = { COL3_LABELS [ 1 ] } article = { getArticle ( 'column3' , 1 ) } onClear = { ( ) => clearSlot ( 'col3-1' ) } />
793- < DropSlot slotId = "col3-2" label = { COL3_LABELS [ 2 ] } article = { getArticle ( 'column3' , 2 ) } onClear = { ( ) => clearSlot ( 'col3-2' ) } />
794- < DropSlot slotId = "col3-3" label = { COL3_LABELS [ 3 ] } article = { getArticle ( 'column3' , 3 ) } showImage onClear = { ( ) => clearSlot ( 'col3-3' ) } isImageSlot />
888+ { layout . column3 . map ( ( _ , i ) => (
889+ < DropSlot
890+ key = { `col3-${ i } ` }
891+ slotId = { `col3-${ i } ` }
892+ label = { COL3_LABELS [ i ] ?? 'Article' }
893+ article = { getArticle ( 'column3' , i ) }
894+ showImage = { layout . column3Images [ i ] }
895+ isImageSlot = { layout . column3Images [ i ] }
896+ onClear = { ( ) => clearSlot ( `col3-${ i } ` ) }
897+ imageToggle = {
898+ < label className = "ole-image-toggle" title = "Toggle image" >
899+ < input type = "checkbox" checked = { ! ! layout . column3Images [ i ] } onChange = { ( ) => toggleColumnImage ( 'column3' , i ) } style = { { position : 'relative' , top : '1px' } } />
900+ < span style = { { textTransform : 'uppercase' , color : '#999' , fontSize : '0.68rem' , fontWeight : 600 , letterSpacing : '0.06em' , position : 'relative' , top : '-1px' } } > IMG</ span >
901+ </ label >
902+ }
903+ />
904+ ) ) }
905+ < button className = "ole-add-slot-btn" onClick = { ( ) => addSlot ( 'column3' ) } > + Add slot</ button >
795906 </ div >
796907 </ div >
797908 </ div >
@@ -828,3 +939,9 @@ function trimTrailingNulls(arr: (number | null)[]): (number | null)[] {
828939 while ( result . length > 0 && result [ result . length - 1 ] === null ) result . pop ( ) ;
829940 return result ;
830941}
942+
943+ function padBoolArray ( arr : boolean [ ] | undefined , len : number ) : boolean [ ] {
944+ const result = [ ...( arr || [ ] ) ] ;
945+ while ( result . length < len ) result . push ( false ) ;
946+ return result . slice ( 0 , len ) ;
947+ }
0 commit comments