This commit is contained in:
alorig
2025-11-18 05:03:27 +05:00
parent 342d9eab17
commit 40d379dd7e
35 changed files with 2073 additions and 0 deletions

17
sites/Dockerfile.dev Normal file
View File

@@ -0,0 +1,17 @@
# Sites Renderer Dev Image (Node 22 to satisfy Vite requirements)
FROM node:22-alpine
WORKDIR /app
# Copy package manifests first for better caching
COPY package*.json ./
RUN npm install
# Copy source (still bind-mounted at runtime, but needed for initial run)
COPY . .
EXPOSE 5176
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5176"]

29
sites/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

14
sites/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IGNY8 Sites</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
sites/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "igny8-sites",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2"
}
}

2
sites/public/vite.svg Normal file
View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.666 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

16
sites/src/App.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import SiteRenderer from './pages/SiteRenderer';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/:siteId/*" element={<SiteRenderer />} />
<Route path="/" element={<div>IGNY8 Sites Renderer</div>} />
</Routes>
</BrowserRouter>
);
}
export default App;

29
sites/src/index.css Normal file
View File

@@ -0,0 +1,29 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
margin: 0 auto;
text-align: center;
}

View File

@@ -0,0 +1,92 @@
/**
* Site Definition Loader
* Phase 5: Sites Renderer & Publishing
*
* Loads site definitions from the filesystem or API.
*/
import axios from 'axios';
import type { SiteDefinition } from '../types';
const API_URL = import.meta.env.VITE_API_URL || 'https://api.igny8.com/api';
const SITES_DATA_PATH = import.meta.env.SITES_DATA_PATH || '/sites';
/**
* Load site definition by site ID.
* First tries to load from filesystem (deployed sites),
* then falls back to API.
*/
export async function loadSiteDefinition(siteId: string): Promise<SiteDefinition> {
// Try filesystem first (for deployed sites)
try {
const fsPath = `${SITES_DATA_PATH}/clients/${siteId}/latest/site.json`;
const response = await fetch(fsPath);
if (response.ok) {
const definition = await response.json();
return definition;
}
} catch (error) {
// Filesystem load failed, try API
console.warn('Failed to load from filesystem, trying API:', error);
}
// Fallback to API
try {
const response = await axios.get(`${API_URL}/v1/site-builder/blueprints/${siteId}/`);
const blueprint = response.data;
// Transform blueprint to site definition format
return transformBlueprintToSiteDefinition(blueprint);
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Failed to load site: ${error.message}`);
}
throw error;
}
}
/**
* Transform SiteBlueprint to SiteDefinition format.
*/
function transformBlueprintToSiteDefinition(blueprint: any): SiteDefinition {
return {
id: blueprint.id,
name: blueprint.name,
description: blueprint.description,
version: blueprint.version,
layout: blueprint.structure_json?.layout || 'default',
theme: blueprint.structure_json?.theme || {},
navigation: blueprint.structure_json?.navigation || [],
pages: blueprint.pages?.map((page: any) => ({
id: page.id,
slug: page.slug,
title: page.title,
type: page.type,
blocks: page.blocks_json || [],
status: page.status,
})) || [],
config: blueprint.config_json || {},
created_at: blueprint.created_at,
updated_at: blueprint.updated_at,
};
}
/**
* Load site definition from a specific version.
*/
export async function loadSiteDefinitionByVersion(
siteId: string,
version: number
): Promise<SiteDefinition> {
try {
const fsPath = `${SITES_DATA_PATH}/clients/${siteId}/v${version}/site.json`;
const response = await fetch(fsPath);
if (response.ok) {
const definition = await response.json();
return definition;
}
throw new Error(`Version ${version} not found`);
} catch (error) {
throw new Error(`Failed to load site version ${version}: ${error}`);
}
}

11
sites/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { loadSiteDefinition } from '../loaders/loadSiteDefinition';
import { renderLayout } from '../utils/layoutRenderer';
import type { SiteDefinition } from '../types';
function SiteRenderer() {
const { siteId } = useParams<{ siteId: string }>();
const [siteDefinition, setSiteDefinition] = useState<SiteDefinition | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!siteId) {
setError('Site ID is required');
setLoading(false);
return;
}
loadSiteDefinition(siteId)
.then((definition) => {
setSiteDefinition(definition);
setLoading(false);
})
.catch((err) => {
setError(err.message || 'Failed to load site');
setLoading(false);
});
}, [siteId]);
if (loading) {
return <div>Loading site...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
if (!siteDefinition) {
return <div>Site not found</div>;
}
return (
<div className="site-renderer">
{renderLayout(siteDefinition)}
</div>
);
}
export default SiteRenderer;

34
sites/src/types/index.ts Normal file
View File

@@ -0,0 +1,34 @@
export interface SiteDefinition {
id: number;
name: string;
description?: string;
version: number;
layout: string;
theme: Record<string, any>;
navigation: NavigationItem[];
pages: PageDefinition[];
config: Record<string, any>;
created_at: string;
updated_at: string;
}
export interface NavigationItem {
label: string;
slug: string;
order: number;
}
export interface PageDefinition {
id: number;
slug: string;
title: string;
type: string;
blocks: Block[];
status: string;
}
export interface Block {
type: string;
data: Record<string, any>;
}

View File

@@ -0,0 +1,119 @@
/**
* File Access Utility
* Phase 5: Sites Renderer & Publishing
*
* Integrates with Phase 3's SiteBuilderFileService for file access.
* Provides utilities to access site assets (images, documents, media).
*/
const SITES_DATA_PATH = import.meta.env.SITES_DATA_PATH || '/sites';
const API_URL = import.meta.env.VITE_API_URL || 'https://api.igny8.com/api';
/**
* Get file URL for a site asset.
*
* @param siteId - Site ID
* @param version - Site version (optional, defaults to 'latest')
* @param filePath - Relative path to file from assets directory
* @returns Full URL to the asset
*/
export function getSiteAssetUrl(
siteId: string | number,
filePath: string,
version: string | number = 'latest'
): string {
// Try filesystem first (for deployed sites)
const fsPath = `${SITES_DATA_PATH}/clients/${siteId}/v${version}/assets/${filePath}`;
// In browser, we need to use API endpoint
// The backend will serve files from the filesystem
return `${API_URL}/v1/site-builder/assets/${siteId}/${version}/${filePath}`;
}
/**
* Get image URL for a site.
*/
export function getSiteImageUrl(
siteId: string | number,
imagePath: string,
version: string | number = 'latest'
): string {
return getSiteAssetUrl(siteId, `images/${imagePath}`, version);
}
/**
* Get document URL for a site.
*/
export function getSiteDocumentUrl(
siteId: string | number,
documentPath: string,
version: string | number = 'latest'
): string {
return getSiteAssetUrl(siteId, `documents/${documentPath}`, version);
}
/**
* Get media URL for a site.
*/
export function getSiteMediaUrl(
siteId: string | number,
mediaPath: string,
version: string | number = 'latest'
): string {
return getSiteAssetUrl(siteId, `media/${mediaPath}`, version);
}
/**
* Check if a file exists.
*
* @param siteId - Site ID
* @param filePath - Relative path to file
* @param version - Site version
* @returns Promise that resolves to true if file exists
*/
export async function fileExists(
siteId: string | number,
filePath: string,
version: string | number = 'latest'
): Promise<boolean> {
try {
const url = getSiteAssetUrl(siteId, filePath, version);
const response = await fetch(url, { method: 'HEAD' });
return response.ok;
} catch {
return false;
}
}
/**
* Load file content as text.
*/
export async function loadFileAsText(
siteId: string | number,
filePath: string,
version: string | number = 'latest'
): Promise<string> {
const url = getSiteAssetUrl(siteId, filePath, version);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load file: ${filePath}`);
}
return response.text();
}
/**
* Load file content as blob.
*/
export async function loadFileAsBlob(
siteId: string | number,
filePath: string,
version: string | number = 'latest'
): Promise<Blob> {
const url = getSiteAssetUrl(siteId, filePath, version);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load file: ${filePath}`);
}
return response.blob();
}

View File

@@ -0,0 +1,205 @@
/**
* Layout Renderer
* Phase 5: Sites Renderer & Publishing
*
* Renders different layout types for sites.
*/
import React from 'react';
import type { SiteDefinition } from '../types';
import { renderTemplate } from './templateEngine';
export type LayoutType =
| 'default'
| 'centered'
| 'sidebar'
| 'grid'
| 'magazine'
| 'minimal'
| 'fullwidth';
/**
* Render site layout based on site definition.
*/
export function renderLayout(siteDefinition: SiteDefinition): React.ReactElement {
const layoutType = siteDefinition.layout as LayoutType;
switch (layoutType) {
case 'centered':
return renderCenteredLayout(siteDefinition);
case 'sidebar':
return renderSidebarLayout(siteDefinition);
case 'grid':
return renderGridLayout(siteDefinition);
case 'magazine':
return renderMagazineLayout(siteDefinition);
case 'minimal':
return renderMinimalLayout(siteDefinition);
case 'fullwidth':
return renderFullwidthLayout(siteDefinition);
case 'default':
default:
return renderDefaultLayout(siteDefinition);
}
}
/**
* Default layout: Standard header, content, footer.
*/
function renderDefaultLayout(siteDefinition: SiteDefinition): React.ReactElement {
return (
<div className="layout-default">
<header className="site-header">
<nav className="site-navigation">
{renderNavigation(siteDefinition.navigation)}
</nav>
</header>
<main className="site-main">
{renderPages(siteDefinition.pages)}
</main>
<footer className="site-footer">
<p>&copy; {new Date().getFullYear()} {siteDefinition.name}</p>
</footer>
</div>
);
}
/**
* Centered layout: Content centered with max-width.
*/
function renderCenteredLayout(siteDefinition: SiteDefinition): React.ReactElement {
return (
<div className="layout-centered">
<header className="site-header">
<nav className="site-navigation">
{renderNavigation(siteDefinition.navigation)}
</nav>
</header>
<main className="site-main" style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
{renderPages(siteDefinition.pages)}
</main>
<footer className="site-footer">
<p>&copy; {new Date().getFullYear()} {siteDefinition.name}</p>
</footer>
</div>
);
}
/**
* Sidebar layout: Sidebar navigation with main content.
*/
function renderSidebarLayout(siteDefinition: SiteDefinition): React.ReactElement {
return (
<div className="layout-sidebar">
<aside className="site-sidebar">
<nav className="site-navigation">
{renderNavigation(siteDefinition.navigation)}
</nav>
</aside>
<main className="site-main">
{renderPages(siteDefinition.pages)}
</main>
</div>
);
}
/**
* Grid layout: Grid-based page layout.
*/
function renderGridLayout(siteDefinition: SiteDefinition): React.ReactElement {
return (
<div className="layout-grid">
<header className="site-header">
<nav className="site-navigation">
{renderNavigation(siteDefinition.navigation)}
</nav>
</header>
<main className="site-main" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '2rem', padding: '2rem' }}>
{renderPages(siteDefinition.pages)}
</main>
</div>
);
}
/**
* Magazine layout: Multi-column magazine style.
*/
function renderMagazineLayout(siteDefinition: SiteDefinition): React.ReactElement {
return (
<div className="layout-magazine">
<header className="site-header">
<h1>{siteDefinition.name}</h1>
<nav className="site-navigation">
{renderNavigation(siteDefinition.navigation)}
</nav>
</header>
<main className="site-main" style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '2rem', padding: '2rem' }}>
{renderPages(siteDefinition.pages)}
</main>
</div>
);
}
/**
* Minimal layout: Clean, minimal design.
*/
function renderMinimalLayout(siteDefinition: SiteDefinition): React.ReactElement {
return (
<div className="layout-minimal">
<main className="site-main" style={{ padding: '4rem 2rem', maxWidth: '800px', margin: '0 auto' }}>
{renderPages(siteDefinition.pages)}
</main>
</div>
);
}
/**
* Fullwidth layout: Full-width content.
*/
function renderFullwidthLayout(siteDefinition: SiteDefinition): React.ReactElement {
return (
<div className="layout-fullwidth">
<header className="site-header">
<nav className="site-navigation">
{renderNavigation(siteDefinition.navigation)}
</nav>
</header>
<main className="site-main" style={{ width: '100%' }}>
{renderPages(siteDefinition.pages)}
</main>
</div>
);
}
/**
* Render navigation items.
*/
function renderNavigation(navigation: SiteDefinition['navigation']): React.ReactElement {
return (
<ul style={{ listStyle: 'none', display: 'flex', gap: '1rem', padding: 0 }}>
{navigation.map((item) => (
<li key={item.slug}>
<a href={`/${item.slug}`}>{item.label}</a>
</li>
))}
</ul>
);
}
/**
* Render pages.
*/
function renderPages(pages: SiteDefinition['pages']): React.ReactElement[] {
return pages
.filter((page) => page.status === 'ready')
.map((page) => (
<div key={page.id} className="page" data-page-slug={page.slug}>
<h2>{page.title}</h2>
{page.blocks.map((block, index) => (
<div key={index} className="block" data-block-type={block.type}>
{renderTemplate(block)}
</div>
))}
</div>
));
}

View File

@@ -0,0 +1,245 @@
/**
* Template Engine
* Phase 5: Sites Renderer & Publishing
*
* Renders blocks using shared components from the component library.
*/
import React from 'react';
import type { Block } from '../types';
/**
* Render a block using the template engine.
* Imports shared components dynamically.
*/
export function renderTemplate(block: Block): React.ReactElement {
const { type, data } = block;
try {
// Try to import shared component
// This will be replaced with actual component imports once shared components are available
switch (type) {
case 'hero':
return renderHeroBlock(data);
case 'text':
return renderTextBlock(data);
case 'image':
return renderImageBlock(data);
case 'button':
return renderButtonBlock(data);
case 'section':
return renderSectionBlock(data);
case 'grid':
return renderGridBlock(data);
case 'card':
return renderCardBlock(data);
case 'list':
return renderListBlock(data);
case 'quote':
return renderQuoteBlock(data);
case 'video':
return renderVideoBlock(data);
case 'form':
return renderFormBlock(data);
case 'accordion':
return renderAccordionBlock(data);
default:
return <div className="block-unknown">Unknown block type: {type}</div>;
}
} catch (error) {
console.error(`Error rendering block type ${type}:`, error);
return <div className="block-error">Error rendering block: {type}</div>;
}
}
/**
* Render hero block.
*/
function renderHeroBlock(data: Record<string, any>): React.ReactElement {
return (
<section className="block-hero" style={{ padding: '4rem 2rem', textAlign: 'center', background: data.background || '#f0f0f0' }}>
<h1>{data.title || 'Hero Title'}</h1>
{data.subtitle && <p>{data.subtitle}</p>}
{data.buttonText && (
<a href={data.buttonLink || '#'} className="button">
{data.buttonText}
</a>
)}
</section>
);
}
/**
* Render text block.
*/
function renderTextBlock(data: Record<string, any>): React.ReactElement {
return (
<div className="block-text" style={{ padding: '2rem' }}>
{data.content && <div dangerouslySetInnerHTML={{ __html: data.content }} />}
</div>
);
}
/**
* Render image block.
*/
function renderImageBlock(data: Record<string, any>): React.ReactElement {
return (
<div className="block-image" style={{ padding: '1rem' }}>
{data.src && (
<img
src={data.src}
alt={data.alt || ''}
style={{ maxWidth: '100%', height: 'auto' }}
/>
)}
{data.caption && <p style={{ fontSize: '0.9rem', color: '#666' }}>{data.caption}</p>}
</div>
);
}
/**
* Render button block.
*/
function renderButtonBlock(data: Record<string, any>): React.ReactElement {
return (
<div className="block-button" style={{ padding: '1rem', textAlign: data.align || 'center' }}>
<a
href={data.link || '#'}
className="button"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
background: data.color || '#007bff',
color: 'white',
textDecoration: 'none',
borderRadius: '4px'
}}
>
{data.text || 'Button'}
</a>
</div>
);
}
/**
* Render section block.
*/
function renderSectionBlock(data: Record<string, any>): React.ReactElement {
return (
<section className="block-section" style={{ padding: '3rem 2rem', background: data.background || 'transparent' }}>
{data.title && <h2>{data.title}</h2>}
{data.content && <div dangerouslySetInnerHTML={{ __html: data.content }} />}
</section>
);
}
/**
* Render grid block.
*/
function renderGridBlock(data: Record<string, any>): React.ReactElement {
const columns = data.columns || 3;
return (
<div className="block-grid" style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '2rem', padding: '2rem' }}>
{data.items?.map((item: any, index: number) => (
<div key={index} className="grid-item">
{item.content && <div dangerouslySetInnerHTML={{ __html: item.content }} />}
</div>
))}
</div>
);
}
/**
* Render card block.
*/
function renderCardBlock(data: Record<string, any>): React.ReactElement {
return (
<div className="block-card" style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '1.5rem', margin: '1rem' }}>
{data.title && <h3>{data.title}</h3>}
{data.content && <div dangerouslySetInnerHTML={{ __html: data.content }} />}
</div>
);
}
/**
* Render list block.
*/
function renderListBlock(data: Record<string, any>): React.ReactElement {
const listType = data.ordered ? 'ol' : 'ul';
return React.createElement(
listType,
{ className: 'block-list', style: { padding: '1rem 2rem' } },
data.items?.map((item: string, index: number) => (
<li key={index}>{item}</li>
))
);
}
/**
* Render quote block.
*/
function renderQuoteBlock(data: Record<string, any>): React.ReactElement {
return (
<blockquote className="block-quote" style={{ padding: '2rem', borderLeft: '4px solid #007bff', margin: '2rem 0', fontStyle: 'italic' }}>
{data.quote && <p>{data.quote}</p>}
{data.author && <cite> {data.author}</cite>}
</blockquote>
);
}
/**
* Render video block.
*/
function renderVideoBlock(data: Record<string, any>): React.ReactElement {
return (
<div className="block-video" style={{ padding: '1rem' }}>
{data.src && (
<video controls style={{ maxWidth: '100%' }}>
<source src={data.src} type={data.type || 'video/mp4'} />
</video>
)}
{data.embed && <div dangerouslySetInnerHTML={{ __html: data.embed }} />}
</div>
);
}
/**
* Render form block.
*/
function renderFormBlock(data: Record<string, any>): React.ReactElement {
return (
<form className="block-form" style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
{data.fields?.map((field: any, index: number) => (
<div key={index} style={{ marginBottom: '1rem' }}>
<label>{field.label}</label>
<input
type={field.type || 'text'}
name={field.name}
placeholder={field.placeholder}
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
))}
<button type="submit" style={{ padding: '0.75rem 1.5rem', background: '#007bff', color: 'white', border: 'none', borderRadius: '4px' }}>
{data.submitText || 'Submit'}
</button>
</form>
);
}
/**
* Render accordion block.
*/
function renderAccordionBlock(data: Record<string, any>): React.ReactElement {
return (
<div className="block-accordion" style={{ padding: '1rem' }}>
{data.items?.map((item: any, index: number) => (
<details key={index} style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: '4px', padding: '1rem' }}>
<summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>{item.title}</summary>
<div style={{ marginTop: '1rem' }}>{item.content}</div>
</details>
))}
</div>
);
}

26
sites/tsconfig.app.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

14
sites/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@shared/*": ["../frontend/src/components/shared/*"]
}
}
}

23
sites/tsconfig.node.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

32
sites/vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sharedPathCandidates = [
path.resolve(__dirname, '../frontend/src/components/shared'),
path.resolve(__dirname, '../../frontend/src/components/shared'),
'/frontend/src/components/shared',
];
const sharedComponentsPath = sharedPathCandidates.find((candidate) => fs.existsSync(candidate)) ?? sharedPathCandidates[0];
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@shared': sharedComponentsPath,
},
},
server: {
host: '0.0.0.0',
port: 5176,
allowedHosts: ['sites.igny8.com'],
fs: {
allow: [path.resolve(__dirname, '..'), sharedComponentsPath],
},
},
});