From 0d4df55d9313056f52999f46def67f7ba0e06b71 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 19:39:38 -0400 Subject: [PATCH] x --- LLM_MCP_GUIDE.md | 9 + apps/admin/src/pages/PageBuilderPage.tsx | 102 ++++++++- apps/backend/src/routes/pages.ts | 20 ++ apps/mcp-server/section-toolkit.test.ts | 23 ++ apps/mcp-server/section-toolkit.ts | 114 +++++++++- .../dynamic/DynamicPageRenderer.tsx | 199 ++++++++++++++++++ packages/types/index.ts | 62 +++++- 7 files changed, 526 insertions(+), 3 deletions(-) diff --git a/LLM_MCP_GUIDE.md b/LLM_MCP_GUIDE.md index ac05685..d9ecca1 100644 --- a/LLM_MCP_GUIDE.md +++ b/LLM_MCP_GUIDE.md @@ -103,6 +103,15 @@ Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate. - `width` accepts `narrow`, `standard`, or `wide` - `background` accepts `default` or `muted` +### Article composition blocks + +- `ARTICLE_HEADER`: article title, subtitle, category, author, date, and optional cover image +- `ARTICLE_GALLERY`: 2–8 images with required localized alt text; layouts are `grid` or `featured` +- `ARTICLE_FACTS`: 1–8 concise facts or key takeaways +- `ARTICLE_VIDEO`: HTTPS YouTube, Vimeo, or direct video URL; embed HTML is not accepted +- `ARTICLE_REFERENCES`: 1–20 references with optional verified HTTPS URLs and notes +- Reuse `QUOTE_CALLOUT`, `ACCORDION`, and `PRODUCT_CAROUSEL` instead of creating duplicate article-only variants + ### `SCENT_NOTES_BREAKDOWN` - `title` and `description` are localized diff --git a/apps/admin/src/pages/PageBuilderPage.tsx b/apps/admin/src/pages/PageBuilderPage.tsx index cb7316f..f9551eb 100644 --- a/apps/admin/src/pages/PageBuilderPage.tsx +++ b/apps/admin/src/pages/PageBuilderPage.tsx @@ -31,6 +31,13 @@ import TextForm from '../components/page-builder/TextForm'; import HeroArticleForm from '../components/page-builder/HeroArticleForm'; import RichTextForm from '../components/page-builder/RichTextForm'; import ArticleMediaForm from '../components/page-builder/ArticleMediaForm'; +import { + ArticleFactsForm, + ArticleGalleryForm, + ArticleHeaderForm, + ArticleReferencesForm, + ArticleVideoForm, +} from '../components/page-builder/ArticleContentForms'; import ScentNotesBreakdownForm from '../components/page-builder/ScentNotesBreakdownForm'; import QuoteCalloutForm from '../components/page-builder/QuoteCalloutForm'; import ProductCarouselForm from '../components/page-builder/ProductCarouselForm'; @@ -71,7 +78,7 @@ export default function PageBuilderPage() { const [brands, setBrands] = useState([]); const [loading, setLoading] = useState(true); const [editingSection, setEditingSection] = useState | null>(null); - const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null); + const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery'; index?: number } | null>(null); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [isSavingPageSettings, setIsSavingPageSettings] = useState(false); const [isReorderModalOpen, setIsReorderModalOpen] = useState(false); @@ -87,6 +94,14 @@ export default function PageBuilderPage() { currentSlides[idx] = { ...currentSlides[idx], imageUrl: url }; return { ...prev, payload: { ...(prev.payload || {}), slides: currentSlides } }; }); + } else if (uploadTarget?.type === 'gallery' && typeof uploadTarget.index === 'number') { + const idx = uploadTarget.index; + setEditingSection(prev => { + if (!prev) return null; + const images = [...(prev.payload?.images || [])]; + images[idx] = { ...(images[idx] || {}), imageUrl: url }; + return { ...prev, payload: { ...(prev.payload || {}), images } }; + }); } else { setEditingSection(prev => { if (!prev) return null; @@ -642,6 +657,27 @@ export default function PageBuilderPage() { background: 'default', }; break; + case 'ARTICLE_HEADER': + defaultPayload = { title: 'Article title', subtitle: 'A concise introduction to the story.' }; + break; + case 'ARTICLE_GALLERY': + defaultPayload = { + layout: 'grid', + images: [ + { imageUrl: '/images/hero.png', alt: 'Gallery image one' }, + { imageUrl: '/images/oil_ozone.png', alt: 'Gallery image two' }, + ], + }; + break; + case 'ARTICLE_FACTS': + defaultPayload = { title: 'Key facts', items: [{ title: 'Important fact', description: 'Supporting detail.' }] }; + break; + case 'ARTICLE_VIDEO': + defaultPayload = { videoUrl: 'https://www.youtube.com/watch?v=VIDEO_ID', aspectRatio: '16:9' }; + break; + case 'ARTICLE_REFERENCES': + defaultPayload = { title: 'References', items: [{ title: 'Source title' }] }; + break; case 'SCENT_NOTES_BREAKDOWN': defaultPayload = { title: 'Scent Notes', @@ -1085,6 +1121,11 @@ export default function PageBuilderPage() { + + + + + @@ -1262,6 +1303,65 @@ export default function PageBuilderPage() { /> )} + {editingSection.type?.toUpperCase() === 'ARTICLE_HEADER' && ( + + )} + + {editingSection.type?.toUpperCase() === 'ARTICLE_GALLERY' && ( + + )} + + {editingSection.type?.toUpperCase() === 'ARTICLE_FACTS' && ( + + )} + + {editingSection.type?.toUpperCase() === 'ARTICLE_VIDEO' && ( + + )} + + {editingSection.type?.toUpperCase() === 'ARTICLE_REFERENCES' && ( + + )} + {editingSection.type?.toUpperCase() === 'SCENT_NOTES_BREAKDOWN' && ( { }); }); + test("ARTICLE_GALLERY rejects fewer than two images", () => { + const result = validatePayloadAgainstMetadata("ARTICLE_GALLERY", { + images: [{ imageUrl: "/uploads/one.jpg", alt: { th: "ภาพเดียว" } }], + }); + + expect(result.valid).toBe(false); + expect(result.validationIssues.some((issue) => issue.path === "images")).toBe(true); + }); + + test("ARTICLE_VIDEO rejects non-HTTPS URLs", () => { + const result = validatePayloadAgainstMetadata("ARTICLE_VIDEO", { + videoUrl: "http://example.com/video.mp4", + }); + + expect(result.valid).toBe(false); + expect(result.validationIssues.some((issue) => issue.path === "videoUrl")).toBe(true); + }); + test("create_page_with_section rejects invalid payloads without calling backend", async () => { let postCalls = 0; const api = { diff --git a/apps/mcp-server/section-toolkit.ts b/apps/mcp-server/section-toolkit.ts index e952193..3e2e1c3 100644 --- a/apps/mcp-server/section-toolkit.ts +++ b/apps/mcp-server/section-toolkit.ts @@ -18,7 +18,12 @@ const WEBSITE_PAGE_SECTION_TYPE_LIST = [ "BANNER", "HERO_ARTICLE", "RICH_TEXT", + "ARTICLE_HEADER", "ARTICLE_MEDIA", + "ARTICLE_GALLERY", + "ARTICLE_FACTS", + "ARTICLE_VIDEO", + "ARTICLE_REFERENCES", "SCENT_NOTES_BREAKDOWN", "QUOTE_CALLOUT", "PRODUCT_CAROUSEL", @@ -326,6 +331,93 @@ const PAGE_BLOCK_METADATA: Record = { background: "default", }, }, + ARTICLE_HEADER: { + type: "ARTICLE_HEADER", + requiredFields: ["title"], + optionalFields: ["subtitle", "category", "author", "publishDate", "imageUrl", "imageAlt"], + renderedFields: ["title", "subtitle", "category", "author", "publishDate", "imageUrl", "imageAlt"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["imageAlt is required whenever imageUrl is provided."], + minimalPayload: { title: localizedObjectExample }, + recommendedPayload: { + title: { th: "เทคโนโลยีที่อยู่เบื้องหลังทุกการขับเคลื่อน", en: "Technology behind every ride" }, + subtitle: { th: "ทำความรู้จักกระบวนการผลิตที่ให้ความสำคัญกับความแม่นยำ", en: "A closer look at precision manufacturing." }, + category: { th: "เทคโนโลยี", en: "Technology" }, + author: { th: "ทีมงาน DDC", en: "DDC Team" }, + publishDate: "2026-07-24", + }, + }, + ARTICLE_GALLERY: { + type: "ARTICLE_GALLERY", + requiredFields: ["images", "images[].imageUrl", "images[].alt"], + optionalFields: ["title", "layout", "images[].caption"], + renderedFields: ["title", "layout", "images"], + storedOnlyFields: [], + discoveryToolsNeeded: ["Use real CMS media URLs; do not invent image URLs."], + warnings: ["Provide 2 to 8 images. Every image requires localized alt text."], + minimalPayload: { + images: [ + { imageUrl: imageUrlExample, alt: localizedObjectExample }, + { imageUrl: imageUrlExample, alt: localizedObjectExample }, + ], + }, + recommendedPayload: { + title: { th: "ภาพจากกระบวนการผลิต", en: "Inside the production process" }, + layout: "grid", + images: [ + { imageUrl: imageUrlExample, alt: { th: "ขั้นตอนการผลิตชิ้นงาน", en: "Component production" } }, + { imageUrl: imageUrlExample, alt: { th: "การตรวจสอบคุณภาพ", en: "Quality inspection" } }, + ], + }, + }, + ARTICLE_FACTS: { + type: "ARTICLE_FACTS", + requiredFields: ["items", "items[].title"], + optionalFields: ["title", "items[].description"], + renderedFields: ["title", "items"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Provide 1 to 8 concise facts."], + minimalPayload: { items: [{ title: localizedObjectExample }] }, + recommendedPayload: { + title: { th: "ข้อมูลสำคัญ", en: "Key facts" }, + items: [ + { title: { th: "ผิวตัดสม่ำเสมอ", en: "Consistent cut surface" }, description: { th: "ช่วยลดขั้นตอนการแต่งผิว", en: "Reduces secondary finishing." } }, + { title: { th: "รูปทรงแม่นยำ", en: "Precise geometry" }, description: { th: "ช่วยให้การขบกับโซ่ต่อเนื่อง", en: "Supports consistent chain engagement." } }, + ], + }, + }, + ARTICLE_VIDEO: { + type: "ARTICLE_VIDEO", + requiredFields: ["videoUrl"], + optionalFields: ["title", "caption", "posterUrl", "aspectRatio"], + renderedFields: ["title", "videoUrl", "caption", "posterUrl", "aspectRatio"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Use a full HTTPS YouTube, Vimeo, or direct video URL. Do not send embed HTML."], + minimalPayload: { videoUrl: "https://www.youtube.com/watch?v=VIDEO_ID" }, + recommendedPayload: { + title: { th: "ชมกระบวนการผลิต", en: "Watch the production process" }, + videoUrl: "https://www.youtube.com/watch?v=VIDEO_ID", + caption: { th: "เบื้องหลังการผลิตที่แม่นยำ", en: "Behind precision manufacturing" }, + aspectRatio: "16:9", + }, + }, + ARTICLE_REFERENCES: { + type: "ARTICLE_REFERENCES", + requiredFields: ["items", "items[].title"], + optionalFields: ["title", "items[].url", "items[].note"], + renderedFields: ["title", "items"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Use verified source URLs only. A reference may omit url for offline sources."], + minimalPayload: { items: [{ title: localizedObjectExample }] }, + recommendedPayload: { + title: { th: "แหล่งข้อมูล", en: "References" }, + items: [{ title: { th: "เอกสารข้อมูลทางเทคนิค", en: "Technical documentation" }, note: { th: "ข้อมูลจากฝ่ายวิศวกรรม", en: "Engineering source material" } }], + }, + }, SCENT_NOTES_BREAKDOWN: { type: "SCENT_NOTES_BREAKDOWN", requiredFields: [], @@ -555,6 +647,26 @@ const ADD_PAGE_SECTION_GUIDANCE: Record, lang ) } +function ArticleHeaderSection({ payload, lang }: { payload: Record, lang: string }) { + const i18nLang = useLang() + const resolvedLang = lang === 'all' ? i18nLang : lang + const title = localizedFromRecord(payload, 'title', resolvedLang) + const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang) + const category = localizedFromRecord(payload, 'category', resolvedLang) + const author = localizedFromRecord(payload, 'author', resolvedLang) + const imageAlt = localizedFromRecord(payload, 'imageAlt', resolvedLang) + const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '' + const publishDate = typeof payload.publishDate === 'string' ? payload.publishDate : '' + + if (!title) return null + + return ( +
+
+
+ {category &&

{category}

} +

{title}

+ {subtitle &&

{subtitle}

} + {(author || publishDate) && ( +

+ {[author, publishDate].filter(Boolean).join(' · ')} +

+ )} +
+ {imageUrl && ( +
+ {imageAlt +
+ )} +
+
+ ) +} + function ArticleMediaSection({ payload, lang }: { payload: Record, lang: string }) { const i18nLang = useLang() const resolvedLang = lang === 'all' ? i18nLang : lang @@ -547,6 +583,159 @@ function ArticleMediaSection({ payload, lang }: { payload: Record, lang: string }) { + const i18nLang = useLang() + const resolvedLang = lang === 'all' ? i18nLang : lang + const title = localizedFromRecord(payload, 'title', resolvedLang) + const images = Array.isArray(payload.images) ? payload.images : [] + const featured = payload.layout === 'featured' + + if (images.length < 2) return null + + return ( +
+
+ {title &&

{title}

} +
+ {images.map((image, index) => { + const item = toRecord(image) + const imageUrl = typeof item.imageUrl === 'string' ? item.imageUrl : '' + const alt = localizedFromRecord(item, 'alt', resolvedLang) + const caption = localizedFromRecord(item, 'caption', resolvedLang) + if (!imageUrl) return null + return ( +
+
+ {alt} +
+ {caption &&
{caption}
} +
+ ) + })} +
+
+
+ ) +} + +function ArticleFactsSection({ payload, lang }: { payload: Record, lang: string }) { + const i18nLang = useLang() + const resolvedLang = lang === 'all' ? i18nLang : lang + const title = localizedFromRecord(payload, 'title', resolvedLang) + const items = Array.isArray(payload.items) ? payload.items : [] + + if (items.length === 0) return null + + return ( +
+
+ {title &&

{title}

} +
+ {items.map((item, index) => { + const record = toRecord(item) + const itemTitle = localizedFromRecord(record, 'title', resolvedLang) + const description = localizedFromRecord(record, 'description', resolvedLang) + return ( +
+
{itemTitle}
+ {description &&
{description}
} +
+ ) + })} +
+
+
+ ) +} + +function getVideoEmbedUrl(videoUrl: string) { + try { + const url = new URL(videoUrl) + if (url.hostname === 'youtu.be') return `https://www.youtube.com/embed/${url.pathname.slice(1)}` + if (url.hostname === 'youtube.com' || url.hostname.endsWith('.youtube.com')) { + const id = url.searchParams.get('v') + return id ? `https://www.youtube.com/embed/${id}` : '' + } + if (url.hostname === 'vimeo.com' || url.hostname.endsWith('.vimeo.com')) { + const id = url.pathname.split('/').filter(Boolean).pop() + return id ? `https://player.vimeo.com/video/${id}` : '' + } + } catch { + return '' + } + return '' +} + +function ArticleVideoSection({ payload, lang }: { payload: Record, lang: string }) { + const i18nLang = useLang() + const resolvedLang = lang === 'all' ? i18nLang : lang + const title = localizedFromRecord(payload, 'title', resolvedLang) + const caption = localizedFromRecord(payload, 'caption', resolvedLang) + const videoUrl = typeof payload.videoUrl === 'string' ? payload.videoUrl : '' + const posterUrl = typeof payload.posterUrl === 'string' ? payload.posterUrl : undefined + const embedUrl = getVideoEmbedUrl(videoUrl) + const aspectClass = payload.aspectRatio === '4:3' ? 'aspect-[4/3]' : payload.aspectRatio === '1:1' ? 'aspect-square' : 'aspect-video' + + if (!videoUrl) return null + + return ( +
+
+ {title &&

{title}

} +
+ {embedUrl ? ( +