Skip to content

Commit 01be9f9

Browse files
opinion editor changes (#53)
* opinion editor: add image toggles, add-slot buttons, and IMG label styling - Add per-slot image toggle (show/hide featured image) to all 3 columns - Add "+ Add slot" button at the bottom of each column - Rewrite opinion-layout-editor.css to match Features editor structure and dark-theme variables - Fix IMG toggle label: hardcoded inline uppercase/color/font styles to override Payload admin defaults - Sync fle-image-toggle styles with ole-image-toggle for consistency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * add migration to add layout jsonb column to opinion_page_layout The layout json field was defined in the collection but never had a corresponding ALTER TABLE migration, so saves would silently fail on production. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Revert "add migration to add layout jsonb column to opinion_page_layout" This reverts commit bcf1aae. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8aa2db7 commit 01be9f9

3 files changed

Lines changed: 387 additions & 335 deletions

File tree

components/Dashboard/FeaturesLayoutEditor/features-layout-editor.css

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -519,18 +519,27 @@
519519

520520
/* ---- Image toggle ---- */
521521
.fle-image-toggle {
522-
display: inline-flex;
523-
align-items: center;
524-
gap: 4px;
525-
font-size: 0.64rem;
526-
font-weight: 600;
527-
text-transform: uppercase;
528-
letter-spacing: 0.04em;
529-
opacity: 0.5;
530-
cursor: pointer;
522+
display: inline-flex !important;
523+
flex-direction: row !important;
524+
align-items: center !important;
525+
align-self: center !important;
526+
gap: 4px !important;
527+
font-size: 0.68rem !important;
528+
font-weight: 600 !important;
529+
line-height: 1 !important;
530+
text-transform: uppercase !important;
531+
letter-spacing: 0.06em !important;
532+
color: var(--theme-text, #fff) !important;
533+
opacity: 0.4 !important;
534+
cursor: pointer !important;
535+
margin: 0 !important;
536+
padding: 0 !important;
531537
}
532538
.fle-image-toggle input {
533539
cursor: pointer;
540+
vertical-align: middle;
541+
margin: 0 !important;
542+
flex-shrink: 0;
534543
}
535544

536545
/* ---- Photo Spotlight ---- */

components/Dashboard/OpinionLayoutEditor/index.tsx

Lines changed: 160 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ type UserData = {
5050

5151
type 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

7073
const 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

181187
function 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">&times;</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">&times;</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

Comments
 (0)