diff --git a/apps/admin-backend/src/index.ts b/apps/admin-backend/src/index.ts index d2bf1f9..4adfe3d 100644 --- a/apps/admin-backend/src/index.ts +++ b/apps/admin-backend/src/index.ts @@ -1,5 +1,6 @@ import { serve } from '@hono/node-server' import { Hono } from 'hono' +import { cors } from 'hono/cors' import { config } from 'dotenv' import { drizzle } from 'drizzle-orm/postgres-js' import { scenarioTable } from './infrastructure/database/schema.js' @@ -10,10 +11,16 @@ config({ path: '.env' }) const client = postgres(process.env.DATABASE_URL!, { prepare: false }) const db = drizzle(client) -// const db = drizzle(process.env.DATABASE_URL!) const app = new Hono() +// Enable CORS for admin frontend +app.use('*', cors({ + origin: process.env.ADMIN_FRONTEND_URL || 'http://localhost:5173', + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], +})) + app.get('/json-schema', async (c) => { const schema = await db.select().from(scenarioTable) const jsonSchema = schema[0] @@ -60,6 +67,17 @@ app.post('/api/scenarios/publish', async (c) => { } }) +// Get all scenarios (list) +app.get('/api/scenarios', async (c) => { + try { + const { desc } = await import('drizzle-orm') + const scenarios = await db.select().from(scenarioTable).orderBy(desc(scenarioTable.updatedAt)) + return c.json(scenarios) + } catch (error: any) { + return c.json({ error: error.message }, 500) + } +}) + // Get scenario by key app.get('/api/scenarios/:key', async (c) => { try { @@ -77,6 +95,76 @@ app.get('/api/scenarios/:key', async (c) => { } }) +// Update scenario by key +app.put('/api/scenarios/:key', async (c) => { + try { + const key = c.req.param('key') + const updates = await c.req.json() + const { eq } = await import('drizzle-orm') + + // Check if scenario exists + const existing = await db.select().from(scenarioTable).where(eq(scenarioTable.key, key)).limit(1) + if (existing.length === 0) { + return c.json({ error: 'Scenario not found' }, 404) + } + + // Update the scenario + const result = await db.update(scenarioTable) + .set({ + ...updates, + updatedAt: new Date(), + buildNumber: existing[0].buildNumber + 1, // Increment build number + }) + .where(eq(scenarioTable.key, key)) + .returning() + + return c.json(result[0]) + } catch (error: any) { + return c.json({ error: error.message }, 500) + } +}) + +// Delete scenario by key +app.delete('/api/scenarios/:key', async (c) => { + try { + const key = c.req.param('key') + const { eq } = await import('drizzle-orm') + + // Check if scenario exists + const existing = await db.select().from(scenarioTable).where(eq(scenarioTable.key, key)).limit(1) + if (existing.length === 0) { + return c.json({ error: 'Scenario not found' }, 404) + } + + await db.delete(scenarioTable).where(eq(scenarioTable.key, key)) + return c.json({ message: 'Scenario deleted successfully' }) + } catch (error: any) { + return c.json({ error: error.message }, 500) + } +}) + +// Validate JSX code without transpiling +app.post('/api/scenarios/validate', async (c) => { + try { + const { jsxCode } = await c.req.json() + + if (!jsxCode) { + return c.json({ error: 'jsxCode is required' }, 400) + } + + // Try to transpile to validate + await transpile(jsxCode) + + return c.json({ valid: true, message: 'JSX code is valid' }) + } catch (error: any) { + return c.json({ + valid: false, + message: 'JSX code is invalid', + error: error.message + }, 400) + } +}) + serve( { fetch: app.fetch, diff --git a/apps/admin/src/components/layout/data/sidebar-data.ts b/apps/admin/src/components/layout/data/sidebar-data.ts index 2a7ed18..7347061 100644 --- a/apps/admin/src/components/layout/data/sidebar-data.ts +++ b/apps/admin/src/components/layout/data/sidebar-data.ts @@ -81,6 +81,11 @@ export const sidebarData: SidebarData = { url: '/editor', icon: IconCode, }, + { + title: 'Scenarios', + url: '/scenarios', + icon: IconCode, + }, { title: 'Avito Design System', url: '/avito-design-system', diff --git a/apps/admin/src/components/ui/badge.tsx b/apps/admin/src/components/ui/badge.tsx index 42244ea..d78aa6b 100644 --- a/apps/admin/src/components/ui/badge.tsx +++ b/apps/admin/src/components/ui/badge.tsx @@ -12,6 +12,8 @@ const badgeVariants = cva( secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', destructive: 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + success: + 'border-transparent bg-green-600 text-white [a&]:hover:bg-green-600/90 focus-visible:ring-green-600/20 dark:focus-visible:ring-green-600/40', outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', }, }, diff --git a/apps/admin/src/features/scenarios/api/scenarios.ts b/apps/admin/src/features/scenarios/api/scenarios.ts new file mode 100644 index 0000000..012faf8 --- /dev/null +++ b/apps/admin/src/features/scenarios/api/scenarios.ts @@ -0,0 +1,134 @@ +import type { + Scenario, + CreateScenarioData, + UpdateScenarioData, + CompileResult, + ValidationResult +} from '../types' + +const API_BASE = 'http://localhost:3050/api' + +export const scenarioService = { + async getAll(): Promise { + const response = await fetch(`${API_BASE}/scenarios`) + if (!response.ok) { + throw new Error('Failed to fetch scenarios') + } + return response.json() + }, + + async getByKey(key: string): Promise { + const response = await fetch(`${API_BASE}/scenarios/${encodeURIComponent(key)}`) + if (!response.ok) { + throw new Error('Failed to fetch scenario') + } + return response.json() + }, + + async create(data: CreateScenarioData): Promise { + // First compile the JSX code + const compiled = await this.compile(data.jsxCode) + + // Then publish the compiled scenario + const response = await fetch(`${API_BASE}/scenarios/publish`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + key: data.key || compiled.key, + main: compiled.main, + components: compiled.components, + version: compiled.version, + metadata: { + ...data.metadata, + jsxCode: data.jsxCode, + }, + }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to create scenario') + } + + return response.json() + }, + + async update(key: string, data: UpdateScenarioData): Promise { + let updates: any = { + metadata: data.metadata, + } + + // If JSX code is being updated, compile it first + if (data.jsxCode) { + const compiled = await this.compile(data.jsxCode) + updates = { + ...updates, + mainComponent: compiled.main, + components: compiled.components, + metadata: { + ...data.metadata, + jsxCode: data.jsxCode, + }, + } + } + + const response = await fetch(`${API_BASE}/scenarios/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updates), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to update scenario') + } + + return response.json() + }, + + async delete(key: string): Promise { + const response = await fetch(`${API_BASE}/scenarios/${encodeURIComponent(key)}`, { + method: 'DELETE', + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to delete scenario') + } + }, + + async compile(jsxCode: string): Promise { + const response = await fetch(`${API_BASE}/scenarios/compile`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ jsxCode }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to compile JSX') + } + + return response.json() + }, + + async validate(jsxCode: string): Promise { + const response = await fetch(`${API_BASE}/scenarios/validate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ jsxCode }), + }) + + // Note: validation endpoint returns 400 for invalid code + const result = await response.json() + return result + }, +} \ No newline at end of file diff --git a/apps/admin/src/features/scenarios/components/scenario-dialog.tsx b/apps/admin/src/features/scenarios/components/scenario-dialog.tsx new file mode 100644 index 0000000..fb72fe2 --- /dev/null +++ b/apps/admin/src/features/scenarios/components/scenario-dialog.tsx @@ -0,0 +1,345 @@ +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + CheckCircle, + XCircle, + Loader2, + Code, + Eye, + Save, + Upload, + AlertTriangle +} from 'lucide-react' +import { scenarioService } from '../api/scenarios' +import type { Scenario, CompileResult, ValidationResult } from '../types' + +interface ScenarioDialogProps { + mode: 'create' | 'edit' | null + scenario?: Scenario | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +const DEFAULT_JSX = `export const SCENARIO_KEY = 'my-scenario' + +export default function MyScenario() { + return ( +
+

Hello World

+

This is a sample scenario.

+
+ ) +}` + +export function ScenarioDialog({ + mode, + scenario, + open, + onOpenChange, + onSuccess +}: ScenarioDialogProps) { + const [key, setKey] = useState('') + const [jsxCode, setJsxCode] = useState(DEFAULT_JSX) + const [validation, setValidation] = useState(null) + const [compiled, setCompiled] = useState(null) + const [isValidating, setIsValidating] = useState(false) + const [isCompiling, setIsCompiling] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [activeTab, setActiveTab] = useState('editor') + + // Initialize form when dialog opens + useEffect(() => { + if (mode === 'edit' && scenario) { + setKey(scenario.key) + setJsxCode(scenario.metadata?.jsxCode || DEFAULT_JSX) + } else if (mode === 'create') { + setKey('') + setJsxCode(DEFAULT_JSX) + } + + // Reset state + setValidation(null) + setCompiled(null) + setActiveTab('editor') + }, [mode, scenario, open]) + + const handleValidate = async () => { + if (!jsxCode.trim()) return + + setIsValidating(true) + try { + const result = await scenarioService.validate(jsxCode) + setValidation(result) + if (result.valid) { + setActiveTab('preview') + } + } catch (error) { + setValidation({ + valid: false, + message: 'Validation failed', + error: error instanceof Error ? error.message : 'Unknown error' + }) + } finally { + setIsValidating(false) + } + } + + const handleCompile = async () => { + if (!jsxCode.trim()) return + + setIsCompiling(true) + try { + const result = await scenarioService.compile(jsxCode) + setCompiled(result) + setValidation({ valid: true, message: 'JSX compiled successfully' }) + setActiveTab('preview') + } catch (error) { + setValidation({ + valid: false, + message: 'Compilation failed', + error: error instanceof Error ? error.message : 'Unknown error' + }) + setCompiled(null) + } finally { + setIsCompiling(false) + } + } + + const handleSave = async () => { + if (!jsxCode.trim()) return + if (mode === 'create' && !key.trim()) return + + setIsSaving(true) + try { + if (mode === 'create') { + await scenarioService.create({ + key: key.trim(), + jsxCode, + metadata: { description: 'Created via admin panel' } + }) + } else if (mode === 'edit' && scenario) { + await scenarioService.update(scenario.key, { + jsxCode, + metadata: { + ...scenario.metadata, + jsxCode, + description: 'Updated via admin panel' + } + }) + } + + onSuccess() + } catch (error) { + console.error('Failed to save scenario:', error) + // TODO: Show error toast + } finally { + setIsSaving(false) + } + } + + const isValid = validation?.valid === true + const canSave = jsxCode.trim() && (mode === 'edit' || key.trim()) && isValid + + return ( + + + + + {mode === 'create' ? 'Create New Scenario' : 'Edit Scenario'} + + + {mode === 'create' + ? 'Write JSX code and compile it to a JSON schema.' + : 'Modify the JSX code and update the scenario.' + } + + + +
+ {mode === 'create' && ( +
+ + setKey(e.target.value)} + placeholder='my-awesome-scenario' + className='font-mono' + /> +
+ )} + + + + + + JSX Editor + + + + Preview + + + + +
+
+ +
+ + +
+
+ +