28 KiB
28 KiB
Handoff Report: Editorial Carousel split_screen Layout Investigation
This handoff report summarizes findings, analysis, and implementation proposals for introducing the split_screen layout configuration to the EditorialCarousel page section.
1. Observation
We directly investigated five code locations:
1.1 Types Definition
- File:
packages/types/index.ts - Lines: 375-388
- Current schema code:
export const EditorialCarouselSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
slides: z.array(z.object({
category: LocalizedStringSchema,
title: LocalizedStringSchema,
description: LocalizedStringSchema,
imageUrl: z.string().min(1),
buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(),
})).min(1)
});
export type EditorialCarouselSectionPayload = z.infer<typeof EditorialCarouselSectionPayloadSchema>;
1.2 MCP Server Tool
- File:
apps/mcp-server/section-toolkit.ts - Lines: 265-292
- Current properties structure:
name: "update_editorial_carousel",
sectionType: "EDITORIAL_CAROUSEL",
description: `Update an EDITORIAL_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title, description, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "description": ${localizedStringExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
description: { ...localizedStringSchema, description: `Localized section description, for example ${localizedStringExample}` },
backgroundColor: { type: "string", description: "Plain string background color in hex or rgba" },
textColor: { type: "string", description: "Plain string text color in hex or rgba" },
slides: { ... }
}
- Execution (Lines 550-578): The tool retrieves the payload properties defined in the config, extracts them from incoming
params, fetches the existing section from backend (GET /pages/sections/:id?hydrate=false), merges the two payloads (mergedPayload = { ...existingRes.data.payload, ...payload }), and callsPUT /pages/sections/:id.
1.3 Backend Section Validation
- File:
apps/backend/src/routes/pages.ts - Lines: 451-465 (under
PUT /sections/:sectionId), 536-578 (insidevalidateSectionPayload) - Validation logic:
function validateSectionPayload(
type: string,
payload: any,
): { success: boolean; issues?: z.ZodIssue[]; data?: any } {
// ...
switch (type.toUpperCase()) {
// ...
case "EDITORIAL_CAROUSEL":
schema = EditorialCarouselSectionPayloadSchema;
break;
// ...
}
const res = schema.safeParse(payload);
if (!res.success) {
return { success: false, issues: res.error.issues };
}
return { success: true, data: res.data };
}
Validation output is saved directly into the database: payload: finalPayload = validationResult.data.
1.4 Admin Panel Page Builder Form
- File:
apps/admin/src/components/page-builder/EditorialCarouselForm.tsx - Lines: 43-86
- Current properties updating pattern: Uses
setEditingSectionby merging state:
onChange={(e) => setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value }
})}
1.5 Client Renderers (SPA and Website)
- SPA Component:
apps/spa/src/components/DynamicEditorialCarousel.tsx(Lines 77-145)- Uses
Swiperwith standard slides-per-view breakpoints (slidesPerView: 1, 2, or 3). - Uses theme token-mapped utility classes:
bg-pop-bg(resolved viabgMap),text-pop-black(resolved viatextMap).
- Uses
- Website Renderer:
apps/website/src/components/dynamic/DynamicPageRenderer.tsx(Lines 415-481)- Uses a horizontal flex scroll container with
scrollContainerRef. - Buttons call
scrollBy(400)to scroll horizontally. - Render card style uses
.ui-cardwithw-[85vw] md:w-[350px].
- Uses a horizontal flex scroll container with
2. Logic Chain
- Schema Extension (Types): To allow the layout properties (
layoutandimagePosition) to be saved, we must add them toEditorialCarouselSectionPayloadSchemainpackages/types/index.ts. They must be.optional()so that existing sections in the database remain valid. - Backend Validation Validation & Tool Exposure: Since
pages.tsvalidates payload records usingsafeParseagainst the types schema, extending the schema will automatically allow the new parameters to pass backend validation. In the MCP server, we must also update the properties structure ofupdate_editorial_carouselinapps/mcp-server/section-toolkit.tsto exposelayoutandimagePositionto AI agents. - Form Fields (Admin): To enable administrative control over these layout configurations, we can add two select fields (
LayoutandImage Position) to the styling settings section inEditorialCarouselForm.tsxusing the same merging style asbackgroundColor. To enhance UX, theImage Positionfield can be disabled unlesslayout === 'split_screen'. - Renderer Behavior (SPA & Website):
- For
layout === 'split_screen', we render exactly 1 slide at a time. - For the SPA component, Swiper configuration must adapt (
slidesPerView={1}and no desktop breakpoints). - For the Next.js website component, slide width must stretch to 100% (
w-full), andscrollBymust scroll by the full width of the containerscrollContainerRef.current.clientWidth. - On desktop, CSS Grid will split the slide
35%text /65%image. CSSorderproperty will arrange them so thatimagePosition: 'right'renders text-left/image-right, andimagePosition: 'left'renders image-left/text-right. - On mobile, CSS
orderwill force the image to stack on top and text on the bottom.
- For
3. Caveats
- Existing Data Migrations: Since no database migrations are needed (fields are optional and schema validates them safely as optional), existing databases do not need prisma migrations.
- Swiper Navigation Controls: In
split_screenlayout, standard Swiper arrows overlap the image/text on the far edges. Swiper handles pagination bullet styling, which has been configured to matchbg-pop-pink.
4. Conclusion
The layout configuration changes are backward-compatible and require updates to:
packages/types/index.tsapps/mcp-server/section-toolkit.tsapps/admin/src/components/page-builder/EditorialCarouselForm.tsxapps/spa/src/components/DynamicEditorialCarousel.tsxapps/website/src/components/dynamic/DynamicPageRenderer.tsx
Proposed Code Diffs
1. packages/types/index.ts
export const EditorialCarouselSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
+ layout: z.enum(['standard', 'split_screen']).optional(),
+ imagePosition: z.enum(['left', 'right']).optional(),
slides: z.array(z.object({
category: LocalizedStringSchema,
title: LocalizedStringSchema,
2. apps/mcp-server/section-toolkit.ts
{
name: "update_editorial_carousel",
sectionType: "EDITORIAL_CAROUSEL",
- description: `Update an EDITORIAL_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title, description, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "description": ${localizedStringExample} }`,
+ description: `Update an EDITORIAL_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title, description, layout, imagePosition, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "layout": "split_screen", "imagePosition": "right" }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
description: { ...localizedStringSchema, description: `Localized section description, for example ${localizedStringExample}` },
backgroundColor: { type: "string", description: "Plain string background color in hex or rgba" },
textColor: { type: "string", description: "Plain string text color in hex or rgba" },
+ layout: { type: "string", enum: ["standard", "split_screen"], description: "Layout style of the carousel (standard or split_screen)" },
+ imagePosition: { type: "string", enum: ["left", "right"], description: "Position of the image relative to text when layout is split_screen (left or right)" },
slides: {
3. apps/admin/src/components/page-builder/EditorialCarouselForm.tsx
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label>
...
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Text Color</label>
...
</div>
</div>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Layout</label>
+ <select
+ value={editingSection.payload?.layout || 'standard'}
+ onChange={(e) => setEditingSection({
+ ...editingSection,
+ payload: { ...(editingSection.payload || {}), layout: e.target.value }
+ })}
+ className="admin-input w-full"
+ >
+ <option value="standard">Standard Carousel</option>
+ <option value="split_screen">Split Screen</option>
+ </select>
+ </div>
+ <div className="space-y-2">
+ <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Image Position (Split Screen)</label>
+ <select
+ disabled={editingSection.payload?.layout !== 'split_screen'}
+ value={editingSection.payload?.imagePosition || 'right'}
+ onChange={(e) => setEditingSection({
+ ...editingSection,
+ payload: { ...(editingSection.payload || {}), imagePosition: e.target.value }
+ })}
+ className="admin-input w-full disabled:opacity-50"
+ >
+ <option value="right">Right (Text Left, Image Right)</option>
+ <option value="left">Left (Image Left, Text Right)</option>
+ </select>
+ </div>
+ </div>
+
<div className="space-y-4">
4. apps/spa/src/components/DynamicEditorialCarousel.tsx
export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCarouselProps) {
const { i18n } = useTranslation();
const lang = i18n.language;
+ const layout = payload.layout || 'standard';
+ const imagePosition = payload.imagePosition || 'right';
+ const isSplitScreen = layout === 'split_screen';
+
const title = getLocalizedField(payload.title, lang);
const slides = (payload.slides || []).map((slide) => ({
...slide,
@@ -86,13 +90,13 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
<div className="relative select-none pb-12">
<Swiper
modules={[Autoplay, Pagination, Navigation]}
- spaceBetween={30}
+ spaceBetween={isSplitScreen ? 0 : 30}
slidesPerView={1}
- breakpoints={{
+ breakpoints={isSplitScreen ? undefined : {
640: { slidesPerView: 2 },
1024: { slidesPerView: 3 }
- }}
+ }}
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-pop-pink' }}
navigation={true}
autoplay={{ delay: 5000, disableOnInteraction: false }}
className="w-full"
>
{slides.map((slide, idx) => (
<SwiperSlide key={idx} className="h-auto flex flex-col">
- <div className="ui-dark-card group flex h-full flex-col transition-all duration-300 hover:border-pop-pink/30">
- <div className="aspect-[4/3] overflow-hidden relative">
- <img
- src={slide.imageUrl}
- alt={slide.title}
- className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
- onError={(e) => {
- (e.target as HTMLImageElement).style.display = 'none';
- }}
- />
- </div>
-
- <div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6">
- <div className="space-y-3">
- <span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-pop-pink">
- {slide.category}
- </span>
- <h3 className="font-serif text-xl font-light tracking-wide text-pop-black">
- {renderTitle(slide.title) || slide.title}
- </h3>
- <p className="line-clamp-3 text-xs font-light leading-relaxed text-pop-black/70 md:text-sm">
- {slide.description}
- </p>
- </div>
-
- {slide.buttonText && slide.buttonLink && (
- <div className="pt-2">
- <Link
- to={slide.buttonLink}
- className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-pop-pink hover:text-pop-black transition-colors group/link"
- >
- {slide.buttonText}
- <span className="group-hover/link:translate-x-1 transition-transform">→</span>
- </Link>
- </div>
- )}
- </div>
- </div>
+ {isSplitScreen ? (
+ <div className={`grid grid-cols-1 md:grid-cols-${imagePosition === 'left' ? '[65%_35%]' : '[35%_65%]'} w-full items-stretch border border-white/10 rounded-xl overflow-hidden bg-night-950/20`}>
+ {/* Text Column */}
+ <div className={`p-8 md:p-12 lg:p-16 flex flex-col justify-center space-y-6 order-2 md:order-${imagePosition === 'left' ? '2' : '1'}`}>
+ <span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-pop-pink">
+ {slide.category}
+ </span>
+ <h3 className="font-serif text-2xl md:text-3xl lg:text-4xl font-light tracking-wide text-pop-black">
+ {renderTitle(slide.title) || slide.title}
+ </h3>
+ <p className="text-sm font-light leading-relaxed text-pop-black/70">
+ {slide.description}
+ </p>
+ {slide.buttonText && slide.buttonLink && (
+ <div className="pt-2">
+ <Link
+ to={slide.buttonLink}
+ className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-pop-pink hover:text-pop-black transition-colors group/link"
+ >
+ {slide.buttonText}
+ <span className="group-hover/link:translate-x-1 transition-transform">→</span>
+ </Link>
+ </div>
+ )}
+ </div>
+ {/* Image Column */}
+ <div className={`relative aspect-[4/3] md:aspect-auto md:h-full min-h-[300px] overflow-hidden order-1 md:order-${imagePosition === 'left' ? '1' : '2'}`}>
+ <img
+ src={slide.imageUrl}
+ alt={slide.title}
+ className="w-full h-full object-cover transition-transform duration-700 ease-out hover:scale-105"
+ onError={(e) => {
+ (e.target as HTMLImageElement).style.display = 'none';
+ }}
+ />
+ </div>
+ </div>
+ ) : (
+ <div className="ui-dark-card group flex h-full flex-col transition-all duration-300 hover:border-pop-pink/30">
+ <div className="aspect-[4/3] overflow-hidden relative">
+ <img
+ src={slide.imageUrl}
+ alt={slide.title}
+ className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
+ onError={(e) => {
+ (e.target as HTMLImageElement).style.display = 'none';
+ }}
+ />
+ </div>
+ <div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6">
+ <div className="space-y-3">
+ <span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-pop-pink">
+ {slide.category}
+ </span>
+ <h3 className="font-serif text-xl font-light tracking-wide text-pop-black">
+ {renderTitle(slide.title) || slide.title}
+ </h3>
+ <p className="line-clamp-3 text-xs font-light leading-relaxed text-pop-black/70 md:text-sm">
+ {slide.description}
+ </p>
+ </div>
+ {slide.buttonText && slide.buttonLink && (
+ <div className="pt-2">
+ <Link
+ to={slide.buttonLink}
+ className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-pop-pink hover:text-pop-black transition-colors group/link"
+ >
+ {slide.buttonText}
+ <span className="group-hover/link:translate-x-1 transition-transform">→</span>
+ </Link>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
</SwiperSlide>
))}
5. apps/website/src/components/dynamic/DynamicPageRenderer.tsx
function EditorialCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const slides = Array.isArray(payload.slides) ? payload.slides.map(toRecord) : []
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const bgClass = backgroundColor ? `bg-pop-${backgroundColor}` : 'bg-surface-page'
const textClass = textColor ? `text-pop-${textColor}` : 'text-text-main'
+ const layout = typeof payload.layout === 'string' ? payload.layout : 'standard'
+ const imagePosition = typeof payload.imagePosition === 'string' ? payload.imagePosition : 'right'
+ const isSplitScreen = layout === 'split_screen'
+
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollBy = (offset: number) => {
if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollBy({ left: offset, behavior: 'smooth' })
+ if (isSplitScreen) {
+ const width = scrollContainerRef.current.clientWidth
+ scrollContainerRef.current.scrollBy({ left: offset > 0 ? width : -width, behavior: 'smooth' })
+ } else {
+ scrollContainerRef.current.scrollBy({ left: offset, behavior: 'smooth' })
+ }
}
}
if (!slides.length) return null
@@ -432,38 +441,755
{slides.map((slide, index) => {
const slideTitle = localizedFromRecord(slide, 'title', resolvedLang)
const description = localizedFromRecord(slide, 'description', resolvedLang)
const category = localizedFromRecord(slide, 'category', resolvedLang)
const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang)
const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : ''
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
- return (
- <article key={index} className="ui-card overflow-hidden group w-[85vw] md:w-[350px] flex-none snap-start">
- {imageUrl ? (
- <div className="relative aspect-[4/3] overflow-hidden">
- <Image
- src={imageUrl}
- alt={slideTitle || 'Editorial slide'}
- fill
- unoptimized
- className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
- />
- </div>
- ) : null}
- <div className="space-y-4 p-6 flex flex-col flex-1 justify-between">
- <div>
- {category && <span className="text-[10px] font-bold uppercase tracking-[0.24em] text-brand-primary block">{category}</span>}
- {slideTitle && <h3 className="text-xl font-bold text-text-main mt-6 group-hover:text-brand-primary transition-colors">{renderAccentTitle(slideTitle)}</h3>}
- {description && <p className="text-base text-muted-text mt-3 whitespace-pre-wrap">{description}</p>}
- </div>
- {buttonText && buttonLink && (
- <div className="pt-2">
- <Link href={buttonLink} className="ui-section-dark-link inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-colors group/link">
- {buttonText} <span className="group-hover/link:translate-x-1 transition-transform">→</span>
- </Link>
- </div>
- )}
- </div>
- </article>
- )
+ return isSplitScreen ? (
+ <article key={index} className={`w-full flex-none snap-start grid grid-cols-1 md:grid-cols-${imagePosition === 'left' ? '[65%_35%]' : '[35%_65%]'} items-stretch border border-border-muted rounded-xl overflow-hidden`}>
+ {/* Text Column */}
+ <div className={`p-8 md:p-12 lg:p-16 flex flex-col justify-center space-y-6 order-2 md:order-${imagePosition === 'left' ? '2' : '1'}`}>
+ {category && <span className="text-[10px] font-bold uppercase tracking-[0.24em] text-brand-primary block">{category}</span>}
+ {slideTitle && <h3 className="text-2xl md:text-3xl lg:text-4xl font-bold text-text-main group-hover:text-brand-primary transition-colors">{renderAccentTitle(slideTitle)}</h3>}
+ {description && <p className="text-sm font-light leading-relaxed text-muted-text whitespace-pre-wrap">{description}</p>}
+ {buttonText && buttonLink && (
+ <div className="pt-2">
+ <Link href={buttonLink} className="ui-section-dark-link inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-colors group/link">
+ {buttonText} <span className="group-hover/link:translate-x-1 transition-transform">→</span>
+ </Link>
+ </div>
+ )}
+ </div>
+ {/* Image Column */}
+ {imageUrl ? (
+ <div className={`relative aspect-[4/3] md:aspect-auto md:h-full min-h-[300px] overflow-hidden order-1 md:order-${imagePosition === 'left' ? '1' : '2'}`}>
+ <Image
+ src={imageUrl}
+ alt={slideTitle || 'Editorial slide'}
+ fill
+ unoptimized
+ className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
+ />
+ </div>
+ ) : null}
+ </article>
+ ) : (
+ <article key={index} className="ui-card overflow-hidden group w-[85vw] md:w-[350px] flex-none snap-start">
+ {imageUrl ? (
+ <div className="relative aspect-[4/3] overflow-hidden">
+ <Image
+ src={imageUrl}
+ alt={slideTitle || 'Editorial slide'}
+ fill
+ unoptimized
+ className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
+ />
+ </div>
+ ) : null}
+ <div className="space-y-4 p-6 flex flex-col flex-1 justify-between">
+ <div>
+ {category && <span className="text-[10px] font-bold uppercase tracking-[0.24em] text-brand-primary block">{category}</span>}
+ {slideTitle && <h3 className="text-xl font-bold text-text-main mt-6 group-hover:text-brand-primary transition-colors">{renderAccentTitle(slideTitle)}</h3>}
+ {description && <p className="text-base text-muted-text mt-3 whitespace-pre-wrap">{description}</p>}
+ </div>
+ {buttonText && buttonLink && (
+ <div className="pt-2">
+ <Link href={buttonLink} className="ui-section-dark-link inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-colors group/link">
+ {buttonText} <span className="group-hover/link:translate-x-1 transition-transform">→</span>
+ </Link>
+ </div>
+ )}
+ </div>
+ </article>
+ )
})}
</div>
5. Verification Method
To verify these changes after implementation:
5.1 Compilation and Type Check
Run the build command for the monorepo from the root directory to ensure type safety:
npm run build
5.2 Dynamic Form Verification
- Access the Admin Panel and navigate to the Page Builder.
- Select or create an
Editorial Carouselsection. - Verify that the
Layoutoption displaysStandard CarouselandSplit Screen. - Select
Split Screenand confirm thatImage Positionis enabled (it should be disabled whenStandard Carouselis chosen). - Switch
Image PositionbetweenLeftandRightand save the page.
5.3 Frontend Verification (Desktop and Mobile)
- Verify the layout renders correctly on desktop:
- For
split_screen, check that exactly 1 slide occupies the width. - Text takes up
35%of the width, and the Image takes up65%. - When
imagePositionisright, text is on the left, image on the right. - When
imagePositionisleft, image is on the left, text on the right.
- For
- Verify layout on mobile:
- Viewport width < 768px should stack the image on top and the text on the bottom for both left and right positions.
- Test compatibility by validating that sections with standard layouts remain untouched (3 columns on desktop, 1.5/2 cards on tablet/mobile).