From c4bcb4c6aaf70cfb66bd938cd1797ff5ef951a6c Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Thu, 31 Jul 2025 22:19:21 -0700 Subject: [PATCH 1/3] initial rev of web demo of PPTX creation --- .dockerignore | 66 +++ DEPLOYMENT.md | 97 ++++ Dockerfile | 71 +++ docs/postman/README.md | 114 +++++ docs/postman/presentation-demo.json | 415 ++++++++++++++++++ next.config.ts | 13 +- package.json | 8 +- railway.json | 14 + src/app/api/presentations/create/route.ts | 137 ++++++ .../api/presentations/download/[id]/route.ts | 78 ++++ src/app/api/presentations/format/route.ts | 190 ++++++++ src/app/api/presentations/templates/route.ts | 68 +++ src/app/presentations/demo/page.tsx | 375 ++++++++++++++++ src/lib/presentation-api.ts | 132 ++++++ 14 files changed, 1774 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 docs/postman/README.md create mode 100644 docs/postman/presentation-demo.json create mode 100644 railway.json create mode 100644 src/app/api/presentations/create/route.ts create mode 100644 src/app/api/presentations/download/[id]/route.ts create mode 100644 src/app/api/presentations/format/route.ts create mode 100644 src/app/api/presentations/templates/route.ts create mode 100644 src/app/presentations/demo/page.tsx create mode 100644 src/lib/presentation-api.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..696c9d5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,66 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Testing +coverage +.nyc_output +tests/tmp + +# Next.js +.next/ +out/ + +# Production build +dist +build + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Vercel +.vercel + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Documentation +README.md +docs/ +*.md +!CLAUDE.md + +# Temporary files +tmp/ +temp/ +*.tmp +*.log + +# E2E test artifacts +__fs_snapshots__/ +tmp/test-e2e-* + +# Development only files +.eslintrc* +prettier.config.* +jest.config.* +tailwind.config.* +tsconfig*.json \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..24e1022 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,97 @@ +# Presentation Demo - Deployment Guide + +## Railway Deployment + +### Prerequisites +- Railway account (railway.app) +- GitHub repository connected to Railway + +### Deployment Steps + +1. **Connect Repository** + ```bash + # Install Railway CLI (optional) + npm install -g @railway/cli + railway login + ``` + +2. **Create New Project** + - Go to railway.app + - Click "New Project" + - Choose "Deploy from GitHub repo" + - Select your repository + +3. **Configure Environment** + Railway will automatically: + - Detect the Dockerfile + - Build with system dependencies (pandoc, chromium) + - Install Mermaid CLI + - Expose port 3000 + +4. **Custom Domain (Optional)** + - Go to project settings + - Add custom domain + - Update Postman environment variable + +### Environment Variables +No additional environment variables required for basic functionality. + +Optional configurations: +- `NODE_ENV=production` (auto-set by Railway) +- `PORT=3000` (auto-set by Railway) + +### Build Process +The Dockerfile handles: +- Installing pandoc for document conversion +- Installing Chromium for Mermaid diagram generation +- Installing Mermaid CLI globally +- Building Next.js application +- Setting up proper permissions + +### Monitoring +- Railway provides automatic logs +- Health check endpoint: `/api/presentations/templates` +- Monitor build logs for any dependency issues + +### Troubleshooting + +**Build Failures:** +- Check Dockerfile syntax +- Verify all dependencies are available in Alpine Linux +- Check build logs for specific error messages + +**Runtime Issues:** +- Verify Mermaid CLI can access Chromium +- Check file permissions for temp directory +- Monitor memory usage (Railway has limits per plan) + +**API Issues:** +- Test endpoints with Postman collection +- Check server logs in Railway dashboard +- Verify all presentation templates are accessible + +### Local Testing +Before deploying, test the Docker build locally: + +```bash +# Build the image +docker build -t presentation-demo . + +# Run locally +docker run -p 3000:3000 presentation-demo + +# Test endpoints +curl http://localhost:3000/api/presentations/templates +``` + +### Performance Considerations +- PPTX generation can take 30-60 seconds +- Mermaid diagram rendering requires Chromium (memory intensive) +- Consider Railway Pro plan for production use +- File cleanup happens automatically (temp directory is ephemeral) + +### Security +- Application runs as non-root user +- Temp files are isolated per container +- No persistent storage of user data +- All generated files are cleaned up on container restart \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3330f95 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# Multi-stage build for markdown-workflow presentation demo +FROM node:20-alpine AS base + +# Install system dependencies +RUN apk add --no-cache \ + # Pandoc for document conversion + pandoc \ + # Chromium and dependencies for Mermaid CLI + chromium \ + nss \ + freetype \ + freetype-dev \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + # Additional fonts for better PDF/PPTX output + font-noto \ + font-noto-cjk \ + # Git for potential version control features + git \ + # Build tools + make \ + g++ + +# Configure Puppeteer to use installed Chromium +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser +ENV PUPPETEER_ARGS="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --disable-gpu" + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY pnpm-lock.yaml ./ + +# Install pnpm +RUN npm install -g pnpm + +# Install Mermaid CLI globally (before app dependencies to leverage caching) +RUN npm install -g @mermaid-js/mermaid-cli + +# Install app dependencies +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY . . + +# Build the Next.js application +RUN pnpm run build + +# Create non-root user for security +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nextjs -u 1001 + +# Create temp directory for presentations with proper permissions +RUN mkdir -p /app/tmp/presentation-demo && \ + chown -R nextjs:nodejs /app/tmp + +# Switch to non-root user +USER nextjs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:3000/api/presentations/templates || exit 1 + +# Start the application +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/docs/postman/README.md b/docs/postman/README.md new file mode 100644 index 0000000..536cd70 --- /dev/null +++ b/docs/postman/README.md @@ -0,0 +1,114 @@ +# Presentation API - Postman Collection + +This Postman collection provides comprehensive testing for the Presentation API endpoints. + +## Setup + +1. **Import Collection**: Import `presentation-demo.json` into Postman +2. **Set Environment**: Create environment with the following variables: + - `baseUrl`: `http://localhost:3000` (for local development) + - `collectionId`: Leave empty (auto-populated by tests) + +## API Endpoints + +### 1. GET /api/presentations/templates +**Purpose**: Retrieve available presentation templates +**Tests**: Validates response structure and template properties + +### 2. POST /api/presentations/create +**Purpose**: Create a new presentation collection +**Body**: +```json +{ + "title": "API Test Presentation", + "templateName": "default" +} +``` +**Tests**: Validates collection creation and auto-captures `collectionId` + +### 3. POST /api/presentations/format +**Purpose**: Format presentation to PPTX with custom content +**Body**: +```json +{ + "collectionId": "{{collectionId}}", + "content": "# Custom markdown content...", + "mermaidOptions": { + "theme": "default", + "output_format": "png", + "timeout": 30 + } +} +``` +**Tests**: Validates formatting success and download URL + +### 4. GET /api/presentations/download/{id} +**Purpose**: Download the generated PPTX file +**Tests**: Validates file download headers and content type + +## Running Tests + +### Individual Requests +Run requests in sequence (1 → 2 → 3 → 4) for full workflow testing. + +### Collection Runner +1. Click "Run Collection" +2. Select all requests +3. Run with 1-2 second delay between requests +4. All tests should pass for successful API functionality + +## Environment Variables + +### Local Development +``` +baseUrl: http://localhost:3000 +collectionId: (auto-populated) +``` + +### Production (Railway) +``` +baseUrl: https://your-app.railway.app +collectionId: (auto-populated) +``` + +## Expected Results + +- **Templates**: Should return at least `default` and `beginner` templates +- **Create**: Returns collection ID like `api_test_presentation_20250801` +- **Format**: Processing time varies (30s-2min depending on diagrams) +- **Download**: PPTX file typically 50KB-500KB depending on content + +## Troubleshooting + +### Common Issues + +**404 on templates**: Check that presentation workflow exists in `workflows/presentation/` + +**500 on create**: Verify WorkflowEngine can write to temp directory + +**Timeout on format**: Mermaid CLI or pandoc may not be installed + +**404 on download**: File may not have been generated successfully + +### Debug Tips + +1. Check server logs for detailed error messages +2. Verify temp directory has proper write permissions +3. Test individual components (Mermaid CLI, pandoc) separately +4. Use environment variables to switch between local/production testing + +## Test Data + +The collection includes realistic test data: +- Professional presentation title +- Custom markdown content with Mermaid diagrams +- Standard Mermaid configuration options +- Proper MIME types and headers + +## Integration Testing + +This collection can be used for: +- **Development**: Quick API validation during development +- **CI/CD**: Automated testing in deployment pipelines +- **Documentation**: Living API examples and expected responses +- **Debugging**: Isolate backend issues from frontend problems \ No newline at end of file diff --git a/docs/postman/presentation-demo.json b/docs/postman/presentation-demo.json new file mode 100644 index 0000000..97c9b60 --- /dev/null +++ b/docs/postman/presentation-demo.json @@ -0,0 +1,415 @@ +{ + "info": { + "name": "Presentation API Demo", + "description": "API endpoints for the markdown-workflow presentation generator demo", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_postman_id": "presentation-demo-api", + "version": "1.0.0" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:3000", + "type": "string", + "description": "Base URL for the presentation API" + }, + { + "key": "collectionId", + "value": "", + "type": "string", + "description": "Collection ID returned from create endpoint" + } + ], + "item": [ + { + "name": "1. Get Available Templates", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response has templates array', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('templates');", + " pm.expect(jsonData.templates).to.be.an('array');", + "});", + "", + "pm.test('Templates have required properties', function () {", + " const jsonData = pm.response.json();", + " if (jsonData.templates.length > 0) {", + " const template = jsonData.templates[0];", + " pm.expect(template).to.have.property('name');", + " pm.expect(template).to.have.property('displayName');", + " pm.expect(template).to.have.property('content');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/api/presentations/templates", + "host": ["{{baseUrl}}"], + "path": ["api", "presentations", "templates"] + }, + "description": "Retrieves all available presentation templates with their content" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/presentations/templates", + "host": ["{{baseUrl}}"], + "path": ["api", "presentations", "templates"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"templates\": [\n {\n \"name\": \"default\",\n \"displayName\": \"Default\",\n \"content\": \"---\\ntitle: '{{title}}'\\nauthor: '{{user.name}}'\\n...\"\n }\n ],\n \"count\": 1\n}" + } + ] + }, + { + "name": "2. Create New Presentation", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response contains collection ID', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('collectionId');", + " pm.expect(jsonData.collectionId).to.be.a('string');", + " pm.expect(jsonData.collectionId).to.not.be.empty;", + " ", + " // Store collection ID for subsequent requests", + " pm.collectionVariables.set('collectionId', jsonData.collectionId);", + "});", + "", + "pm.test('Response indicates success', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('success');", + " pm.expect(jsonData.success).to.be.true;", + "});", + "", + "pm.test('Response contains template content', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('templateContent');", + " pm.expect(jsonData.templateContent).to.be.a('string');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"API Test Presentation\",\n \"templateName\": \"default\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/presentations/create", + "host": ["{{baseUrl}}"], + "path": ["api", "presentations", "create"] + }, + "description": "Creates a new presentation collection using the specified template" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"API Test Presentation\",\n \"templateName\": \"default\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/presentations/create", + "host": ["{{baseUrl}}"], + "path": ["api", "presentations", "create"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"collectionId\": \"api_test_presentation_20250801\",\n \"stage\": \"draft\",\n \"templateContent\": \"# API Test Presentation\\n\\n## Overview...\",\n \"message\": \"Presentation created successfully\"\n}" + } + ] + }, + { + "name": "3. Format Presentation to PPTX", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response indicates success', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('success');", + " pm.expect(jsonData.success).to.be.true;", + "});", + "", + "pm.test('Response contains download URL', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('downloadUrl');", + " pm.expect(jsonData.downloadUrl).to.be.a('string');", + " pm.expect(jsonData.downloadUrl).to.not.be.empty;", + "});", + "", + "pm.test('Response contains file size', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('fileSize');", + " pm.expect(jsonData.fileSize).to.be.a('number');", + " pm.expect(jsonData.fileSize).to.be.above(0);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"collectionId\": \"{{collectionId}}\",\n \"content\": \"# Modified Presentation Content\\n\\n## Overview\\nThis is modified content for testing the API.\\n\\n## Solution Architecture\\n\\n```mermaid:test-diagram {align=center, width=80%, layout=horizontal}\\nflowchart LR\\n A[Start] --> B[Process]\\n B --> C[End]\\n \\n style A fill:#e1f5fe\\n style C fill:#c8e6c9\\n```\\n\\n## Conclusion\\nAPI formatting test complete.\",\n \"mermaidOptions\": {\n \"theme\": \"default\",\n \"output_format\": \"png\",\n \"timeout\": 30\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/presentations/format", + "host": ["{{baseUrl}}"], + "path": ["api", "presentations", "format"] + }, + "description": "Formats the presentation collection to PPTX with optional custom content and Mermaid options" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"collectionId\": \"api_test_presentation_20250801\",\n \"content\": \"# Modified Content...\",\n \"mermaidOptions\": {\n \"theme\": \"default\",\n \"output_format\": \"png\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/presentations/format", + "host": ["{{baseUrl}}"], + "path": ["api", "presentations", "format"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"success\": true,\n \"downloadUrl\": \"/api/presentations/download/api_test_presentation_20250801\",\n \"message\": \"Presentation formatted successfully\",\n \"fileSize\": 45678\n}" + } + ] + }, + { + "name": "4. Download PPTX File", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response is PPTX file', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('application/vnd.openxmlformats-officedocument.presentationml.presentation');", + "});", + "", + "pm.test('Response has attachment header', function () {", + " const contentDisposition = pm.response.headers.get('Content-Disposition');", + " pm.expect(contentDisposition).to.include('attachment');", + " pm.expect(contentDisposition).to.include('.pptx');", + "});", + "", + "pm.test('Response has content length', function () {", + " const contentLength = pm.response.headers.get('Content-Length');", + " pm.expect(contentLength).to.not.be.null;", + " pm.expect(parseInt(contentLength)).to.be.above(0);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/api/presentations/download/{{collectionId}}", + "host": ["{{baseUrl}}"], + "path": ["api", "presentations", "download", "{{collectionId}}"] + }, + "description": "Downloads the generated PPTX file for the presentation" + }, + "response": [ + { + "name": "Success Response (Binary File)", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/presentations/download/api_test_presentation_20250801", + "host": ["{{baseUrl}}"], + "path": ["api", "presentations", "download", "api_test_presentation_20250801"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "text", + "header": [ + { + "key": "Content-Type", + "value": "application/vnd.openxmlformats-officedocument.presentationml.presentation" + }, + { + "key": "Content-Disposition", + "value": "attachment; filename=\"api_test_presentation_20250801.pptx\"" + }, + { + "key": "Content-Length", + "value": "45678" + } + ], + "cookie": [], + "body": "[Binary PPTX file content]" + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "// Global pre-request script", + "console.log('Running request to:', pm.request.url);" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Global test script", + "console.log('Response time:', pm.response.responseTime + 'ms');" + ] + } + } + ] +} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 5e891cf..35318ed 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,18 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - /* config options here */ + webpack: (config) => { + // Handle .js imports that should resolve to .ts files + config.resolve.extensionAlias = { + '.js': ['.js', '.ts'], + '.jsx': ['.jsx', '.tsx'], + }; + + return config; + }, + + // External packages that should not be bundled + serverExternalPackages: ['@mermaid-js/mermaid-cli'], }; export default nextConfig; diff --git a/package.json b/package.json index ad3f214..a919b9f 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,13 @@ "wf": "dist/cli/index.js" }, "scripts": { + "dev": "next dev", "start": "next start", "lint": "eslint . --ext .ts,.tsx,.js", "cli": "tsx src/cli/index.ts", "cli:build": "tsc --project tsconfig.cli.json", - "build": "tsc --project tsconfig.cli.json", + "build": "next build", + "build:cli": "tsc --project tsconfig.cli.json", "test": "jest", "test:watch": "jest --watch", "test:e2e": "bash scripts/test-e2e.sh", @@ -27,8 +29,8 @@ "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"", "lint:md": "markdownlint \"**/*.md\" --ignore node_modules", - "preflight": "pnpm run build && pnpm run test && pnpm run lint && pnpm run format:check", - "preflight:full": "pnpm run build && pnpm run test && pnpm run lint && pnpm run format:check && pnpm run test:e2e:snapshots", + "preflight": "pnpm run build:cli && pnpm run test && pnpm run lint && pnpm run format:check", + "preflight:full": "pnpm run build:cli && pnpm run test && pnpm run lint && pnpm run format:check && pnpm run test:e2e:snapshots", "generate-mock-fs": "tsx scripts/generate-mock-fs.ts", "snapshot": "node scripts/snapshot.js", "gh:ci:list": "gh run list --workflow=ci.yml --limit=5", diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..59aabb3 --- /dev/null +++ b/railway.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile" + }, + "deploy": { + "startCommand": "pnpm start", + "healthcheckPath": "/api/presentations/templates", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} \ No newline at end of file diff --git a/src/app/api/presentations/create/route.ts b/src/app/api/presentations/create/route.ts new file mode 100644 index 0000000..c4dce45 --- /dev/null +++ b/src/app/api/presentations/create/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { WorkflowEngine } from '@/core/workflow-engine'; +import { ConfigDiscovery } from '@/core/config-discovery'; +import * as fs from 'fs'; +import * as path from 'path'; + +interface CreatePresentationRequest { + title: string; + templateName?: string; + content?: string; +} + +/** + * POST /api/presentations/create + * Creates a new presentation collection using the WorkflowEngine + */ +export async function POST(request: NextRequest) { + try { + const body: CreatePresentationRequest = await request.json(); + + if (!body.title) { + return NextResponse.json( + { error: 'Title is required' }, + { status: 400 } + ); + } + + const templateName = body.templateName || 'default'; + + // Initialize WorkflowEngine + const configDiscovery = new ConfigDiscovery(); + const systemConfig = configDiscovery.discoverSystemConfiguration(); + + // Create a temporary project directory for the demo + // In a real app, this would be user-specific + const tempProjectDir = path.join(process.cwd(), 'tmp', 'presentation-demo'); + if (!fs.existsSync(tempProjectDir)) { + fs.mkdirSync(tempProjectDir, { recursive: true }); + } + + // Initialize project if needed + const projectMarker = path.join(tempProjectDir, '.markdown-workflow'); + if (!fs.existsSync(projectMarker)) { + fs.mkdirSync(projectMarker, { recursive: true }); + + // Create minimal config + const configPath = path.join(projectMarker, 'config.yml'); + const minimalConfig = ` +user: + name: "Demo User" + email: "demo@example.com" + +system: + mermaid: + output_format: "png" + theme: "default" + timeout: 30 +`; + fs.writeFileSync(configPath, minimalConfig); + } + + // Import the create command directly + const { createCommand } = await import('@/cli/commands/create'); + + // Create the presentation collection using the CLI command + await createCommand('presentation', body.title, { + template_variant: templateName, + cwd: tempProjectDir, + configDiscovery, + force: true // Always force recreate for the demo + }); + + // Find the created collection by looking for the most recent one + const workflowEngine = new WorkflowEngine(tempProjectDir); + const collections = await workflowEngine.getCollections('presentation'); + + // Find the collection that matches our title (most recent should be first) + const createdCollection = collections.find(c => + c.metadata.title === body.title || + c.metadata.collection_id.includes(body.title.toLowerCase().replace(/[^a-z0-9]/g, '_')) + ) || collections[0]; // Fallback to most recent + + if (!createdCollection) { + return NextResponse.json( + { error: 'Collection was created but could not be found' }, + { status: 500 } + ); + } + + const collectionId = createdCollection.metadata.collection_id; + const stage = createdCollection.metadata.status; + + console.log(`Created collection: ${collectionId} in stage: ${stage}`); + + // If custom content is provided, update the content.md file + if (body.content) { + try { + const contentPath = path.join(tempProjectDir, 'collections', 'presentation', stage, collectionId, 'content.md'); + if (fs.existsSync(contentPath)) { + fs.writeFileSync(contentPath, body.content); + } + } catch (error) { + console.warn('Failed to update content with custom text:', error); + // Don't fail the whole request for this + } + } + + // Get the template content that was used + let templateContent = ''; + try { + const contentPath = path.join(tempProjectDir, 'collections', 'presentation', stage, collectionId, 'content.md'); + if (fs.existsSync(contentPath)) { + templateContent = fs.readFileSync(contentPath, 'utf-8'); + } + } catch (error) { + console.warn('Failed to read generated content:', error); + } + + return NextResponse.json({ + success: true, + collectionId, + stage, + templateContent, + message: 'Presentation created successfully' + }); + + } catch (error) { + console.error('Error creating presentation:', error); + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/presentations/download/[id]/route.ts b/src/app/api/presentations/download/[id]/route.ts new file mode 100644 index 0000000..424d836 --- /dev/null +++ b/src/app/api/presentations/download/[id]/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * GET /api/presentations/download/[id] + * Downloads the generated PPTX file for a presentation collection + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const collectionId = params.id; + + if (!collectionId) { + return NextResponse.json( + { error: 'Collection ID is required' }, + { status: 400 } + ); + } + + // Find the PPTX file + const tempProjectDir = path.join(process.cwd(), 'tmp', 'presentation-demo'); + const collectionsDir = path.join(tempProjectDir, 'collections', 'presentation'); + const stages = ['draft', 'review', 'published']; + + let pptxFile = ''; + let fileName = `${collectionId}.pptx`; + + for (const stage of stages) { + const formattedPath = path.join(collectionsDir, stage, collectionId, 'formatted'); + if (fs.existsSync(formattedPath)) { + const files = fs.readdirSync(formattedPath); + const pptxFiles = files.filter(file => file.endsWith('.pptx')); + if (pptxFiles.length > 0) { + pptxFile = path.join(formattedPath, pptxFiles[0]); + fileName = pptxFiles[0]; // Use the actual filename + break; + } + } + } + + if (!pptxFile || !fs.existsSync(pptxFile)) { + return NextResponse.json( + { error: 'PPTX file not found' }, + { status: 404 } + ); + } + + // Read the file + const fileBuffer = fs.readFileSync(pptxFile); + const fileStats = fs.statSync(pptxFile); + + // Return the file as a download + return new NextResponse(fileBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'Content-Disposition': `attachment; filename="${fileName}"`, + 'Content-Length': fileStats.size.toString(), + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + }); + + } catch (error) { + console.error('Error downloading presentation:', error); + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/presentations/format/route.ts b/src/app/api/presentations/format/route.ts new file mode 100644 index 0000000..b51c62d --- /dev/null +++ b/src/app/api/presentations/format/route.ts @@ -0,0 +1,190 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { WorkflowEngine } from '@/core/workflow-engine'; +import { ConfigDiscovery } from '@/core/config-discovery'; +import * as fs from 'fs'; +import * as path from 'path'; + +interface FormatPresentationRequest { + collectionId: string; + content?: string; + mermaidOptions?: { + theme?: 'default' | 'dark' | 'forest' | 'neutral'; + output_format?: 'png' | 'svg'; + timeout?: number; + }; +} + +/** + * POST /api/presentations/format + * Formats a presentation collection to PPTX using the WorkflowEngine + */ +export async function POST(request: NextRequest) { + try { + const body: FormatPresentationRequest = await request.json(); + + if (!body.collectionId) { + return NextResponse.json( + { error: 'Collection ID is required' }, + { status: 400 } + ); + } + + // The CLI creates collections in a flat structure, but WorkflowEngine expects nested + // So we need to point WorkflowEngine to the parent directory of where CLI creates them + const tempProjectDir = path.join(process.cwd(), 'tmp', 'presentation-demo'); + + // Create the expected directory structure by moving/symlinking if needed + const cliCollectionsDir = path.join(tempProjectDir, 'presentation'); + const expectedCollectionsDir = path.join(tempProjectDir, 'collections', 'presentation'); + + if (fs.existsSync(cliCollectionsDir) && !fs.existsSync(expectedCollectionsDir)) { + // Create the expected parent directory + fs.mkdirSync(path.dirname(expectedCollectionsDir), { recursive: true }); + // Create a symlink so WorkflowEngine can find the collections + fs.symlinkSync(cliCollectionsDir, expectedCollectionsDir); + } + + // Use the same ConfigDiscovery approach as the create endpoint + const configDiscovery = new ConfigDiscovery(); + const workflowEngine = new WorkflowEngine(tempProjectDir, configDiscovery); + + console.log(`Looking for collection: ${body.collectionId}`); + console.log(`Project directory: ${tempProjectDir}`); + + // Debug: check both possible directory structures + const collectionsDir1 = path.join(tempProjectDir, 'collections', 'presentation'); + const collectionsDir2 = path.join(tempProjectDir, 'presentation'); + + console.log(`Collections directory (v1) exists: ${fs.existsSync(collectionsDir1)}`); + console.log(`Collections directory (v2) exists: ${fs.existsSync(collectionsDir2)}`); + + const collectionsDir = fs.existsSync(collectionsDir2) ? collectionsDir2 : collectionsDir1; + console.log(`Using collections directory: ${collectionsDir}`); + + if (fs.existsSync(collectionsDir)) { + const stages = fs.readdirSync(collectionsDir); + console.log(`Available stages: ${stages.join(', ')}`); + for (const stage of stages) { + const stageDir = path.join(collectionsDir, stage); + if (fs.existsSync(stageDir)) { + const collections = fs.readdirSync(stageDir); + console.log(`Collections in ${stage}: ${collections.join(', ')}`); + } + } + } + + // Check if collection exists first + const collection = await workflowEngine.getCollection('presentation', body.collectionId); + if (!collection) { + // Try to list all collections to debug + const allCollections = await workflowEngine.getCollections('presentation'); + console.log('Available collections via WorkflowEngine:', allCollections.map(c => c.metadata.collection_id)); + + return NextResponse.json( + { + error: 'Collection not found', + message: `Collection '${body.collectionId}' not found. Available: ${allCollections.map(c => c.metadata.collection_id).join(', ')}` + }, + { status: 404 } + ); + } + + // Update content if provided + if (body.content) { + try { + // Find the collection directory + const collectionsDir = path.join(tempProjectDir, 'collections', 'presentation'); + const stages = ['draft', 'review', 'published']; + + let collectionPath = ''; + for (const stage of stages) { + const stagePath = path.join(collectionsDir, stage, body.collectionId); + if (fs.existsSync(stagePath)) { + collectionPath = stagePath; + break; + } + } + + if (!collectionPath) { + return NextResponse.json( + { error: 'Collection not found' }, + { status: 404 } + ); + } + + // Update the content.md file + const contentPath = path.join(collectionPath, 'content.md'); + fs.writeFileSync(contentPath, body.content); + + } catch (error) { + console.error('Error updating content:', error); + return NextResponse.json( + { + error: 'Failed to update content', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } + } + + // Format the collection to PPTX using executeAction + await workflowEngine.executeAction('presentation', body.collectionId, 'format', { + formats: ['pptx'], + mermaidConfig: body.mermaidOptions ? { + output_format: body.mermaidOptions.output_format || 'png', + theme: body.mermaidOptions.theme || 'default', + timeout: body.mermaidOptions.timeout || 30 + } : undefined + }); + + // executeAction throws on error, so if we get here it succeeded + + // Find the generated PPTX file + const formattedDir = path.join(tempProjectDir, 'collections', 'presentation'); + const stages = ['draft', 'review', 'published']; + + let pptxFile = ''; + for (const stage of stages) { + const formattedPath = path.join(formattedDir, stage, body.collectionId, 'formatted'); + if (fs.existsSync(formattedPath)) { + const files = fs.readdirSync(formattedPath); + const pptxFiles = files.filter(file => file.endsWith('.pptx')); + if (pptxFiles.length > 0) { + pptxFile = path.join(formattedPath, pptxFiles[0]); + break; + } + } + } + + if (!pptxFile || !fs.existsSync(pptxFile)) { + return NextResponse.json( + { + error: 'PPTX file not found after formatting', + message: 'File may not have been generated successfully' + }, + { status: 500 } + ); + } + + // Generate download URL + const downloadUrl = `/api/presentations/download/${body.collectionId}`; + + return NextResponse.json({ + success: true, + downloadUrl, + message: 'Presentation formatted successfully', + fileSize: fs.statSync(pptxFile).size + }); + + } catch (error) { + console.error('Error formatting presentation:', error); + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/presentations/templates/route.ts b/src/app/api/presentations/templates/route.ts new file mode 100644 index 0000000..f1ea1eb --- /dev/null +++ b/src/app/api/presentations/templates/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ConfigDiscovery } from '@/core/config-discovery'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * GET /api/presentations/templates + * Returns available presentation templates with their content + */ +export async function GET(request: NextRequest) { + try { + const configDiscovery = new ConfigDiscovery(); + const systemConfig = configDiscovery.discoverSystemConfiguration(); + + // Get presentation workflow templates + const presentationWorkflowPath = path.join(systemConfig.systemRoot, 'workflows', 'presentation'); + const templatesPath = path.join(presentationWorkflowPath, 'templates', 'content'); + + if (!fs.existsSync(templatesPath)) { + return NextResponse.json( + { error: 'Presentation templates not found' }, + { status: 404 } + ); + } + + // Read available template files + const templateFiles = fs.readdirSync(templatesPath) + .filter(file => file.endsWith('.md')) + .map(file => file.replace('.md', '')); + + // Load content for each template + const templates = templateFiles.map(templateName => { + try { + const templatePath = path.join(templatesPath, `${templateName}.md`); + const content = fs.readFileSync(templatePath, 'utf-8'); + + return { + name: templateName, + displayName: templateName.charAt(0).toUpperCase() + templateName.slice(1), + content + }; + } catch (error) { + console.error(`Error reading template ${templateName}:`, error); + return { + name: templateName, + displayName: templateName.charAt(0).toUpperCase() + templateName.slice(1), + content: `# ${templateName} Template\n\nTemplate content could not be loaded.`, + error: 'Failed to load template content' + }; + } + }); + + return NextResponse.json({ + templates, + count: templates.length + }); + + } catch (error) { + console.error('Error fetching presentation templates:', error); + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/presentations/demo/page.tsx b/src/app/presentations/demo/page.tsx new file mode 100644 index 0000000..1b6001b --- /dev/null +++ b/src/app/presentations/demo/page.tsx @@ -0,0 +1,375 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + getTemplates, + createPresentation, + formatPresentation, + downloadPresentation, + downloadFile, + type Template, + type MermaidOptions, +} from '@/lib/presentation-api'; + +type Status = 'idle' | 'loading-templates' | 'creating' | 'formatting' | 'ready' | 'error'; + +export default function PresentationDemo() { + // State management + const [templates, setTemplates] = useState([]); + const [selectedTemplate, setSelectedTemplate] = useState('default'); + const [title, setTitle] = useState('Demo Presentation'); + const [content, setContent] = useState(''); + const [status, setStatus] = useState('loading-templates'); + const [error, setError] = useState(''); + const [collectionId, setCollectionId] = useState(''); + const [downloadUrl, setDownloadUrl] = useState(''); + const [fileSize, setFileSize] = useState(0); + + // Mermaid configuration + const [mermaidOptions, setMermaidOptions] = useState({ + theme: 'default', + output_format: 'png', + timeout: 30, + }); + + // Load templates on component mount + useEffect(() => { + async function loadTemplates() { + try { + setStatus('loading-templates'); + const response = await getTemplates(); + setTemplates(response.templates); + + // Load default template content + const defaultTemplate = response.templates.find(t => t.name === 'default'); + if (defaultTemplate) { + setContent(defaultTemplate.content); + } + + setStatus('idle'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load templates'); + setStatus('error'); + } + } + + loadTemplates(); + }, []); + + // Handle template selection change + const handleTemplateChange = (templateName: string) => { + setSelectedTemplate(templateName); + const template = templates.find(t => t.name === templateName); + if (template) { + setContent(template.content); + } + }; + + // Status log for debugging + const [statusLog, setStatusLog] = useState([]); + + const addStatusLog = (message: string) => { + setStatusLog(prev => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]); + }; + + // Handle form submission + const handleGenerate = async () => { + if (!title.trim()) { + setError('Title is required'); + return; + } + + try { + setError(''); + setStatusLog([]); + setStatus('creating'); + addStatusLog('Starting presentation creation...'); + + // Create presentation + addStatusLog('Creating collection...'); + const createResponse = await createPresentation({ + title: title.trim(), + templateName: selectedTemplate, + content: content || undefined, + }); + + addStatusLog(`Collection created: ${createResponse.collectionId}`); + setCollectionId(createResponse.collectionId); + setStatus('formatting'); + + // Small delay to ensure collection is fully written + addStatusLog('Waiting for collection to be ready...'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Format to PPTX + addStatusLog('Processing diagrams and formatting to PPTX...'); + const formatResponse = await formatPresentation({ + collectionId: createResponse.collectionId, + content: content, + mermaidOptions, + }); + + addStatusLog('PPTX generation complete!'); + setDownloadUrl(formatResponse.downloadUrl); + setFileSize(formatResponse.fileSize); + setStatus('ready'); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; + addStatusLog(`Error: ${errorMessage}`); + setError(errorMessage); + setStatus('error'); + } + }; + + // Handle file download + const handleDownload = () => { + if (downloadUrl) { + const filename = `${title.replace(/[^a-zA-Z0-9]/g, '_')}.pptx`; + downloadFile(downloadUrl, filename); + } + }; + + // Reset for new presentation + const handleReset = () => { + setStatus('idle'); + setError(''); + setCollectionId(''); + setDownloadUrl(''); + setFileSize(0); + const template = templates.find(t => t.name === selectedTemplate); + if (template) { + setContent(template.content); + } + }; + + // Status messages + const getStatusMessage = () => { + switch (status) { + case 'loading-templates': + return '⏳ Loading templates...'; + case 'creating': + return '⏳ Creating presentation collection...'; + case 'formatting': + return '🔄 Processing diagrams and generating PPTX... (this may take 30-60 seconds)'; + case 'ready': + return '✅ Presentation ready for download!'; + case 'error': + return `❌ Error: ${error}`; + default: + return 'Ready to generate presentation'; + } + }; + + const isProcessing = status === 'loading-templates' || status === 'creating' || status === 'formatting'; + + return ( +
+ {/* Header */} +
+

+ Presentation Generator Demo +

+

+ Create professional presentations with Mermaid diagrams and export to PPTX +

+
+ +
+ {/* Left Column: Editor */} +
+ {/* Title Input */} +
+ + setTitle(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="Enter presentation title" + disabled={isProcessing} + /> +
+ + {/* Template Selection */} +
+ + + {templates.length === 0 && status !== 'loading-templates' && ( +

No templates available

+ )} +
+ + {/* Content Editor */} +
+ +