From aa9b360536e751023e0e818640a4d860034b6e59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Oct 2025 14:07:47 +0000 Subject: [PATCH] feat: Add scenario management to admin panel This commit introduces the ability to create, edit, delete, and view scenarios within the admin panel. It includes backend API endpoints for scenario operations and frontend components for managing them. CORS is also enabled for the admin backend. Co-authored-by: max.mrtnv --- apps/admin-backend/src/index.ts | 90 ++++- .../components/layout/data/sidebar-data.ts | 5 + apps/admin/src/components/ui/badge.tsx | 2 + .../src/features/scenarios/api/scenarios.ts | 134 +++++++ .../scenarios/components/scenario-dialog.tsx | 345 ++++++++++++++++++ apps/admin/src/features/scenarios/index.tsx | 210 +++++++++++ apps/admin/src/features/scenarios/types.ts | 35 ++ apps/admin/src/routeTree.gen.ts | 22 ++ .../routes/_authenticated/scenarios/index.tsx | 6 + 9 files changed, 848 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/features/scenarios/api/scenarios.ts create mode 100644 apps/admin/src/features/scenarios/components/scenario-dialog.tsx create mode 100644 apps/admin/src/features/scenarios/index.tsx create mode 100644 apps/admin/src/features/scenarios/types.ts create mode 100644 apps/admin/src/routes/_authenticated/scenarios/index.tsx 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 + + + + +
+
+ +
+ + +
+
+ +