LinkEditor.tsx 76 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028
  1. import * as React from 'react';
  2. // FIX: Import FormBlock and related types to handle form creation and editing.
  3. import { Block, BlockType, LinkBlock, HeaderBlock, SocialBlock, VideoBlock, ImageBlock, TextBlock, MapBlock, PdfBlock, SocialLink, MediaSource, EmailBlock, PhoneBlock, AIGCVideo, AIGCArticle, NewsBlock, NewsItemFromUrl, ProductBlock, ProductItem, SocialPlatform, ChatBlock, EnterpriseInfoBlock, EnterpriseInfoItem, EnterpriseInfoIcon, FormBlock, FormField, FormFieldId, FormPurposeOption, AwardBlock, AwardItem, FooterBlock, FooterLink } from '../types';
  4. import { Icon } from './ui/Icon';
  5. import { parseProductUrl, mockProductDatabase } from '../services/geminiService';
  6. const blockTypeMetadata: Record<BlockType, { icon: JSX.Element; name: string }> = {
  7. link: { icon: <path d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />, name: 'Link' },
  8. header: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m-4-16v16m8-16v16M4 4h16M4 20h16" />, name: 'Header' },
  9. text: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />, name: 'Text' },
  10. social: { icon: <path d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />, name: 'Social Icons' },
  11. chat: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />, name: 'Chat Button' },
  12. enterprise_info: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h6.375a.375.375 0 01.375.375v1.5a.375.375 0 01-.375.375H9a.375.375 0 01-.375-.375v-1.5A.375.375 0 019 6.75zM9 12.75h6.375a.375.375 0 01.375.375v1.5a.375.375 0 01-.375.375H9a.375.375 0 01-.375-.375v-1.5A.375.375 0 019 12.75z" />, name: 'Enterprise Info'},
  13. video: { icon: <path d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />, name: 'Video' },
  14. image: { icon: <path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />, name: 'Image' },
  15. map: { icon: <path d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 9m-6 3l6-3" />, name: 'Map' },
  16. pdf: { icon: <path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />, name: 'PDF' },
  17. email: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />, name: 'Email' },
  18. phone: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />, name: 'Phone' },
  19. news: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3h3m-3 4h3m-3 4h3" />, name: 'News' },
  20. product: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />, name: 'Product' },
  21. award: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M9 21h6m-3-4v4m-3-11h6m-3 0V4M3 11h18M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />, name: 'Awards' },
  22. form: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />, name: 'Form' },
  23. footer: { icon: <path strokeLinecap="round" strokeLinejoin="round" d="M5 21V3m0 18h14V3m-7 18v-8" />, name: 'Footer' },
  24. };
  25. const AddBlockModal: React.FC<{ onSelect: (type: BlockType) => void; onClose: () => void; hasChatBlock: boolean; hasFooterBlock: boolean; }> = ({ onSelect, onClose, hasChatBlock, hasFooterBlock }) => {
  26. return (
  27. <div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center backdrop-blur-sm" onClick={onClose}>
  28. <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl p-8 border border-gray-200 dark:border-gray-700" onClick={e => e.stopPropagation()}>
  29. <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Add Block</h2>
  30. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  31. {(Object.keys(blockTypeMetadata) as BlockType[]).map(type => {
  32. const isDisabled = (type === 'chat' && hasChatBlock) || (type === 'footer' && hasFooterBlock);
  33. return (
  34. <button key={type} onMouseDown={(e) => e.preventDefault()} onClick={() => !isDisabled && onSelect(type)} className={`flex flex-col items-center justify-center p-4 bg-gray-100 dark:bg-gray-800 rounded-lg transition-colors text-gray-700 dark:text-gray-300 aspect-square ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-brand-primary'}`}>
  35. <Icon className="h-8 w-8 mb-2">{blockTypeMetadata[type].icon}</Icon>
  36. <span className="text-sm font-semibold">{blockTypeMetadata[type].name}</span>
  37. </button>
  38. )
  39. })}
  40. </div>
  41. </div>
  42. </div>
  43. );
  44. };
  45. const LayoutToggle: React.FC<{ label: string; options: {label: string, value: string}[]; value: string; onLayoutChange: (value: any) => void; }> = ({ label, options, value, onLayoutChange }) => (
  46. <div>
  47. <label className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2 block">{label}</label>
  48. <div className="flex items-center gap-1 bg-gray-200 dark:bg-gray-700 p-1 rounded-lg">
  49. {options.map(opt => (
  50. <button key={opt.value} onMouseDown={(e) => e.preventDefault()} onClick={() => onLayoutChange(opt.value)} className={`flex-1 px-3 py-1 text-sm rounded-md capitalize transition-colors ${value === opt.value ? 'bg-brand-primary text-white shadow' : 'hover:bg-gray-300 dark:hover:bg-gray-600'}`}>{opt.label}</button>
  51. ))}
  52. </div>
  53. </div>
  54. );
  55. const SourceTypeToggle: React.FC<{ isVideo: boolean, type: 'url' | 'file' | 'aigc'; onTypeChange: (type: any) => void; onAIGCOpen: () => void; }> = ({ isVideo, type, onTypeChange, onAIGCOpen }) => (
  56. <div className="flex items-center gap-2">
  57. <label className="text-sm font-semibold text-gray-600 dark:text-gray-300">Source:</label>
  58. <button onMouseDown={(e) => e.preventDefault()} onClick={() => onTypeChange('url')} className={`px-3 py-1 text-sm rounded-md ${type === 'url' ? 'bg-brand-primary text-white' : 'bg-gray-200 dark:bg-gray-600'}`}>URL</button>
  59. <button onMouseDown={(e) => e.preventDefault()} onClick={() => onTypeChange('file')} className={`px-3 py-1 text-sm rounded-md ${type === 'file' ? 'bg-brand-primary text-white' : 'bg-gray-200 dark:bg-gray-600'}`}>Upload</button>
  60. {isVideo && <button onMouseDown={(e) => e.preventDefault()} onClick={onAIGCOpen} className={`px-3 py-1 text-sm rounded-md ${type === 'aigc' ? 'bg-brand-primary text-white' : 'bg-gray-200 dark:bg-gray-600'}`}>AIGC Library</button>}
  61. </div>
  62. );
  63. const AIGCNewsLibraryModal: React.FC<{
  64. articles: AIGCArticle[];
  65. selectedArticleIds: string[];
  66. onClose: () => void;
  67. onSave: (selectedIds: string[]) => void;
  68. }> = ({ articles, selectedArticleIds, onClose, onSave }) => {
  69. const [localSelected, setLocalSelected] = React.useState(selectedArticleIds);
  70. const toggleSelection = (id: string) => {
  71. setLocalSelected(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
  72. };
  73. return (
  74. <div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center backdrop-blur-sm" onClick={onClose}>
  75. <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-4xl h-[80vh] p-8 border border-gray-200 dark:border-gray-700 flex flex-col" onClick={e => e.stopPropagation()}>
  76. <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 flex-shrink-0">Select News Articles</h2>
  77. <div className="flex-1 overflow-y-auto pr-4 -mr-4">
  78. {articles.length === 0 ? (
  79. <p className="text-center text-gray-400 dark:text-gray-500 py-12">No articles in your library. Create some in the AIGC News Creator!</p>
  80. ) : (
  81. <div className="space-y-3">
  82. {articles.map(article => (
  83. <button key={article.id} onClick={() => toggleSelection(article.id)} className={`w-full text-left p-4 rounded-lg border transition-colors flex items-center gap-4 ${localSelected.includes(article.id) ? 'bg-brand-primary/10 border-brand-primary' : 'bg-gray-100 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-400'}`}>
  84. <div className={`w-5 h-5 rounded-sm flex-shrink-0 flex items-center justify-center border-2 ${localSelected.includes(article.id) ? 'bg-brand-primary border-brand-primary' : 'bg-white dark:bg-gray-800 border-gray-300'}`}>
  85. {localSelected.includes(article.id) && <Icon className="h-4 w-4 text-white"><path d="M5 13l4 4L19 7" /></Icon>}
  86. </div>
  87. <div className="flex-1 min-w-0">
  88. <p className="font-semibold truncate">{article.title}</p>
  89. <p className="text-sm text-gray-500 dark:text-gray-400 truncate">{article.summary}</p>
  90. </div>
  91. </button>
  92. ))}
  93. </div>
  94. )}
  95. </div>
  96. <div className="flex justify-end gap-4 mt-6 flex-shrink-0">
  97. <button onClick={onClose} className="px-4 py-2 rounded-md font-semibold bg-gray-200 dark:bg-gray-700">Cancel</button>
  98. <button onClick={() => onSave(localSelected)} className="px-4 py-2 rounded-md font-semibold bg-brand-primary text-white">Save Selection</button>
  99. </div>
  100. </div>
  101. </div>
  102. );
  103. };
  104. const SparkleIcon = () => (
  105. <Icon className="h-4 w-4 text-brand-primary">
  106. <path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM18 15.75l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 20l-1.035.259a3.375 3.375 0 00-2.456 2.456L18 23.75l-.259-1.035a3.375 3.375 0 00-2.456-2.456L14.25 20l1.035-.259a3.375 3.375 0 002.456-2.456L18 15.75z" />
  107. </Icon>
  108. );
  109. const BlockItemEditor: React.FC<{ block: Block; onUpdate: (updatedBlock: Block) => void; onAIGCOpen: () => void, aigcVideos: AIGCVideo[]; aigcArticles: AIGCArticle[] }> = ({ block, onUpdate, onAIGCOpen, aigcVideos, aigcArticles }) => {
  110. const inputClasses = "w-full bg-gray-100 dark:bg-gray-700 p-2 rounded-md border-2 border-gray-300 dark:border-gray-600 focus:outline-none focus:border-brand-primary";
  111. const labelClasses = "text-sm font-semibold text-gray-600 dark:text-gray-300";
  112. const handleUrlBlur = (e: React.FocusEvent<HTMLInputElement>) => {
  113. const { value: url } = e.target;
  114. const linkBlock = block as LinkBlock;
  115. if (!url || !url.startsWith('http')) {
  116. onUpdate({ ...linkBlock, iconUrl: undefined, url });
  117. return;
  118. }
  119. try {
  120. const domain = new URL(url).hostname;
  121. const iconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
  122. onUpdate({ ...linkBlock, iconUrl, url });
  123. } catch (error) {
  124. console.error("Invalid URL for favicon:", error);
  125. onUpdate({ ...linkBlock, iconUrl: undefined, url });
  126. }
  127. };
  128. switch (block.type) {
  129. case 'header': {
  130. const headerBlock = block as HeaderBlock;
  131. return <div className="space-y-4">
  132. <div><label className={labelClasses}>Header Text</label><input type="text" value={headerBlock.text} onChange={e => onUpdate({ ...block, text: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  133. <div><LayoutToggle label="Alignment" options={[{label: 'Left', value: 'left'}, {label: 'Center', value: 'center'}]} value={headerBlock.titleAlignment || 'left'} onLayoutChange={align => onUpdate({ ...headerBlock, titleAlignment: align })} /></div>
  134. </div>
  135. }
  136. case 'link': {
  137. const linkBlock = block as LinkBlock;
  138. return <div className="space-y-4">
  139. <div><label className={labelClasses}>Title</label><input type="text" value={linkBlock.title} onChange={e => onUpdate({ ...block, title: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  140. <div><label className={labelClasses}>URL</label><input type="url" defaultValue={linkBlock.url} onBlur={handleUrlBlur} className={`${inputClasses} mt-1`} /></div>
  141. <div><label className={labelClasses}>Thumbnail URL (Optional)</label><input type="url" placeholder="https://..." value={linkBlock.thumbnailUrl || ''} onChange={e => onUpdate({ ...block, thumbnailUrl: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  142. </div>
  143. }
  144. case 'chat': {
  145. const chatBlock = block as ChatBlock;
  146. return (
  147. <div className="space-y-4">
  148. <LayoutToggle
  149. label="Display Style"
  150. options={[
  151. {label: 'Under Avatar', value: 'under_avatar'},
  152. {label: 'Block Button', value: 'button'},
  153. {label: 'Floating Widget', value: 'widget'}
  154. ]}
  155. value={chatBlock.layout}
  156. onLayoutChange={(layout: 'button' | 'under_avatar' | 'widget') => onUpdate({ ...chatBlock, layout })}
  157. />
  158. <p className="text-xs text-gray-500 dark:text-gray-400 -mt-2">
  159. <strong>Under Avatar:</strong> Only visible in Personal mode.<br/>
  160. <strong>Floating Widget:</strong> Recommended for Enterprise mode.<br/>
  161. <strong>Block Button:</strong> A standard button for any mode.
  162. </p>
  163. </div>
  164. )
  165. }
  166. case 'enterprise_info': {
  167. const infoBlock = block as EnterpriseInfoBlock;
  168. const availableIcons: { value: EnterpriseInfoIcon; label: string }[] = [
  169. { value: 'building', label: 'Building' },
  170. { value: 'bank', label: 'Bank' },
  171. { value: 'money', label: 'Money' },
  172. { value: 'location', label: 'Location' },
  173. { value: 'calendar', label: 'Calendar' },
  174. { value: 'users', label: 'Users' },
  175. { value: 'lightbulb', label: 'Lightbulb' },
  176. ];
  177. const handleUpdateItem = (id: string, field: 'icon' | 'label' | 'value', value: string) => {
  178. const updatedItems = infoBlock.items.map(item => item.id === id ? { ...item, [field]: value } : item);
  179. onUpdate({ ...infoBlock, items: updatedItems });
  180. };
  181. const handleAddItem = () => {
  182. const newItem: EnterpriseInfoItem = {
  183. id: `ei-${Date.now()}`,
  184. icon: 'lightbulb',
  185. label: 'New Item',
  186. value: 'Value'
  187. };
  188. onUpdate({ ...infoBlock, items: [...infoBlock.items, newItem] });
  189. };
  190. const handleRemoveItem = (id: string) => {
  191. onUpdate({ ...infoBlock, items: infoBlock.items.filter(item => item.id !== id) });
  192. };
  193. return (
  194. <div className="space-y-4">
  195. <LayoutToggle
  196. label="Alignment"
  197. options={[
  198. {label: 'Left', value: 'left'},
  199. {label: 'Center', value: 'center'},
  200. ]}
  201. value={infoBlock.alignment || 'left'}
  202. onLayoutChange={(align: 'left' | 'center') => onUpdate({ ...infoBlock, alignment: align })}
  203. />
  204. {infoBlock.items.map(item => (
  205. <div key={item.id} className="p-3 bg-gray-100 dark:bg-gray-900 rounded-md space-y-2 relative">
  206. <button onClick={() => handleRemoveItem(item.id)} className="absolute top-2 right-2 text-gray-400 hover:text-red-500"><Icon className="h-4 w-4"><path d="M6 18L18 6M6 6l12 12" /></Icon></button>
  207. <div className="grid grid-cols-1 md:grid-cols-3 gap-2">
  208. <div>
  209. <label className="text-xs font-semibold text-gray-500">Icon</label>
  210. <select value={item.icon} onChange={e => handleUpdateItem(item.id, 'icon', e.target.value)} className="w-full capitalize bg-white dark:bg-gray-700 p-1 text-sm rounded border border-gray-300 dark:border-gray-600">
  211. {availableIcons.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
  212. </select>
  213. </div>
  214. <div className="md:col-span-2">
  215. <label className="text-xs font-semibold text-gray-500">Label</label>
  216. <input type="text" value={item.label} onChange={e => handleUpdateItem(item.id, 'label', e.target.value)} className="w-full bg-white dark:bg-gray-700 p-1 text-sm rounded border border-gray-300 dark:border-gray-600" />
  217. </div>
  218. </div>
  219. <div>
  220. <label className="text-xs font-semibold text-gray-500">Value</label>
  221. <input type="text" value={item.value} onChange={e => handleUpdateItem(item.id, 'value', e.target.value)} className="w-full bg-white dark:bg-gray-700 p-1 text-sm rounded border border-gray-300 dark:border-gray-600" />
  222. </div>
  223. </div>
  224. ))}
  225. <button onClick={handleAddItem} className="w-full justify-center p-2 bg-brand-primary/10 rounded-md text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1">
  226. <Icon className="h-4 w-4"><path d="M12 4v16m8-8H4" /></Icon>Add Info Item
  227. </button>
  228. </div>
  229. )
  230. }
  231. case 'social': {
  232. const socialBlock = block as SocialBlock;
  233. const availablePlatforms: SocialPlatform[] = ['twitter', 'instagram', 'facebook', 'linkedin', 'youtube', 'tiktok', 'github'];
  234. const handleAddLink = () => {
  235. const usedPlatforms = new Set(socialBlock.links.map(l => l.platform));
  236. const nextPlatform = availablePlatforms.find(p => !usedPlatforms.has(p)) || 'twitter';
  237. const newLink: SocialLink = { id: `sl-${Date.now()}`, platform: nextPlatform, url: '' };
  238. onUpdate({ ...socialBlock, links: [...socialBlock.links, newLink] });
  239. };
  240. const handleAddMockLinks = () => {
  241. const mockLinks: SocialLink[] = [
  242. { id: 'sl-mock-twitter', platform: 'twitter', url: 'https://twitter.com/johndoe' },
  243. { id: 'sl-mock-github', platform: 'github', url: 'https://github.com/johndoe' },
  244. { id: 'sl-mock-linkedin', platform: 'linkedin', url: 'https://linkedin.com/in/johndoe' },
  245. ];
  246. const existingPlatforms = new Set(socialBlock.links.map(l => l.platform));
  247. const newMockLinks = mockLinks.filter(ml => !existingPlatforms.has(ml.platform));
  248. onUpdate({ ...socialBlock, links: [...socialBlock.links, ...newMockLinks] });
  249. };
  250. const handleRemoveLink = (linkId: string) => {
  251. onUpdate({ ...socialBlock, links: socialBlock.links.filter(l => l.id !== linkId) });
  252. };
  253. const handleUpdateLink = (linkId: string, field: 'platform' | 'url', value: string) => {
  254. onUpdate({ ...socialBlock, links: socialBlock.links.map(l => l.id === linkId ? { ...l, [field]: value } as SocialLink : l) });
  255. };
  256. return <div className="space-y-3">
  257. {socialBlock.links.map(link =>
  258. <div key={link.id} className="flex items-center gap-2">
  259. <select value={link.platform} onChange={e => handleUpdateLink(link.id, 'platform', e.target.value)} className="capitalize bg-gray-100 dark:bg-gray-700 p-2 rounded-md border-2 border-gray-300 dark:border-gray-600">
  260. {availablePlatforms.map(p => <option key={p} value={p}>{p}</option>)}
  261. </select>
  262. <input type="url" value={link.url} onChange={e => handleUpdateLink(link.id, 'url', e.target.value)} placeholder="https://..." className={`flex-1 ${inputClasses}`} />
  263. <button onClick={() => handleRemoveLink(link.id)} className="text-gray-400 hover:text-red-500"><Icon className="h-5 w-5"><path d="M6 18L18 6M6 6l12 12" /></Icon></button>
  264. </div>
  265. )}
  266. <div className="flex gap-4 pt-2">
  267. <button onClick={handleAddLink} className="text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1">
  268. <Icon className="h-4 w-4"><path d="M12 4v16m8-8H4" /></Icon>Add Link
  269. </button>
  270. <button onClick={handleAddMockLinks} className="text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1">
  271. <SparkleIcon />Add Mock Links
  272. </button>
  273. </div>
  274. </div>
  275. }
  276. case 'image':
  277. case 'video': {
  278. const mediaBlock = block as ImageBlock | VideoBlock;
  279. const addMedia = () => {
  280. const newSource: MediaSource = mediaBlock.type === 'image' ? { type: 'url', value: '' } : { type: 'url', value: '' };
  281. onUpdate({ ...mediaBlock, sources: [...mediaBlock.sources, newSource] });
  282. };
  283. const updateMediaSource = (index: number, newSource: MediaSource) => onUpdate({ ...mediaBlock, sources: mediaBlock.sources.map((s, i) => i === index ? newSource : s) });
  284. const removeMedia = (index: number) => onUpdate({ ...mediaBlock, sources: mediaBlock.sources.filter((_, i) => i !== index) });
  285. const displayClasses = "w-full text-sm p-2 bg-gray-100 dark:bg-gray-700 rounded-md border-2 border-gray-300 dark:border-gray-600";
  286. const handleAddMockMedia = () => {
  287. const newSources: MediaSource[] = Array.from({ length: 3 }).map((_, i) => {
  288. if (mediaBlock.type === 'image') {
  289. return { type: 'url', value: `https://picsum.photos/seed/${Date.now() + i}/400/300` };
  290. } else {
  291. const mockVideoUrls = [
  292. 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
  293. 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4',
  294. 'https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
  295. ];
  296. return { type: 'url', value: mockVideoUrls[i % mockVideoUrls.length] };
  297. }
  298. });
  299. onUpdate({ ...mediaBlock, sources: [...mediaBlock.sources, ...newSources] });
  300. };
  301. return <div className="space-y-6">
  302. <LayoutToggle label="Layout Mode" options={[{label: 'Single Column', value: 'single'}, {label: 'Grid', value: 'grid'}]} value={mediaBlock.layout} onLayoutChange={layout => onUpdate({ ...mediaBlock, layout })} />
  303. <div className="space-y-4">
  304. {mediaBlock.sources.map((source, index) => {
  305. const videoFromAIGC = source.type === 'aigc' ? aigcVideos.find(v => v.id === source.videoId) : null;
  306. return (
  307. <div key={index} className="bg-gray-100 dark:bg-gray-900 p-4 rounded-lg space-y-3 border border-gray-200 dark:border-gray-700">
  308. <div className="flex justify-between items-center">
  309. <SourceTypeToggle isVideo={mediaBlock.type === 'video'} type={source.type} onTypeChange={type => {
  310. const newSource: MediaSource = type === 'url' ? { type: 'url', value: '' } : { type: 'file', value: { name: 'example.file', size: 12345, previewUrl: `https://picsum.photos/seed/upload-${Date.now()}/200` } };
  311. updateMediaSource(index, newSource);
  312. }} onAIGCOpen={onAIGCOpen} />
  313. <button onMouseDown={(e) => e.preventDefault()} onClick={() => removeMedia(index)} className="text-gray-400 hover:text-red-500 flex-shrink-0"><Icon className="h-5 w-5"><path d="M6 18L18 6M6 6l12 12" /></Icon></button>
  314. </div>
  315. {source.type === 'url' ?
  316. <input type="url" value={source.value} onChange={e => updateMediaSource(index, { ...source, value: e.target.value })} className={inputClasses} placeholder="https://..." /> :
  317. source.type === 'file' ?
  318. <div className={displayClasses}>Simulated Upload: {source.value.name}</div> :
  319. videoFromAIGC ?
  320. <div className={`${displayClasses} flex items-center gap-2`}>
  321. <img src={videoFromAIGC.thumbnailUrl} className="h-8 w-12 rounded-sm object-cover" />
  322. <span>{videoFromAIGC.title}</span>
  323. </div> :
  324. <div className="text-sm p-2 bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200 rounded-md">AIGC video not found.</div>
  325. }
  326. </div>
  327. );
  328. })}
  329. </div>
  330. <div className="flex gap-4">
  331. <button onClick={addMedia} className="text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1">
  332. <Icon className="h-4 w-4"><path d="M12 4v16m8-8H4" /></Icon>Add {mediaBlock.type}
  333. </button>
  334. <button onClick={handleAddMockMedia} className="text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1">
  335. <SparkleIcon />Add 3 Mock {mediaBlock.type === 'image' ? 'Images' : 'Videos'}
  336. </button>
  337. </div>
  338. </div>
  339. }
  340. case 'text': {
  341. const textBlock = block as TextBlock;
  342. return <div className="space-y-4">
  343. <div className="flex items-center gap-2 p-2 bg-gray-100 dark:bg-gray-800 rounded-md">
  344. <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => onUpdate({ ...textBlock, textAlign: 'left' })} className={`p-2 rounded ${textBlock.textAlign === 'left' ? 'bg-brand-primary text-white' : ''}`}><Icon className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></Icon></button>
  345. <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => onUpdate({ ...textBlock, textAlign: 'center' })} className={`p-2 rounded ${textBlock.textAlign === 'center' ? 'bg-brand-primary text-white' : ''}`}><Icon className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></Icon></button>
  346. <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => onUpdate({ ...textBlock, textAlign: 'right' })} className={`p-2 rounded ${textBlock.textAlign === 'right' ? 'bg-brand-primary text-white' : ''}`}><Icon className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5M12 17.25h8.25" /></Icon></button>
  347. <div className="h-6 border-l border-gray-300 dark:border-gray-600 mx-2" />
  348. <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => onUpdate({ ...textBlock, isBold: !textBlock.isBold })} className={`p-2 rounded ${textBlock.isBold ? 'bg-brand-primary text-white' : ''}`}><strong>B</strong></button>
  349. <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => onUpdate({ ...textBlock, isItalic: !textBlock.isItalic })} className={`p-2 rounded ${textBlock.isItalic ? 'bg-brand-primary text-white' : ''}`}><em>I</em></button>
  350. <div className="h-6 border-l border-gray-300 dark:border-gray-600 mx-2" />
  351. <input type="text" value={textBlock.fontSize} onChange={e => onUpdate({ ...textBlock, fontSize: e.target.value })} className="w-16 p-1 text-sm bg-white dark:bg-gray-700 rounded" placeholder="16px" />
  352. <input type="color" value={textBlock.fontColor} onChange={e => onUpdate({ ...textBlock, fontColor: e.target.value })} className="h-8 w-8 rounded-md" />
  353. </div>
  354. <textarea value={textBlock.content} onChange={e => onUpdate({ ...textBlock, content: e.target.value })} rows={5} className={inputClasses} placeholder="Enter your text here..." />
  355. </div>
  356. }
  357. case 'map': {
  358. const mapBlock = block as MapBlock;
  359. const displayStyle = mapBlock.displayStyle || 'interactiveMap';
  360. const bgSource = mapBlock.backgroundImageSource;
  361. return <div className="space-y-4">
  362. <div>
  363. <label className={labelClasses}>Address or Location</label>
  364. <input type="text" value={mapBlock.address} onChange={e => onUpdate({ ...block, address: e.target.value })} className={`${inputClasses} mt-1`} placeholder="e.g., Eiffel Tower, Paris" />
  365. </div>
  366. <LayoutToggle
  367. label="Display Style"
  368. options={[{label: 'Map Preview', value: 'interactiveMap'}, {label: 'Image Overlay', value: 'imageOverlay'}]}
  369. value={displayStyle}
  370. onLayoutChange={style => onUpdate({ ...mapBlock, displayStyle: style })}
  371. />
  372. {displayStyle === 'imageOverlay' && (
  373. <div className="bg-gray-100 dark:bg-gray-900 p-4 rounded-lg space-y-3 border border-gray-200 dark:border-gray-700">
  374. <h4 className="text-sm font-semibold text-gray-600 dark:text-gray-300">Background Image</h4>
  375. <SourceTypeToggle
  376. isVideo={false}
  377. type={bgSource?.type || 'url'}
  378. onTypeChange={type => {
  379. const newSource: MediaSource = type === 'url' ? { type: 'url', value: '' } : { type: 'file', value: { name: 'background.jpg', size: 12345, previewUrl: `https://picsum.photos/seed/mapbg-${Date.now()}/400` } };
  380. onUpdate({ ...mapBlock, backgroundImageSource: newSource });
  381. }}
  382. onAIGCOpen={() => {}}
  383. />
  384. {bgSource?.type === 'url' &&
  385. <input type="url" value={bgSource.value} onChange={e => onUpdate({ ...mapBlock, backgroundImageSource: { type: 'url', value: e.target.value } })} className={inputClasses} placeholder="https://..."/>
  386. }
  387. {bgSource?.type === 'file' &&
  388. <div className="w-full text-sm p-2 bg-gray-100 dark:bg-gray-700 rounded-md border-2 border-gray-300 dark:border-gray-600">Simulated Upload: {bgSource.value.name}</div>
  389. }
  390. </div>
  391. )}
  392. </div>
  393. }
  394. case 'pdf': {
  395. const pdfBlock = block as PdfBlock;
  396. const source = pdfBlock.source;
  397. const displayClasses = "w-full text-sm p-2 bg-gray-100 dark:bg-gray-700 rounded-md border-2 border-gray-300 dark:border-gray-600";
  398. return <div className="bg-gray-100 dark:bg-gray-900 p-4 rounded-lg space-y-3 border border-gray-200 dark:border-gray-700">
  399. <SourceTypeToggle isVideo={false} type={source.type} onTypeChange={type => {
  400. const newSource: MediaSource = type === 'url' ? { type: 'url', value: '' } : { type: 'file', value: { name: 'document.pdf', size: 123456, previewUrl: `https://picsum.photos/seed/pdf-${Date.now()}/200/280` } };
  401. onUpdate({ ...pdfBlock, source: newSource });
  402. }} onAIGCOpen={() => {}} />
  403. {source.type === 'url' &&
  404. <input type="url" value={source.value} onChange={e => onUpdate({ ...pdfBlock, source: { type: 'url', value: e.target.value } })} className={inputClasses} placeholder="https://example.com/document.pdf"/>
  405. }
  406. {source.type === 'file' &&
  407. <div className={displayClasses}>Simulated Upload: {source.value.name}</div>
  408. }
  409. </div>
  410. }
  411. case 'email': {
  412. const emailBlock = block as EmailBlock;
  413. return <div className="space-y-4">
  414. <div><label className={labelClasses}>Button Label</label><input type="text" value={emailBlock.label} onChange={e => onUpdate({ ...emailBlock, label: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  415. <div><label className={labelClasses}>Email Address</label><input type="email" value={emailBlock.email} onChange={e => onUpdate({ ...emailBlock, email: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  416. <LayoutToggle label="Display Mode" options={[{label: 'Label Only', value: 'labelOnly'}, {label: 'Label + Email', value: 'labelAndValue'}]} value={emailBlock.displayMode} onLayoutChange={mode => onUpdate({ ...emailBlock, displayMode: mode })} />
  417. </div>
  418. }
  419. case 'phone': {
  420. const phoneBlock = block as PhoneBlock;
  421. return <div className="space-y-4">
  422. <div><label className={labelClasses}>Button Label</label><input type="text" value={phoneBlock.label} onChange={e => onUpdate({ ...phoneBlock, label: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  423. <div><label className={labelClasses}>Phone Number</label><input type="tel" value={phoneBlock.phone} onChange={e => onUpdate({ ...phoneBlock, phone: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  424. <LayoutToggle label="Display Mode" options={[{label: 'Label Only', value: 'labelOnly'}, {label: 'Label + Phone', value: 'labelAndValue'}]} value={phoneBlock.displayMode} onLayoutChange={mode => onUpdate({ ...phoneBlock, displayMode: mode })} />
  425. </div>
  426. }
  427. case 'news': {
  428. const newsBlock = block as NewsBlock;
  429. const [isNewsSelectorOpen, setIsNewsSelectorOpen] = React.useState(false);
  430. const selectedArticles = (newsBlock.source === 'aigc' && newsBlock.articleIds) ? aigcArticles.filter(a => newsBlock.articleIds.includes(a.id)) : [];
  431. const handleSaveSelection = (selectedIds: string[]) => {
  432. const { customItems, ...rest } = newsBlock;
  433. onUpdate({ ...rest, source: 'aigc', articleIds: selectedIds });
  434. setIsNewsSelectorOpen(false);
  435. };
  436. const handleSourceChange = (newSource: 'aigc' | 'custom') => {
  437. if (newSource === 'aigc') {
  438. const { customItems, ...rest } = newsBlock;
  439. onUpdate({ ...rest, source: 'aigc', articleIds: [] });
  440. } else {
  441. const { articleIds, ...rest } = newsBlock;
  442. onUpdate({ ...rest, source: 'custom', customItems: [] });
  443. }
  444. };
  445. const handleUpdateCustomItem = (itemId: string, field: 'title' | 'summary' | 'url', value: string) => {
  446. if(newsBlock.source !== 'custom') return;
  447. const updatedItems = newsBlock.customItems.map(item =>
  448. item.id === itemId ? { ...item, [field]: value } : item
  449. );
  450. onUpdate({ ...newsBlock, customItems: updatedItems });
  451. };
  452. const handleAddCustomItem = () => {
  453. if(newsBlock.source !== 'custom') return;
  454. const newItem: NewsItemFromUrl = { id: `custom-news-${Date.now()}`, title: 'New Article', summary: '', url: '' };
  455. onUpdate({ ...newsBlock, customItems: [...(newsBlock.customItems || []), newItem] });
  456. };
  457. const handleAddMockCustomItems = () => {
  458. if (newsBlock.source !== 'custom') return;
  459. const mockItems: NewsItemFromUrl[] = [
  460. { id: `custom-news-mock-${Date.now()}`, title: 'Mock Article: The Future of AI', summary: 'A fascinating look into what comes next.', url: '#' },
  461. { id: `custom-news-mock-${Date.now()+1}`, title: 'Mock Post: 10 Design Trends for 2025', summary: 'Stay ahead of the curve with these new styles.', url: '#' },
  462. { id: `custom-news-mock-${Date.now()+2}`, title: 'Mock Update: Company Hits New Milestone', summary: 'Celebrating a new achievement in our journey.', url: '#' },
  463. ];
  464. onUpdate({ ...newsBlock, customItems: [...(newsBlock.customItems || []), ...mockItems] });
  465. };
  466. const handleRemoveCustomItem = (itemId: string) => {
  467. if(newsBlock.source !== 'custom') return;
  468. const updatedItems = newsBlock.customItems.filter(item => item.id !== itemId);
  469. onUpdate({ ...newsBlock, customItems: updatedItems });
  470. };
  471. return (
  472. <>
  473. {isNewsSelectorOpen && (
  474. <AIGCNewsLibraryModal
  475. articles={aigcArticles}
  476. selectedArticleIds={newsBlock.source === 'aigc' ? newsBlock.articleIds : []}
  477. onClose={() => setIsNewsSelectorOpen(false)}
  478. onSave={handleSaveSelection}
  479. />
  480. )}
  481. <div className="space-y-4">
  482. <LayoutToggle label="Layout" options={[{label: 'List', value: 'list'}, {label: 'Grid', value: 'grid'}]} value={newsBlock.layout} onLayoutChange={layout => onUpdate({ ...newsBlock, layout })} />
  483. <LayoutToggle label="Content Source" options={[{label: 'AIGC Library', value: 'aigc'}, {label: 'Custom URLs', value: 'custom'}]} value={newsBlock.source || 'aigc'} onLayoutChange={handleSourceChange} />
  484. {newsBlock.source === 'aigc' ? (
  485. <div>
  486. <h4 className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Selected Articles ({selectedArticles.length})</h4>
  487. <div className="space-y-2 p-3 bg-gray-100 dark:bg-gray-900 rounded-md max-h-48 overflow-y-auto">
  488. {selectedArticles.length > 0 ? selectedArticles.map(article => (
  489. <p key={article.id} className="text-sm truncate p-2 bg-white dark:bg-gray-800 rounded">{article.title}</p>
  490. )) : <p className="text-sm text-gray-400 text-center py-2">No articles selected.</p>}
  491. </div>
  492. <button onClick={() => setIsNewsSelectorOpen(true)} className="mt-2 w-full text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1 justify-center p-2 bg-brand-primary/10 rounded-md">
  493. <Icon className="h-4 w-4"><path d="M12 4v16m8-8H4" /></Icon>
  494. Select from AIGC Library
  495. </button>
  496. </div>
  497. ) : (
  498. <div className="space-y-3">
  499. {(newsBlock.customItems || []).map(item => (
  500. <div key={item.id} className="p-3 bg-gray-100 dark:bg-gray-900 rounded-md space-y-2 relative">
  501. <button onClick={() => handleRemoveCustomItem(item.id)} className="absolute top-2 right-2 text-gray-400 hover:text-red-500"><Icon className="h-4 w-4"><path d="M6 18L18 6M6 6l12 12" /></Icon></button>
  502. <div><label className="text-xs font-semibold text-gray-500">Title</label><input type="text" value={item.title} onChange={e => handleUpdateCustomItem(item.id, 'title', e.target.value)} className="w-full bg-white dark:bg-gray-700 p-1 text-sm rounded" /></div>
  503. <div><label className="text-xs font-semibold text-gray-500">Summary</label><input type="text" value={item.summary} onChange={e => handleUpdateCustomItem(item.id, 'summary', e.target.value)} className="w-full bg-white dark:bg-gray-700 p-1 text-sm rounded" /></div>
  504. <div><label className="text-xs font-semibold text-gray-500">URL</label><input type="url" value={item.url} onChange={e => handleUpdateCustomItem(item.id, 'url', e.target.value)} className="w-full bg-white dark:bg-gray-700 p-1 text-sm rounded" /></div>
  505. </div>
  506. ))}
  507. <div className="flex gap-2">
  508. <button onClick={handleAddCustomItem} className="flex-1 justify-center p-2 bg-brand-primary/10 rounded-md text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1">
  509. <Icon className="h-4 w-4"><path d="M12 4v16m8-8H4" /></Icon>Add URL
  510. </button>
  511. <button onClick={handleAddMockCustomItems} className="flex-1 justify-center p-2 bg-brand-primary/10 rounded-md text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1">
  512. <SparkleIcon />Add 3 Mock Items
  513. </button>
  514. </div>
  515. </div>
  516. )}
  517. </div>
  518. </>
  519. );
  520. }
  521. case 'product': {
  522. const productBlock = block as ProductBlock;
  523. const [newUrls, setNewUrls] = React.useState('');
  524. const [isFetching, setIsFetching] = React.useState(false);
  525. const [isMocking, setIsMocking] = React.useState(false);
  526. const handleAddProducts = async () => {
  527. const urls = newUrls.trim().split('\n').filter(url => url.trim().startsWith('http'));
  528. if (urls.length === 0) return;
  529. setIsFetching(true);
  530. try {
  531. const newItemsPromises = urls.map(async (url) => {
  532. const productData = await parseProductUrl(url);
  533. return {
  534. id: `prod-${Date.now()}-${Math.random()}`,
  535. url,
  536. ...productData,
  537. };
  538. });
  539. const newItems = await Promise.all(newItemsPromises);
  540. onUpdate({ ...productBlock, items: [...productBlock.items, ...newItems] });
  541. setNewUrls('');
  542. } catch (error) {
  543. alert("Could not fetch product data from one or more URLs.");
  544. console.error(error);
  545. } finally {
  546. setIsFetching(false);
  547. }
  548. };
  549. const handleAddMockProducts = () => {
  550. setIsMocking(true);
  551. setTimeout(() => {
  552. const newItems: ProductItem[] = [];
  553. for (let i = 0; i < 3; i++) {
  554. const randomProduct = mockProductDatabase[Math.floor(Math.random() * mockProductDatabase.length)];
  555. const randomId = `prod-mock-${Date.now()}-${Math.random()}`;
  556. newItems.push({
  557. id: randomId,
  558. url: '#',
  559. title: randomProduct.title,
  560. price: randomProduct.price,
  561. imageUrl: `https://picsum.photos/seed/${randomId}/400/400`,
  562. });
  563. }
  564. onUpdate({ ...productBlock, items: [...productBlock.items, ...newItems] });
  565. setIsMocking(false);
  566. }, 500);
  567. };
  568. const handleRemoveProduct = (itemId: string) => {
  569. onUpdate({ ...productBlock, items: productBlock.items.filter(item => item.id !== itemId) });
  570. };
  571. return (
  572. <div className="space-y-4">
  573. <LayoutToggle label="Layout" options={[{label: 'Grid', value: 'grid'}, {label: 'List', value: 'list'}]} value={productBlock.layout} onLayoutChange={(layout: 'grid' | 'list') => onUpdate({ ...productBlock, layout })} />
  574. <div>
  575. <label className={labelClasses}>Add Products from URL</label>
  576. <div className="flex flex-col items-stretch gap-2 mt-1">
  577. <textarea
  578. rows={3}
  579. value={newUrls}
  580. onChange={e => setNewUrls(e.target.value)}
  581. placeholder="Paste one URL per line...&#10;https://amazon.com/product...&#10;https://shopee.com/item..."
  582. className={inputClasses}
  583. disabled={isFetching || isMocking}
  584. />
  585. <button onClick={handleAddProducts} disabled={isFetching || isMocking} className="w-full px-4 py-2 bg-gray-200 dark:bg-gray-600 text-sm font-semibold rounded-md hover:bg-gray-300 dark:hover:bg-gray-500 disabled:opacity-50 whitespace-nowrap">
  586. {isFetching ? 'Fetching...' : `Add from URLs`}
  587. </button>
  588. </div>
  589. </div>
  590. <div>
  591. <label className={labelClasses}>Or Add Sample Products</label>
  592. <button onClick={handleAddMockProducts} disabled={isFetching || isMocking} className="mt-1 w-full flex items-center justify-center gap-2 px-4 py-2 bg-brand-primary/10 text-brand-primary text-sm font-semibold rounded-md hover:bg-brand-primary/20 disabled:opacity-50 whitespace-nowrap">
  593. <SparkleIcon />
  594. {isMocking ? 'Adding...' : `Add 3 Mock Items`}
  595. </button>
  596. </div>
  597. <div className="space-y-3 max-h-64 overflow-y-auto pr-2 -mr-2">
  598. {productBlock.items.map(item => (
  599. <div key={item.id} className="flex items-center gap-3 p-2 bg-gray-100 dark:bg-gray-900 rounded-md">
  600. <img src={item.imageUrl} alt={item.title} className="w-12 h-12 object-cover rounded flex-shrink-0" />
  601. <div className="flex-1 min-w-0">
  602. <p className="font-semibold truncate text-gray-900 dark:text-white">{item.title}</p>
  603. <p className="text-sm text-gray-500 dark:text-gray-400">{item.price}</p>
  604. </div>
  605. <button onClick={() => handleRemoveProduct(item.id)} className="text-gray-400 hover:text-red-500"><Icon className="h-5 w-5"><path d="M6 18L18 6M6 6l12 12" /></Icon></button>
  606. </div>
  607. ))}
  608. </div>
  609. </div>
  610. );
  611. }
  612. case 'award': {
  613. const awardBlock = block as AwardBlock;
  614. const handleUpdateItem = (id: string, field: keyof Omit<AwardItem, 'id'>, value: any) => {
  615. const updatedItems = awardBlock.items.map(item =>
  616. item.id === id ? { ...item, [field]: value } : item
  617. );
  618. onUpdate({ ...awardBlock, items: updatedItems });
  619. };
  620. const handleAddItem = () => {
  621. const newItem: AwardItem = {
  622. id: `award-${Date.now()}`,
  623. title: 'New Award',
  624. subtitle: 'Awarding Body',
  625. year: new Date().getFullYear().toString(),
  626. imageSource: { type: 'url', value: `https://picsum.photos/seed/award-${Date.now()}/100` }
  627. };
  628. onUpdate({ ...awardBlock, items: [...awardBlock.items, newItem] });
  629. };
  630. const handleRemoveItem = (id: string) => {
  631. onUpdate({ ...awardBlock, items: awardBlock.items.filter(item => item.id !== id) });
  632. };
  633. return (
  634. <div className="space-y-4">
  635. <LayoutToggle
  636. label="Layout"
  637. options={[{ label: 'Grid', value: 'grid' }, { label: 'Single', value: 'single' }]}
  638. value={awardBlock.layout}
  639. onLayoutChange={(layout: 'grid' | 'single') => onUpdate({ ...awardBlock, layout })}
  640. />
  641. <div className="space-y-3">
  642. {awardBlock.items.map(item => (
  643. <div key={item.id} className="p-3 bg-gray-100 dark:bg-gray-900 rounded-md space-y-2 relative">
  644. <button onClick={() => handleRemoveItem(item.id)} className="absolute top-2 right-2 text-gray-400 hover:text-red-500"><Icon className="h-4 w-4"><path d="M6 18L18 6M6 6l12 12" /></Icon></button>
  645. <div><label className="text-xs font-semibold text-gray-500">Title</label><input type="text" value={item.title} onChange={e => handleUpdateItem(item.id, 'title', e.target.value)} className="w-full bg-white dark:bg-gray-700 p-1 text-sm rounded" /></div>
  646. <div><label className="text-xs font-semibold text-gray-500">Subtitle (e.g., Awarded by)</label><input type="text" value={item.subtitle || ''} onChange={e => handleUpdateItem(item.id, 'subtitle', e.target.value)} className="w-full bg-white dark:bg-gray-700 p-1 text-sm rounded" /></div>
  647. <div className="grid grid-cols-2 gap-2">
  648. <div><label className="text-xs font-semibold text-gray-500">Year</label><input type="text" value={item.year || ''} onChange={e => handleUpdateItem(item.id, 'year', e.target.value)} className="w-full bg-white dark:bg-gray-700 p-1 text-sm rounded" /></div>
  649. <div><label className="text-xs font-semibold text-gray-500">Image URL</label><input type="url" value={item.imageSource?.type === 'url' ? item.imageSource.value : ''} onChange={e => handleUpdateItem(item.id, 'imageSource', { type: 'url', value: e.target.value })} className="w-full bg-white dark:bg-gray-700 p-1 text-sm rounded" /></div>
  650. </div>
  651. </div>
  652. ))}
  653. </div>
  654. <button onClick={handleAddItem} className="w-full justify-center p-2 bg-brand-primary/10 rounded-md text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1">
  655. <Icon className="h-4 w-4"><path d="M12 4v16m8-8H4" /></Icon>Add Award
  656. </button>
  657. </div>
  658. );
  659. }
  660. case 'form': {
  661. const formBlock = block as FormBlock;
  662. const handleFieldChange = (fieldId: FormFieldId, prop: keyof FormField, value: any) => {
  663. const updatedFields = formBlock.fields.map(f => f.id === fieldId ? { ...f, [prop]: value } : f);
  664. onUpdate({ ...formBlock, fields: updatedFields });
  665. };
  666. const handlePurposeChange = (optionId: string, newLabel: string) => {
  667. const updatedOptions = formBlock.purposeOptions.map(o => o.id === optionId ? { ...o, label: newLabel } : o);
  668. onUpdate({ ...formBlock, purposeOptions: updatedOptions });
  669. };
  670. const handleAddPurpose = () => {
  671. const newOption: FormPurposeOption = { id: `po-${Date.now()}`, label: 'New Option' };
  672. onUpdate({ ...formBlock, purposeOptions: [...formBlock.purposeOptions, newOption] });
  673. };
  674. const handleRemovePurpose = (optionId: string) => {
  675. const updatedOptions = formBlock.purposeOptions.filter(o => o.id !== optionId);
  676. onUpdate({ ...formBlock, purposeOptions: updatedOptions });
  677. };
  678. return (
  679. <div className="space-y-4">
  680. <div><label className={labelClasses}>Title</label><input type="text" value={formBlock.title} onChange={e => onUpdate({ ...block, title: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  681. <div><label className={labelClasses}>Description</label><textarea value={formBlock.description} onChange={e => onUpdate({ ...block, description: e.target.value })} rows={3} className={`${inputClasses} mt-1`} /></div>
  682. <div>
  683. <label className={labelClasses}>Form Fields</label>
  684. <div className="space-y-2 mt-2 p-3 bg-gray-100 dark:bg-gray-900 rounded-md">
  685. {formBlock.fields.map(field => (
  686. <div key={field.id} className="flex items-center justify-between p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-800/50">
  687. <label htmlFor={`field-enabled-${field.id}`} className="flex items-center gap-3 cursor-pointer text-sm font-medium">
  688. <input type="checkbox" id={`field-enabled-${field.id}`} checked={field.enabled} onChange={e => handleFieldChange(field.id, 'enabled', e.target.checked)} className="h-4 w-4 rounded bg-gray-200 dark:bg-gray-900 border-gray-300 dark:border-gray-600 text-brand-primary focus:ring-brand-secondary" />
  689. <span className="capitalize text-gray-800 dark:text-gray-200">{field.label}</span>
  690. </label>
  691. {field.enabled && (
  692. <label htmlFor={`field-required-${field.id}`} className="flex items-center gap-1.5 cursor-pointer text-xs text-gray-500 dark:text-gray-400">
  693. <input type="checkbox" id={`field-required-${field.id}`} checked={field.required} onChange={e => handleFieldChange(field.id, 'required', e.target.checked)} className="h-3 w-3 rounded-sm bg-gray-200 dark:bg-gray-900 border-gray-300 dark:border-gray-600 text-brand-primary focus:ring-brand-secondary" />
  694. <span>Required</span>
  695. </label>
  696. )}
  697. </div>
  698. ))}
  699. </div>
  700. </div>
  701. <div>
  702. <label className={labelClasses}>Purpose Options</label>
  703. <div className="space-y-2 mt-2">
  704. {formBlock.purposeOptions.map(option => (
  705. <div key={option.id} className="flex items-center gap-2">
  706. <input type="text" value={option.label} onChange={e => handlePurposeChange(option.id, e.target.value)} className={`${inputClasses} flex-1`} />
  707. <button onClick={() => handleRemovePurpose(option.id)} className="text-gray-400 hover:text-red-500"><Icon className="h-5 w-5"><path d="M6 18L18 6M6 6l12 12" /></Icon></button>
  708. </div>
  709. ))}
  710. <button onClick={handleAddPurpose} className="text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1">
  711. <Icon className="h-4 w-4"><path d="M12 4v16m8-8H4" /></Icon> Add Option
  712. </button>
  713. </div>
  714. </div>
  715. <div><label className={labelClasses}>Submit Button Text</label><input type="text" value={formBlock.submitButtonText} onChange={e => onUpdate({ ...block, submitButtonText: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  716. </div>
  717. );
  718. }
  719. case 'footer': {
  720. const footerBlock = block as FooterBlock;
  721. const handleUpdateLink = (list: 'navLinks' | 'otherLinks', id: string, field: 'title' | 'url', value: string) => {
  722. const updatedLinks = footerBlock[list].map(link => link.id === id ? { ...link, [field]: value } : link);
  723. onUpdate({ ...footerBlock, [list]: updatedLinks });
  724. };
  725. const handleAddLink = (list: 'navLinks' | 'otherLinks') => {
  726. const newLink: FooterLink = { id: `fl-${Date.now()}`, title: 'New Link', url: '#' };
  727. onUpdate({ ...footerBlock, [list]: [...footerBlock[list], newLink] });
  728. };
  729. const handleRemoveLink = (list: 'navLinks' | 'otherLinks', id: string) => {
  730. onUpdate({ ...footerBlock, [list]: footerBlock[list].filter(link => link.id !== id) });
  731. };
  732. const LinkEditorList: React.FC<{ list: FooterLink[], listKey: 'navLinks' | 'otherLinks', title: string }> = ({ list, listKey, title }) => (
  733. <div>
  734. <h4 className="text-base font-semibold text-gray-700 dark:text-gray-200 mb-2">{title}</h4>
  735. <div className="space-y-2 p-3 bg-gray-100 dark:bg-gray-900 rounded-md">
  736. {list.map(link => (
  737. <div key={link.id} className="flex items-center gap-2 p-2 rounded-md bg-white dark:bg-gray-800/50">
  738. <input type="text" placeholder="Title" value={link.title} onChange={e => handleUpdateLink(listKey, link.id, 'title', e.target.value)} className={`${inputClasses} text-sm`} />
  739. <input type="url" placeholder="URL" value={link.url} onChange={e => handleUpdateLink(listKey, link.id, 'url', e.target.value)} className={`${inputClasses} text-sm`} />
  740. <button onClick={() => handleRemoveLink(listKey, link.id)} className="text-gray-400 hover:text-red-500"><Icon className="h-4 w-4"><path d="M6 18L18 6M6 6l12 12" /></Icon></button>
  741. </div>
  742. ))}
  743. <button onClick={() => handleAddLink(listKey)} className="w-full text-sm text-brand-primary hover:underline font-semibold flex items-center gap-1 justify-center p-2 bg-brand-primary/10 rounded-md">
  744. <Icon className="h-4 w-4"><path d="M12 4v16m8-8H4" /></Icon> Add Link
  745. </button>
  746. </div>
  747. </div>
  748. );
  749. return (
  750. <div className="space-y-4">
  751. <LayoutToggle label="Layout" options={[{label: 'Standard', value: 'standard'}, {label: 'Centered', value: 'centered'}]} value={footerBlock.layout} onLayoutChange={(layout: 'standard' | 'centered') => onUpdate({ ...footerBlock, layout })} />
  752. <div><label className={labelClasses}>Copyright Text</label><input type="text" value={footerBlock.copyrightText} onChange={e => onUpdate({ ...block, copyrightText: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  753. <div><label className={labelClasses}>Legal / Filing Number (Optional)</label><input type="text" value={footerBlock.legalText || ''} onChange={e => onUpdate({ ...block, legalText: e.target.value })} className={`${inputClasses} mt-1`} /></div>
  754. <div><label className={labelClasses}>Special Statement (Optional)</label><textarea value={footerBlock.statement || ''} onChange={e => onUpdate({ ...block, statement: e.target.value })} rows={4} className={`${inputClasses} mt-1`} /></div>
  755. <LinkEditorList list={footerBlock.navLinks} listKey="navLinks" title="Navigation Links" />
  756. <LinkEditorList list={footerBlock.otherLinks} listKey="otherLinks" title="Other Hyperlinks" />
  757. </div>
  758. );
  759. }
  760. default: return null
  761. }
  762. };
  763. const AIGCLibraryModal: React.FC<{
  764. videos: AIGCVideo[];
  765. onClose: () => void;
  766. onSelect: (videoId: string) => void;
  767. }> = ({ videos, onClose, onSelect }) => {
  768. return (
  769. <div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center backdrop-blur-sm" onClick={onClose}>
  770. <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-4xl h-[80vh] p-8 border border-gray-200 dark:border-gray-700 flex flex-col" onClick={e => e.stopPropagation()}>
  771. <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 flex-shrink-0">Select a Video from AIGC Library</h2>
  772. <div className="flex-1 overflow-y-auto pr-4 -mr-4">
  773. <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
  774. {videos.map(video => (
  775. <button key={video.id} onMouseDown={(e) => e.preventDefault()} onClick={() => onSelect(video.id)} className="group text-left p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-brand-primary transition-colors">
  776. <img src={video.thumbnailUrl} alt={video.title} className="w-full h-24 object-cover rounded-md mb-2" />
  777. <p className="text-sm font-semibold truncate group-hover:text-brand-primary">{video.title}</p>
  778. </button>
  779. ))}
  780. </div>
  781. {videos.length === 0 && <p className="text-center text-gray-400 dark:text-gray-500 py-12">No videos in your AIGC library. Create some in the AIGC Creator!</p>}
  782. </div>
  783. </div>
  784. </div>
  785. );
  786. };
  787. const LinkEditor: React.FC<{ blocks: Block[]; setBlocks: (newBlocks: Block[]) => void; aigcVideos: AIGCVideo[]; aigcArticles: AIGCArticle[] }> = ({ blocks, setBlocks, aigcVideos, aigcArticles }) => {
  788. const [expandedBlockId, setExpandedBlockId] = React.useState<string | null>(null);
  789. const [draggedItemId, setDraggedItemId] = React.useState<string | null>(null);
  790. const [isAIGCModalOpen, setIsAIGCModalOpen] = React.useState(false);
  791. const [activeBlockForAIGC, setActiveBlockForAIGC] = React.useState<VideoBlock | null>(null);
  792. const [isAddBlockModalOpen, setIsAddBlockModalOpen] = React.useState(false);
  793. const hasChatBlock = React.useMemo(() => blocks.some(b => b.type === 'chat'), [blocks]);
  794. const hasFooterBlock = React.useMemo(() => blocks.some(b => b.type === 'footer'), [blocks]);
  795. const addBlock = (type: BlockType) => {
  796. const newBlock: Block = {
  797. id: `${type}-${Date.now()}`, type, visible: true,
  798. ...(type === 'link' && { title: 'New Link', url: '', iconUrl: '', thumbnailUrl: '' }),
  799. ...(type === 'header' && { text: 'New Header', titleAlignment: 'left' }),
  800. ...(type === 'social' && { links: [] }),
  801. ...(type === 'chat' && { layout: 'under_avatar' }),
  802. ...(type === 'enterprise_info' && { items: [
  803. { id: `ei-${Date.now()}-1`, icon: 'building', label: 'Company Name', value: 'Innovatech Inc.' },
  804. { id: `ei-${Date.now()}-2`, icon: 'location', label: 'Address', value: '123 Tech Avenue, Silicon Valley, CA' },
  805. { id: `ei-${Date.now()}-3`, icon: 'calendar', label: 'Founded', value: '2021' },
  806. ], alignment: 'left' }),
  807. ...(type === 'video' && { sources: [], layout: 'single' }),
  808. ...(type === 'image' && { sources: [], layout: 'grid' }),
  809. ...(type === 'text' && { content: 'This is a new text block. You can edit this content.', textAlign: 'left', fontSize: '16px', fontColor: '#E5E7EB', isBold: false, isItalic: false }),
  810. ...(type === 'map' && { address: 'New York, NY', displayStyle: 'interactiveMap', backgroundImageSource: { type: 'url', value: `https://picsum.photos/seed/new-map-${Date.now()}/600/400` } }),
  811. ...(type === 'pdf' && { source: { type: 'url', value: '' } }),
  812. ...(type === 'email' && { email: 'contact@example.com', label: 'Email Me', displayMode: 'labelAndValue' }),
  813. ...(type === 'phone' && { phone: '+1234567890', label: 'Call Me', displayMode: 'labelAndValue' }),
  814. ...(type === 'news' && { layout: 'list', source: 'aigc', articleIds: [] }),
  815. ...(type === 'product' && { items: [], layout: 'grid' }),
  816. ...(type === 'award' && {
  817. items: [
  818. { id: `award-${Date.now()}-1`, title: 'Design of the Year', subtitle: 'Global Design Awards', year: '2023', imageSource: { type: 'url', value: 'https://picsum.photos/seed/award1/100' }},
  819. { id: `award-${Date.now()}-2`, title: 'Top Innovator', subtitle: 'Tech Weekly Magazine', year: '2022', imageSource: { type: 'url', value: 'https://picsum.photos/seed/award2/100' }}
  820. ],
  821. layout: 'grid'
  822. }),
  823. ...(type === 'form' && {
  824. title: 'New Form',
  825. description: 'Collect information from your visitors.',
  826. submitButtonText: 'Submit',
  827. fields: [
  828. { id: 'name', label: 'Name', enabled: true, required: true },
  829. { id: 'email', label: 'Email', enabled: true, required: true },
  830. { id: 'company', label: 'Company', enabled: false, required: false },
  831. { id: 'phone', label: 'Phone', enabled: false, required: false },
  832. { id: 'industry', label: 'Industry', enabled: false, required: false },
  833. { id: 'position', label: 'Position', enabled: false, required: false },
  834. { id: 'country', label: 'Country', enabled: false, required: false },
  835. ],
  836. purposeOptions: [
  837. { id: `po-new-${Date.now()}`, label: 'General Inquiry' },
  838. ]
  839. }),
  840. ...(type === 'footer' && {
  841. layout: 'standard',
  842. copyrightText: `© ${new Date().getFullYear()} Your Company Name. All Rights Reserved.`,
  843. legalText: 'Your Legal ID / Filing Number',
  844. statement: 'This is a special statement or disclaimer area for your website footer.',
  845. navLinks: [
  846. { id: 'fl-1', title: 'Home', url: '#' },
  847. { id: 'fl-2', title: 'About Us', url: '#' },
  848. { id: 'fl-3', title: 'Services', url: '#' },
  849. { id: 'fl-4', title: 'Contact', url: '#' },
  850. ],
  851. otherLinks: [
  852. { id: 'fl-5', title: 'Privacy Policy', url: '#' },
  853. { id: 'fl-6', title: 'Terms of Service', url: '#' },
  854. ]
  855. }),
  856. } as Block;
  857. setBlocks([...blocks, newBlock]);
  858. setExpandedBlockId(newBlock.id);
  859. };
  860. const handleAddBlockFromModal = (type: BlockType) => {
  861. addBlock(type);
  862. setIsAddBlockModalOpen(false);
  863. };
  864. const updateBlock = (updatedBlock: Block) => setBlocks(blocks.map(b => b.id === updatedBlock.id ? updatedBlock : b));
  865. const deleteBlock = (id: string) => setBlocks(blocks.filter(b => b.id !== id));
  866. const toggleVisibility = (id: string) => setBlocks(blocks.map(b => b.id === id ? { ...b, visible: !b.visible } : b));
  867. const handleDragStart = (e: React.DragEvent<HTMLElement>, id: string) => { setDraggedItemId(id); e.dataTransfer.effectAllowed = 'move'; };
  868. const handleDragOver = (e: React.DragEvent<HTMLElement>) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
  869. const handleDrop = (e: React.DragEvent<HTMLElement>, targetId: string) => {
  870. e.preventDefault();
  871. if (!draggedItemId || draggedItemId === targetId) return;
  872. const draggedIndex = blocks.findIndex(b => b.id === draggedItemId);
  873. const targetIndex = blocks.findIndex(b => b.id === targetId);
  874. if(draggedIndex === -1 || targetIndex === -1) return;
  875. const newBlocks = [...blocks];
  876. const [draggedItem] = newBlocks.splice(draggedIndex, 1);
  877. newBlocks.splice(targetIndex, 0, draggedItem);
  878. setBlocks(newBlocks);
  879. setDraggedItemId(null);
  880. };
  881. const handleOpenAIGCModal = (block: VideoBlock) => {
  882. setActiveBlockForAIGC(block);
  883. setIsAIGCModalOpen(true);
  884. };
  885. const handleSelectAIGCVideo = (videoId: string) => {
  886. if (!activeBlockForAIGC) return;
  887. const newSource: MediaSource = { type: 'aigc', videoId };
  888. updateBlock({
  889. ...activeBlockForAIGC,
  890. sources: [...activeBlockForAIGC.sources, newSource],
  891. });
  892. setIsAIGCModalOpen(false);
  893. setActiveBlockForAIGC(null);
  894. };
  895. const getBlockTitle = (block: Block) => {
  896. switch (block.type) {
  897. case 'header': return block.text;
  898. case 'link': return block.title;
  899. case 'image': return `Image Gallery (${block.sources.length})`;
  900. case 'video': return `Video Gallery (${block.sources.length})`;
  901. case 'text': return `${block.content.substring(0, 30)}...`;
  902. case 'email': return block.label;
  903. case 'phone': return block.label;
  904. case 'chat': return 'Chat Button';
  905. case 'enterprise_info': return 'Enterprise Information';
  906. case 'social': return "Social Media Icons";
  907. case 'map': return block.address || "Map Location";
  908. case 'news': return `News Feed (${block.source === 'aigc' ? (block.articleIds || []).length : (block.customItems || []).length} items)`;
  909. case 'product': return `Product Showcase (${block.items.length} items)`;
  910. case 'form': return block.title;
  911. case 'award': return `Awards (${block.items.length} items)`;
  912. case 'footer': return 'Page Footer';
  913. case 'pdf': {
  914. const source = block.source;
  915. if (source.type === 'file') {
  916. return source.value.name;
  917. }
  918. if (source.type === 'url' && source.value) {
  919. const urlParts = source.value.split('/');
  920. return urlParts[urlParts.length - 1] || "PDF Document";
  921. }
  922. return "PDF Document";
  923. }
  924. default:
  925. const exhaustiveCheck = block as Block;
  926. return blockTypeMetadata[exhaustiveCheck.type]?.name || 'Unknown Block';
  927. }
  928. };
  929. return (
  930. <div>
  931. {isAIGCModalOpen && <AIGCLibraryModal videos={aigcVideos} onClose={() => setIsAIGCModalOpen(false)} onSelect={handleSelectAIGCVideo} />}
  932. {isAddBlockModalOpen && <AddBlockModal hasChatBlock={hasChatBlock} hasFooterBlock={hasFooterBlock} onSelect={handleAddBlockFromModal} onClose={() => setIsAddBlockModalOpen(false)} />}
  933. <button
  934. onClick={() => setIsAddBlockModalOpen(true)}
  935. className="w-full flex items-center justify-center gap-2 p-3 mb-8 bg-brand-primary text-white font-semibold rounded-lg hover:bg-brand-secondary transition-colors shadow-lg"
  936. >
  937. <Icon className="h-5 w-5"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></Icon>
  938. Add Block
  939. </button>
  940. <div>
  941. {blocks.map(block => {
  942. const isHeader = block.type === 'header';
  943. const containerClass = `${isHeader ? 'mt-8 mb-2 border-b-2 border-gray-200 dark:border-gray-700' : 'bg-white dark:bg-gray-800 rounded-lg mt-3'} transition-shadow ${draggedItemId === block.id ? 'opacity-50 shadow-2xl' : ''}`;
  944. const titleClass = isHeader ? 'font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider' : 'font-semibold text-gray-900 dark:text-white';
  945. return <div key={block.id} draggable onDragStart={e => handleDragStart(e, block.id)} onDragOver={handleDragOver} onDrop={e => handleDrop(e, block.id)} className={containerClass}>
  946. <div className="p-3 flex items-center gap-3 cursor-pointer" onClick={() => setExpandedBlockId(expandedBlockId === block.id ? null : block.id)}>
  947. <div onClick={e => e.stopPropagation()} className="cursor-grab text-gray-400 dark:text-gray-500"><Icon className="h-5 w-5"><path d="M4 8h16M4 16h16" /></Icon></div>
  948. <div className="flex-1 truncate"><p className={`${titleClass} truncate`}>{getBlockTitle(block)}</p></div>
  949. <button onMouseDown={(e) => e.preventDefault()} onClick={e => { e.stopPropagation(); toggleVisibility(block.id); }} className="text-gray-400 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"><Icon className="h-5 w-5">{block.visible ? <><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></> : <path d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />}</Icon></button>
  950. <button onMouseDown={(e) => e.preventDefault()} onClick={e => { e.stopPropagation(); deleteBlock(block.id); }} className="text-gray-400 dark:text-gray-500 hover:text-red-500"><Icon className="h-5 w-5"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></Icon></button>
  951. <div className="text-gray-400 dark:text-gray-400">
  952. <Icon className={`h-5 w-5 transition-transform ${expandedBlockId === block.id ? 'rotate-180' : ''}`}><path d="M19 9l-7 7-7-7" /></Icon>
  953. </div>
  954. </div>
  955. {expandedBlockId === block.id && (<div className={`p-4 ${isHeader ? '' : 'border-t border-gray-200 dark:border-gray-700'}`}><BlockItemEditor block={block} onUpdate={updateBlock} onAIGCOpen={() => handleOpenAIGCModal(block as VideoBlock)} aigcVideos={aigcVideos} aigcArticles={aigcArticles} /></div>)}
  956. </div>
  957. })}
  958. </div>
  959. </div>
  960. );
  961. };
  962. export default LinkEditor;