This commit is contained in:
Your Name committed 2026-06-28 20:18:07 -04:00
1 parent 6096c727d9
commit 3965f4f4da
38 files changed
+1513 -752

No files matched your search

+157 -498
View File
@@ -1,515 +1,174 @@
# 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.
---
# Handoff: update_category_image Feature Request Analysis
## 1. Observation
From inspecting the codebase, the following files and lines were analyzed:
* **Prisma Schema (`apps/backend/prisma/schema.prisma` lines 33-42):**
```prisma
model Category {
id Int @id @default(autoincrement())
name String @unique
imageUrl String?
products Product[]
attributes AttributeDefinition[]
translations CategoryTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
*Observation:* The database schema model `Category` already contains a nullable `imageUrl` field.
* **Shared Types (`packages/types/index.ts` lines 9-17):**
```typescript
export interface Category {
id: number
name: TranslatableString
imageUrl?: string | null
_count?: { products: number }
translations?: { langCode: string; name: string }[]
createdAt?: string
updatedAt?: string
}
```
*Observation:* The shared TypeScript types for `Category` already declare `imageUrl` as optional/nullable (`imageUrl?: string | null`).
* **Backend Category Routes (`apps/backend/src/routes/categories.ts` lines 190-218):**
The existing PUT route performs full/partial category updates:
```typescript
.put("/:id", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
We directly investigated five code locations:
### 1.1 Types Definition
- **File**: `packages/types/index.ts`
- **Lines**: 375-388
- **Current schema code**:
```typescript
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**:
```typescript
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: { ... }
try {
const category = await prisma.category.update({
where: { id },
data: {
name: body.name,
imageUrl: body.imageUrl,
},
});
return category;
...
}, {
body: t.Object({
name: t.Optional(t.String()),
imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
})
})
```
* **MCP Server Schema and Handler (`apps/mcp-server/index.ts` lines 121-132, 862-870):**
Exposed categories tools like `update_category_translation` define and route requests via a custom axios `api` instance:
```typescript
// Tool definition
{
name: "update_category_translation",
description: "Add or update translations for a specific category. Do NOT use this tool for products.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" },
name: { type: "string", description: "Translated category name" }
},
required: ["id", "lang", "name"]
}
```
- **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 calls `PUT /pages/sections/:id`.
### 1.3 Backend Section Validation
- **File**: `apps/backend/src/routes/pages.ts`
- **Lines**: 451-465 (under `PUT /sections/:sectionId`), 536-578 (inside `validateSectionPayload`)
- **Validation logic**:
```typescript
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 };
// Tool handler
case "update_category_translation": {
const { id, lang, name } = params as any;
const res = await api.patch(`/categories/${id}/translations`, {
name: { [lang]: name }
});
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
};
}
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 `setEditingSection` by merging state:
```tsx
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 `Swiper` with standard slides-per-view breakpoints (slidesPerView: 1, 2, or 3).
- Uses theme token-mapped utility classes: `bg-pop-bg` (resolved via `bgMap`), `text-pop-black` (resolved via `textMap`).
- **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-card` with `w-[85vw] md:w-[350px]`.
---
```
## 2. Logic Chain
1. **Schema Extension (Types)**: To allow the layout properties (`layout` and `imagePosition`) to be saved, we must add them to `EditorialCarouselSectionPayloadSchema` in `packages/types/index.ts`. They must be `.optional()` so that existing sections in the database remain valid.
2. **Backend Validation Validation & Tool Exposure**: Since `pages.ts` validates payload records using `safeParse` against 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 of `update_editorial_carousel` in `apps/mcp-server/section-toolkit.ts` to expose `layout` and `imagePosition` to AI agents.
3. **Form Fields (Admin)**: To enable administrative control over these layout configurations, we can add two select fields (`Layout` and `Image Position`) to the styling settings section in `EditorialCarouselForm.tsx` using the same merging style as `backgroundColor`. To enhance UX, the `Image Position` field can be disabled unless `layout === 'split_screen'`.
4. **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`), and `scrollBy` must scroll by the full width of the container `scrollContainerRef.current.clientWidth`.
- On desktop, CSS Grid will split the slide `35%` text / `65%` image. CSS `order` property will arrange them so that `imagePosition: 'right'` renders text-left/image-right, and `imagePosition: 'left'` renders image-left/text-right.
- On mobile, CSS `order` will force the image to stack on top and text on the bottom.
---
1. Since the Category model in the database (`prisma/schema.prisma`) and the `Category` type interface in `packages/types/index.ts` already support the `imageUrl` field, no modifications to database structures, Prisma migrations, or the types package are needed.
2. In `apps/backend/src/routes/categories.ts`, we need to implement `PATCH /categories/:id/image`. It should accept a body with `imageUrl: t.Union([t.String(), t.Null()])` to update/clear the category image.
3. In `apps/mcp-server/index.ts`, we need to define the `update_category_image` tool schema under `ListToolsRequestSchema` and map its invocation under `CallToolRequestSchema`'s switch-case block, forwarding the payload to the new backend PATCH endpoint.
## 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_screen` layout, standard Swiper arrows overlap the image/text on the far edges. Swiper handles pagination bullet styling, which has been configured to match `bg-pop-pink`.
---
* **In-place backend compilation:** The backend TypeScript config includes `"declaration": true` without `"outDir"`, meaning transpiled files (`.js`, `.d.ts`, and `.js.map`) are produced directly inside the `src/` directory. If these transpiled files are checked into the repository, they should be regenerated after modifying `.ts` files by running `npm run build` or similar.
* **JSON Schema type support:** The MCP tool definition schema uses `type: ["string", "null"]` for `imageUrl` to explicitly support clearing images. If an MCP client doesn't support array types, passing an empty string `""` is also a valid fallback.
## 4. Conclusion
To implement the `update_category_image` feature:
1. Implement the following route in `apps/backend/src/routes/categories.ts` (e.g. before the `.post("/")` route):
```typescript
.patch("/:id/image", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
The layout configuration changes are backward-compatible and require updates to:
1. `packages/types/index.ts`
2. `apps/mcp-server/section-toolkit.ts`
3. `apps/admin/src/components/page-builder/EditorialCarouselForm.tsx`
4. `apps/spa/src/components/DynamicEditorialCarousel.tsx`
5. `apps/website/src/components/dynamic/DynamicPageRenderer.tsx`
### Proposed Code Diffs
#### 1. packages/types/index.ts
```diff
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
```diff
{
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
```diff
<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
```diff
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">&rarr;</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">&rarr;</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">&rarr;</span>
+ </Link>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
</SwiperSlide>
))}
```
#### 5. apps/website/src/components/dynamic/DynamicPageRenderer.tsx
```diff
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' })
+ }
try {
const category = await prisma.category.update({
where: { id },
data: {
imageUrl: body.imageUrl,
},
});
return category;
} catch (err: any) {
if (err.code === "P2025") {
return status(404, { message: "Category not found" });
}
console.error("Update category image error:", err);
return status(500, { message: "Failed to update category image" });
}
}
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">&rarr;</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">&rarr;</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">&rarr;</span>
+ </Link>
+ </div>
+ )}
+ </div>
+ </article>
+ )
})}
</div>
```
}, {
body: t.Object({
imageUrl: t.Union([t.String(), t.Null()]),
})
})
```
---
2. Register the tool in `apps/mcp-server/index.ts` under `ListToolsRequestSchema` tools list:
```typescript
{
name: "update_category_image",
description: "Update the image URL for a specific category.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
imageUrl: { type: ["string", "null"], description: "New image URL, or null/empty string to remove the image" }
},
required: ["id", "imageUrl"]
}
},
```
3. Implement the handler in `apps/mcp-server/index.ts` under `CallToolRequestSchema`'s `switch (request.params.name)` block:
```typescript
case "update_category_image": {
const { id, imageUrl } = params as any;
const res = await api.patch(`/categories/${id}/image`, {
imageUrl: imageUrl || null
});
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
};
}
```
## 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:
```bash
npm run build
```
### 5.2 Dynamic Form Verification
1. Access the Admin Panel and navigate to the Page Builder.
2. Select or create an `Editorial Carousel` section.
3. Verify that the `Layout` option displays `Standard Carousel` and `Split Screen`.
4. Select `Split Screen` and confirm that `Image Position` is enabled (it should be disabled when `Standard Carousel` is chosen).
5. Switch `Image Position` between `Left` and `Right` and save the page.
### 5.3 Frontend Verification (Desktop and Mobile)
1. 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 up `65%`.
- When `imagePosition` is `right`, text is on the left, image on the right.
- When `imagePosition` is `left`, image is on the left, text on the right.
2. 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.
3. Test compatibility by validating that sections with standard layouts remain untouched (3 columns on desktop, 1.5/2 cards on tablet/mobile).
* **Endpoint Verification:**
Start the backend server (`bun run dev` in `apps/backend`). Call the new endpoint using `curl`:
```bash
curl -X PATCH http://localhost:3051/api/categories/1/image \
-H "Content-Type: application/json" \
-d '{"imageUrl": "https://example.com/test-image.png"}'
```
Verify that the response returns the updated category object with `imageUrl` matching the provided URL.
* **MCP Tool Verification:**
Start the MCP server and run the tool `update_category_image` via an MCP client:
```json
{
"name": "update_category_image",
"arguments": {
"id": 1,
"imageUrl": "https://example.com/test-image.png"
}
}
```
Verify that the tool succeeds and returns the JSON representation of the updated category.