x
This commit is contained in:
1 parent
6096c727d9
commit
3965f4f4da
38 files changed
+1513
-752
No files matched your search
@@ -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">→</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
|
||||
```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">→</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>
|
||||
```
|
||||
}, {
|
||||
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.
|
||||
Reference in new issue
Block a user