mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-30 21:49:30 -05:00
remove: Delete entire PRP directory (4,611 lines)
- Remove PRPViewer component and all related files - Delete 29 PRP-related files including sections, renderers, utilities - Clean up unused complex document rendering system - Simplifies codebase by removing over-engineered flip card viewer Files removed: - PRPViewer.tsx/css - Main component - sections/ - 13 specialized section components - components/ - 5 rendering components - utils/ - 6 utility files - renderers/ - Section rendering logic - types/ - PRP type definitions Part of frontend vertical slice refactoring effort.
This commit is contained in:
@@ -1,304 +0,0 @@
|
||||
/* PRP Viewer Styles - Beautiful Archon Theme */
|
||||
|
||||
.prp-viewer {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth collapse animations */
|
||||
.prp-viewer .collapsible-content {
|
||||
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Hover effects for cards */
|
||||
.prp-viewer .persona-card,
|
||||
.prp-viewer .metric-item,
|
||||
.prp-viewer .flow-diagram {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Glow effects for icons */
|
||||
.prp-viewer .icon-glow {
|
||||
filter: drop-shadow(0 0 8px currentColor);
|
||||
}
|
||||
|
||||
/* Gradient text animations */
|
||||
@keyframes gradientShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.prp-viewer .gradient-text {
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Section reveal animations */
|
||||
.prp-viewer .section-content {
|
||||
animation: slideIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse animation for important metrics */
|
||||
@keyframes metricPulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.prp-viewer .metric-highlight {
|
||||
animation: metricPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Flow diagram connections */
|
||||
.prp-viewer .flow-connection {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prp-viewer .flow-connection::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: linear-gradient(135deg, #3b82f6, #a855f7);
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 0 12px rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
|
||||
/* Interactive hover states */
|
||||
.prp-viewer .interactive-section:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Dark mode enhancements */
|
||||
.dark .prp-viewer .interactive-section:hover {
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.2),
|
||||
0 0 20px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Loading skeleton animation */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.prp-viewer .skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Collapsible chevron animation */
|
||||
.prp-viewer .chevron-animate {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Card entrance animations */
|
||||
.prp-viewer .card-entrance {
|
||||
animation: cardSlideUp 0.5s ease-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes cardSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Stagger animation for lists */
|
||||
.prp-viewer .stagger-item {
|
||||
animation: fadeInUp 0.4s ease-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.prp-viewer .stagger-item:nth-child(1) { animation-delay: 0.1s; }
|
||||
.prp-viewer .stagger-item:nth-child(2) { animation-delay: 0.2s; }
|
||||
.prp-viewer .stagger-item:nth-child(3) { animation-delay: 0.3s; }
|
||||
.prp-viewer .stagger-item:nth-child(4) { animation-delay: 0.4s; }
|
||||
.prp-viewer .stagger-item:nth-child(5) { animation-delay: 0.5s; }
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating animation for icons */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.prp-viewer .float-icon {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Glow border effect */
|
||||
.prp-viewer .glow-border {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prp-viewer .glow-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(45deg, #3b82f6, #a855f7, #ec4899, #3b82f6);
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: -1;
|
||||
background-size: 400% 400%;
|
||||
animation: gradientRotate 3s ease infinite;
|
||||
}
|
||||
|
||||
.prp-viewer .glow-border:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes gradientRotate {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Success metric animations */
|
||||
.prp-viewer .metric-success {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prp-viewer .metric-success::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.prp-viewer .metric-success:hover::after {
|
||||
opacity: 1;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
/* Smooth scrolling for sections */
|
||||
.prp-viewer {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Progress indicator for implementation phases */
|
||||
.prp-viewer .phase-progress {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.prp-viewer .phase-progress::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(to bottom, #3b82f6, #a855f7);
|
||||
}
|
||||
|
||||
.prp-viewer .phase-progress .phase-dot {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 20px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.prp-viewer .grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.prp-viewer .text-3xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.prp-viewer .p-6 {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import React from 'react';
|
||||
import { PRPContent } from './types/prp.types';
|
||||
import { MetadataSection } from './sections/MetadataSection';
|
||||
import { SectionRenderer } from './renderers/SectionRenderer';
|
||||
import { normalizePRPDocument } from './utils/normalizer';
|
||||
import { processContentForPRP, isMarkdownContent, isDocumentWithMetadata } from './utils/markdownParser';
|
||||
import { MarkdownDocumentRenderer } from './components/MarkdownDocumentRenderer';
|
||||
import './PRPViewer.css';
|
||||
|
||||
interface PRPViewerProps {
|
||||
content: PRPContent;
|
||||
isDarkMode?: boolean;
|
||||
sectionOverrides?: Record<string, React.ComponentType<any>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process content to handle [Image #N] placeholders
|
||||
*/
|
||||
const processContent = (content: any): any => {
|
||||
if (typeof content === 'string') {
|
||||
// Replace [Image #N] with proper markdown image syntax
|
||||
return content.replace(/\[Image #(\d+)\]/g, (match, num) => {
|
||||
return ``;
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(item => processContent(item));
|
||||
}
|
||||
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
const processed: any = {};
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
processed[key] = processContent(value);
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flexible PRP Viewer that dynamically renders sections based on content structure
|
||||
*/
|
||||
export const PRPViewer: React.FC<PRPViewerProps> = ({
|
||||
content,
|
||||
isDarkMode = false,
|
||||
sectionOverrides = {}
|
||||
}) => {
|
||||
try {
|
||||
if (!content) {
|
||||
return <div className="text-gray-500">No PRP content available</div>;
|
||||
}
|
||||
|
||||
console.log('PRPViewer: Received content:', {
|
||||
type: typeof content,
|
||||
isString: typeof content === 'string',
|
||||
isObject: typeof content === 'object',
|
||||
hasMetadata: typeof content === 'object' && content !== null ? isDocumentWithMetadata(content) : false,
|
||||
isMarkdown: typeof content === 'string' ? isMarkdownContent(content) : false,
|
||||
keys: typeof content === 'object' && content !== null ? Object.keys(content) : [],
|
||||
contentPreview: typeof content === 'string' ? content.substring(0, 200) + '...' : 'Not a string'
|
||||
});
|
||||
|
||||
// Route to appropriate renderer based on content type
|
||||
|
||||
// 1. Check if it's a document with metadata + markdown content
|
||||
if (isDocumentWithMetadata(content)) {
|
||||
console.log('PRPViewer: Detected document with metadata, using MarkdownDocumentRenderer');
|
||||
return (
|
||||
<MarkdownDocumentRenderer
|
||||
content={content}
|
||||
isDarkMode={isDarkMode}
|
||||
sectionOverrides={sectionOverrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Check if it's a pure markdown string
|
||||
if (typeof content === 'string' && isMarkdownContent(content)) {
|
||||
console.log('PRPViewer: Detected pure markdown content, using MarkdownDocumentRenderer');
|
||||
return (
|
||||
<MarkdownDocumentRenderer
|
||||
content={content}
|
||||
isDarkMode={isDarkMode}
|
||||
sectionOverrides={sectionOverrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Check if it's an object that might contain markdown content in any field
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
// Check for markdown field first (common in PRP documents)
|
||||
if (typeof content.markdown === 'string') {
|
||||
console.log('PRPViewer: Found markdown field, using MarkdownDocumentRenderer');
|
||||
return (
|
||||
<MarkdownDocumentRenderer
|
||||
content={content}
|
||||
isDarkMode={isDarkMode}
|
||||
sectionOverrides={sectionOverrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Look for markdown content in any field
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
if (typeof value === 'string' && isMarkdownContent(value)) {
|
||||
console.log(`PRPViewer: Found markdown content in field '${key}', using MarkdownDocumentRenderer`);
|
||||
// Create a proper document structure
|
||||
const documentContent = {
|
||||
title: content.title || 'Document',
|
||||
content: value,
|
||||
...content // Include all other fields as metadata
|
||||
};
|
||||
return (
|
||||
<MarkdownDocumentRenderer
|
||||
content={documentContent}
|
||||
isDarkMode={isDarkMode}
|
||||
sectionOverrides={sectionOverrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. For any other content that might contain documents, try MarkdownDocumentRenderer first
|
||||
console.log('PRPViewer: Checking if content should use MarkdownDocumentRenderer anyway');
|
||||
|
||||
// If it's an object with any text content, try MarkdownDocumentRenderer
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
const hasAnyTextContent = Object.values(content).some(value =>
|
||||
typeof value === 'string' && value.length > 50
|
||||
);
|
||||
|
||||
if (hasAnyTextContent) {
|
||||
console.log('PRPViewer: Object has substantial text content, trying MarkdownDocumentRenderer');
|
||||
return (
|
||||
<MarkdownDocumentRenderer
|
||||
content={content}
|
||||
isDarkMode={isDarkMode}
|
||||
sectionOverrides={sectionOverrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Final fallback to original PRPViewer logic for purely structured JSON content
|
||||
console.log('PRPViewer: Using standard JSON structure renderer as final fallback');
|
||||
|
||||
// First, check if content is raw markdown and process it
|
||||
let processedForPRP = content;
|
||||
|
||||
// Handle the case where content is a raw markdown string (non-markdown strings)
|
||||
if (typeof content === 'string') {
|
||||
// For non-markdown strings, wrap in a simple structure
|
||||
processedForPRP = {
|
||||
title: 'Document Content',
|
||||
content: content,
|
||||
document_type: 'text'
|
||||
};
|
||||
} else if (typeof content === 'object' && content !== null) {
|
||||
// For objects, process normally
|
||||
processedForPRP = processContentForPRP(content);
|
||||
}
|
||||
|
||||
// Ensure we have an object to work with
|
||||
if (!processedForPRP || typeof processedForPRP !== 'object') {
|
||||
return <div className="text-gray-500">Unable to process PRP content</div>;
|
||||
}
|
||||
|
||||
// Normalize the content
|
||||
const normalizedContent = normalizePRPDocument(processedForPRP);
|
||||
|
||||
// Process content to handle [Image #N] placeholders
|
||||
const processedContent = processContent(normalizedContent);
|
||||
|
||||
// Extract sections (skip metadata fields)
|
||||
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type', 'id', '_id', 'project_id', 'created_at', 'updated_at'];
|
||||
const sections = Object.entries(processedContent).filter(([key]) => !metadataFields.includes(key));
|
||||
|
||||
// Debug: Log sections being rendered
|
||||
console.log('PRP Sections found:', sections.map(([key]) => key));
|
||||
|
||||
// Priority-based sorting for common PRP sections
|
||||
const getSectionPriority = (key: string): number => {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
|
||||
// Define priority order (lower number = higher priority)
|
||||
if (normalizedKey.includes('goal') || normalizedKey.includes('objective')) return 1;
|
||||
if (normalizedKey.includes('why') || normalizedKey.includes('rationale')) return 2;
|
||||
if (normalizedKey.includes('what') || normalizedKey === 'description') return 3;
|
||||
if (normalizedKey.includes('context') || normalizedKey.includes('background')) return 4;
|
||||
if (normalizedKey.includes('persona') || normalizedKey.includes('user') || normalizedKey.includes('stakeholder')) return 5;
|
||||
if (normalizedKey.includes('flow') || normalizedKey.includes('journey') || normalizedKey.includes('workflow')) return 6;
|
||||
if (normalizedKey.includes('requirement') && !normalizedKey.includes('technical')) return 7;
|
||||
if (normalizedKey.includes('metric') || normalizedKey.includes('success') || normalizedKey.includes('kpi')) return 8;
|
||||
if (normalizedKey.includes('timeline') || normalizedKey.includes('roadmap') || normalizedKey.includes('milestone')) return 9;
|
||||
if (normalizedKey.includes('plan') || normalizedKey.includes('implementation')) return 10;
|
||||
if (normalizedKey.includes('technical') || normalizedKey.includes('architecture') || normalizedKey.includes('tech')) return 11;
|
||||
if (normalizedKey.includes('validation') || normalizedKey.includes('testing') || normalizedKey.includes('quality')) return 12;
|
||||
if (normalizedKey.includes('risk') || normalizedKey.includes('mitigation')) return 13;
|
||||
|
||||
// Default priority for unknown sections
|
||||
return 50;
|
||||
};
|
||||
|
||||
// Sort sections by priority
|
||||
const sortedSections = sections.sort(([a], [b]) => {
|
||||
return getSectionPriority(a) - getSectionPriority(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`prp-viewer ${isDarkMode ? 'dark' : ''}`}>
|
||||
{/* Metadata Header */}
|
||||
<MetadataSection content={processedContent} isDarkMode={isDarkMode} />
|
||||
|
||||
{/* Dynamic Sections */}
|
||||
{sortedSections.map(([sectionKey, sectionData], index) => (
|
||||
<div key={sectionKey} className="mb-6">
|
||||
<SectionRenderer
|
||||
sectionKey={sectionKey}
|
||||
data={sectionData}
|
||||
index={index}
|
||||
isDarkMode={isDarkMode}
|
||||
sectionOverrides={sectionOverrides}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sections.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>No additional sections found in this PRP document.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('PRPViewer: Error rendering content:', error);
|
||||
|
||||
// Provide a meaningful error display instead of black screen
|
||||
return (
|
||||
<div className="p-6 bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
|
||||
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">Error Rendering PRP</h3>
|
||||
<p className="text-red-600 dark:text-red-300 text-sm mb-4">
|
||||
There was an error rendering this PRP document. The content may be in an unexpected format.
|
||||
</p>
|
||||
|
||||
{/* Show error details for debugging */}
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm text-red-600 dark:text-red-400 hover:underline">
|
||||
Show error details
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<pre className="p-4 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</pre>
|
||||
{error instanceof Error && error.stack && (
|
||||
<pre className="p-4 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto max-h-48">
|
||||
{error.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Show raw content for debugging */}
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-sm text-red-600 dark:text-red-400 hover:underline">
|
||||
Show raw content
|
||||
</summary>
|
||||
<pre className="mt-2 p-4 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto max-h-96">
|
||||
{typeof content === 'string'
|
||||
? content
|
||||
: JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,411 +0,0 @@
|
||||
import React, { useState, useEffect, ReactNode } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
Brain, Users, Workflow, BarChart3, Clock, Shield,
|
||||
Code, Layers, FileText, List, Hash, Box, Type, ToggleLeft,
|
||||
CheckCircle, AlertCircle, Info, Lightbulb
|
||||
} from 'lucide-react';
|
||||
import { SectionProps } from '../types/prp.types';
|
||||
import { SimpleMarkdown } from './SimpleMarkdown';
|
||||
import { formatValue } from '../utils/formatters';
|
||||
|
||||
interface CollapsibleSectionRendererProps extends SectionProps {
|
||||
children?: ReactNode;
|
||||
headerContent?: ReactNode;
|
||||
sectionKey?: string;
|
||||
contentType?: 'markdown' | 'code' | 'json' | 'list' | 'object' | 'auto';
|
||||
animationDuration?: number;
|
||||
showPreview?: boolean;
|
||||
previewLines?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced CollapsibleSectionRenderer with beautiful animations and content-aware styling
|
||||
* Features:
|
||||
* - Section-specific icons and colors
|
||||
* - Smooth expand/collapse animations with dynamic height
|
||||
* - Content type detection and appropriate formatting
|
||||
* - Code block syntax highlighting support
|
||||
* - Nested structure handling
|
||||
* - Preview mode for collapsed content
|
||||
*/
|
||||
export const CollapsibleSectionRenderer: React.FC<CollapsibleSectionRendererProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon,
|
||||
accentColor = 'gray',
|
||||
defaultOpen = true,
|
||||
isDarkMode = false,
|
||||
isCollapsible = true,
|
||||
isOpen: controlledIsOpen,
|
||||
onToggle,
|
||||
children,
|
||||
headerContent,
|
||||
sectionKey = '',
|
||||
contentType = 'auto',
|
||||
animationDuration = 300,
|
||||
showPreview = true,
|
||||
previewLines = 2
|
||||
}) => {
|
||||
// State management for collapsible behavior
|
||||
const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
|
||||
const [contentHeight, setContentHeight] = useState<number | 'auto'>('auto');
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
|
||||
|
||||
// Content ref for measuring height
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlledIsOpen === undefined) {
|
||||
setInternalIsOpen(defaultOpen);
|
||||
}
|
||||
}, [defaultOpen, controlledIsOpen]);
|
||||
|
||||
// Measure content height for smooth animations
|
||||
useEffect(() => {
|
||||
if (contentRef.current && isCollapsible) {
|
||||
const height = contentRef.current.scrollHeight;
|
||||
setContentHeight(isOpen ? height : 0);
|
||||
}
|
||||
}, [isOpen, data, children]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!isCollapsible) return;
|
||||
|
||||
setIsAnimating(true);
|
||||
|
||||
if (controlledIsOpen === undefined) {
|
||||
setInternalIsOpen(!internalIsOpen);
|
||||
}
|
||||
onToggle?.();
|
||||
|
||||
// Reset animation state after duration
|
||||
setTimeout(() => setIsAnimating(false), animationDuration);
|
||||
};
|
||||
|
||||
// Auto-detect section type and get appropriate icon
|
||||
const getSectionIcon = (): ReactNode => {
|
||||
if (icon) return icon;
|
||||
|
||||
const normalizedKey = sectionKey.toLowerCase();
|
||||
const normalizedTitle = title.toLowerCase();
|
||||
|
||||
// Check both section key and title for better detection
|
||||
const checkKeywords = (keywords: string[]) =>
|
||||
keywords.some(keyword =>
|
||||
normalizedKey.includes(keyword) || normalizedTitle.includes(keyword)
|
||||
);
|
||||
|
||||
if (checkKeywords(['context', 'overview', 'background']))
|
||||
return <Brain className="w-5 h-5" />;
|
||||
if (checkKeywords(['persona', 'user', 'actor', 'stakeholder']))
|
||||
return <Users className="w-5 h-5" />;
|
||||
if (checkKeywords(['flow', 'journey', 'workflow', 'process']))
|
||||
return <Workflow className="w-5 h-5" />;
|
||||
if (checkKeywords(['metric', 'success', 'kpi', 'measurement']))
|
||||
return <BarChart3 className="w-5 h-5" />;
|
||||
if (checkKeywords(['plan', 'implementation', 'roadmap', 'timeline']))
|
||||
return <Clock className="w-5 h-5" />;
|
||||
if (checkKeywords(['validation', 'gate', 'criteria', 'acceptance']))
|
||||
return <Shield className="w-5 h-5" />;
|
||||
if (checkKeywords(['technical', 'tech', 'architecture', 'system']))
|
||||
return <Code className="w-5 h-5" />;
|
||||
if (checkKeywords(['architecture', 'structure', 'design']))
|
||||
return <Layers className="w-5 h-5" />;
|
||||
if (checkKeywords(['feature', 'functionality', 'capability']))
|
||||
return <Lightbulb className="w-5 h-5" />;
|
||||
if (checkKeywords(['requirement', 'spec', 'specification']))
|
||||
return <CheckCircle className="w-5 h-5" />;
|
||||
if (checkKeywords(['risk', 'issue', 'concern', 'challenge']))
|
||||
return <AlertCircle className="w-5 h-5" />;
|
||||
if (checkKeywords(['info', 'note', 'detail']))
|
||||
return <Info className="w-5 h-5" />;
|
||||
|
||||
// Fallback based on data type
|
||||
if (typeof data === 'string') return <Type className="w-5 h-5" />;
|
||||
if (typeof data === 'number') return <Hash className="w-5 h-5" />;
|
||||
if (typeof data === 'boolean') return <ToggleLeft className="w-5 h-5" />;
|
||||
if (Array.isArray(data)) return <List className="w-5 h-5" />;
|
||||
if (typeof data === 'object' && data !== null) return <Box className="w-5 h-5" />;
|
||||
|
||||
return <FileText className="w-5 h-5" />;
|
||||
};
|
||||
|
||||
// Get section-specific color scheme
|
||||
const getColorScheme = () => {
|
||||
const normalizedKey = sectionKey.toLowerCase();
|
||||
const normalizedTitle = title.toLowerCase();
|
||||
|
||||
const checkKeywords = (keywords: string[]) =>
|
||||
keywords.some(keyword =>
|
||||
normalizedKey.includes(keyword) || normalizedTitle.includes(keyword)
|
||||
);
|
||||
|
||||
if (checkKeywords(['context', 'overview'])) return 'blue';
|
||||
if (checkKeywords(['persona', 'user'])) return 'purple';
|
||||
if (checkKeywords(['flow', 'journey'])) return 'orange';
|
||||
if (checkKeywords(['metric', 'success'])) return 'green';
|
||||
if (checkKeywords(['plan', 'implementation'])) return 'cyan';
|
||||
if (checkKeywords(['validation', 'gate'])) return 'emerald';
|
||||
if (checkKeywords(['technical', 'architecture'])) return 'indigo';
|
||||
if (checkKeywords(['feature'])) return 'yellow';
|
||||
if (checkKeywords(['risk', 'issue'])) return 'red';
|
||||
|
||||
return accentColor;
|
||||
};
|
||||
|
||||
// Auto-detect content type if not specified
|
||||
const getContentType = () => {
|
||||
if (contentType !== 'auto') return contentType;
|
||||
|
||||
if (typeof data === 'string') {
|
||||
// Check for code patterns
|
||||
if (/^```[\s\S]*```$/m.test(data) ||
|
||||
/^\s*(function|class|const|let|var|import|export)\s/m.test(data) ||
|
||||
/^\s*[{[][\s\S]*[}\]]$/m.test(data)) {
|
||||
return 'code';
|
||||
}
|
||||
|
||||
// Check for markdown patterns
|
||||
if (/^#{1,6}\s+.+$|^[-*+]\s+.+$|^\d+\.\s+.+$|```|^\>.+$|\*\*.+\*\*|\*.+\*|`[^`]+`/m.test(data)) {
|
||||
return 'markdown';
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) return 'list';
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
try {
|
||||
JSON.stringify(data);
|
||||
return 'json';
|
||||
} catch {
|
||||
return 'object';
|
||||
}
|
||||
}
|
||||
|
||||
return 'auto';
|
||||
};
|
||||
|
||||
// Render content based on type
|
||||
const renderContent = (): ReactNode => {
|
||||
if (children) return children;
|
||||
|
||||
const detectedType = getContentType();
|
||||
|
||||
switch (detectedType) {
|
||||
case 'markdown':
|
||||
return <SimpleMarkdown content={data} className="text-gray-700 dark:text-gray-300" />;
|
||||
|
||||
case 'code':
|
||||
return (
|
||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-sm text-gray-100">
|
||||
<code>{data}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'json':
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'list':
|
||||
if (!Array.isArray(data)) return <span className="text-gray-500 italic">Invalid list data</span>;
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{data.map((item, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="text-gray-400 mt-0.5 flex-shrink-0">•</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{formatValue(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
default:
|
||||
return <span className="text-gray-700 dark:text-gray-300">{formatValue(data)}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
// Generate preview content when collapsed
|
||||
const renderPreview = (): ReactNode => {
|
||||
if (!showPreview || isOpen || !data) return null;
|
||||
|
||||
const dataStr = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
const lines = dataStr.split('\n').slice(0, previewLines);
|
||||
const preview = lines.join('\n');
|
||||
const hasMore = dataStr.split('\n').length > previewLines;
|
||||
|
||||
return (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-2 px-4 pb-2">
|
||||
<div className="truncate">
|
||||
{preview}
|
||||
{hasMore && <span className="ml-1">...</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const colorScheme = getColorScheme();
|
||||
const sectionIcon = getSectionIcon();
|
||||
|
||||
// Color mapping for backgrounds and borders
|
||||
const getColorClasses = () => {
|
||||
const colorMap = {
|
||||
blue: {
|
||||
bg: 'bg-blue-50/50 dark:bg-blue-950/20',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900',
|
||||
iconText: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'border-l-blue-500'
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-50/50 dark:bg-purple-950/20',
|
||||
border: 'border-purple-200 dark:border-purple-800',
|
||||
iconBg: 'bg-purple-100 dark:bg-purple-900',
|
||||
iconText: 'text-purple-600 dark:text-purple-400',
|
||||
accent: 'border-l-purple-500'
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-50/50 dark:bg-green-950/20',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
iconBg: 'bg-green-100 dark:bg-green-900',
|
||||
iconText: 'text-green-600 dark:text-green-400',
|
||||
accent: 'border-l-green-500'
|
||||
},
|
||||
orange: {
|
||||
bg: 'bg-orange-50/50 dark:bg-orange-950/20',
|
||||
border: 'border-orange-200 dark:border-orange-800',
|
||||
iconBg: 'bg-orange-100 dark:bg-orange-900',
|
||||
iconText: 'text-orange-600 dark:text-orange-400',
|
||||
accent: 'border-l-orange-500'
|
||||
},
|
||||
cyan: {
|
||||
bg: 'bg-cyan-50/50 dark:bg-cyan-950/20',
|
||||
border: 'border-cyan-200 dark:border-cyan-800',
|
||||
iconBg: 'bg-cyan-100 dark:bg-cyan-900',
|
||||
iconText: 'text-cyan-600 dark:text-cyan-400',
|
||||
accent: 'border-l-cyan-500'
|
||||
},
|
||||
indigo: {
|
||||
bg: 'bg-indigo-50/50 dark:bg-indigo-950/20',
|
||||
border: 'border-indigo-200 dark:border-indigo-800',
|
||||
iconBg: 'bg-indigo-100 dark:bg-indigo-900',
|
||||
iconText: 'text-indigo-600 dark:text-indigo-400',
|
||||
accent: 'border-l-indigo-500'
|
||||
},
|
||||
emerald: {
|
||||
bg: 'bg-emerald-50/50 dark:bg-emerald-950/20',
|
||||
border: 'border-emerald-200 dark:border-emerald-800',
|
||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900',
|
||||
iconText: 'text-emerald-600 dark:text-emerald-400',
|
||||
accent: 'border-l-emerald-500'
|
||||
},
|
||||
yellow: {
|
||||
bg: 'bg-yellow-50/50 dark:bg-yellow-950/20',
|
||||
border: 'border-yellow-200 dark:border-yellow-800',
|
||||
iconBg: 'bg-yellow-100 dark:bg-yellow-900',
|
||||
iconText: 'text-yellow-600 dark:text-yellow-400',
|
||||
accent: 'border-l-yellow-500'
|
||||
},
|
||||
red: {
|
||||
bg: 'bg-red-50/50 dark:bg-red-950/20',
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
iconBg: 'bg-red-100 dark:bg-red-900',
|
||||
iconText: 'text-red-600 dark:text-red-400',
|
||||
accent: 'border-l-red-500'
|
||||
},
|
||||
gray: {
|
||||
bg: 'bg-gray-50/50 dark:bg-gray-950/20',
|
||||
border: 'border-gray-200 dark:border-gray-800',
|
||||
iconBg: 'bg-gray-100 dark:bg-gray-900',
|
||||
iconText: 'text-gray-600 dark:text-gray-400',
|
||||
accent: 'border-l-gray-500'
|
||||
}
|
||||
};
|
||||
return colorMap[colorScheme as keyof typeof colorMap] || colorMap.gray;
|
||||
};
|
||||
|
||||
const colors = getColorClasses();
|
||||
|
||||
if (!isCollapsible) {
|
||||
return (
|
||||
<div className={`rounded-lg border-l-4 ${colors.accent} ${colors.bg} ${colors.border} shadow-sm`}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`p-2 rounded-lg ${colors.iconBg} ${colors.iconText}`}>
|
||||
{sectionIcon}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800 dark:text-white flex-1">
|
||||
{title}
|
||||
</h3>
|
||||
{headerContent}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border-l-4 ${colors.accent} ${colors.bg} ${colors.border} shadow-sm overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`
|
||||
cursor-pointer select-none p-6
|
||||
hover:bg-opacity-75 transition-colors duration-200
|
||||
${isAnimating ? 'pointer-events-none' : ''}
|
||||
`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${colors.iconBg} ${colors.iconText}`}>
|
||||
{sectionIcon}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800 dark:text-white flex-1">
|
||||
{title}
|
||||
</h3>
|
||||
{headerContent}
|
||||
<div className={`
|
||||
transform transition-transform duration-200
|
||||
${isOpen ? 'rotate-180' : 'rotate-0'}
|
||||
text-gray-500 dark:text-gray-400
|
||||
hover:text-gray-700 dark:hover:text-gray-200
|
||||
`}>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{renderPreview()}
|
||||
</div>
|
||||
|
||||
{/* Content with smooth height animation */}
|
||||
<div
|
||||
className="overflow-hidden transition-all ease-in-out"
|
||||
style={{
|
||||
maxHeight: isOpen ? contentHeight : 0,
|
||||
transitionDuration: `${animationDuration}ms`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`px-6 pb-6 space-y-4 ${
|
||||
isOpen ? 'opacity-100' : 'opacity-0'
|
||||
} transition-opacity duration-200`}
|
||||
style={{
|
||||
transitionDelay: isOpen ? '100ms' : '0ms'
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
import React, { useState, useEffect, ReactNode } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface CollapsibleSectionWrapperProps {
|
||||
children: ReactNode;
|
||||
header: ReactNode;
|
||||
isCollapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper component that makes any section collapsible by clicking on its header
|
||||
*/
|
||||
export const CollapsibleSectionWrapper: React.FC<CollapsibleSectionWrapperProps> = ({
|
||||
children,
|
||||
header,
|
||||
isCollapsible = true,
|
||||
defaultOpen = true,
|
||||
isOpen: controlledIsOpen,
|
||||
onToggle
|
||||
}) => {
|
||||
// Use controlled state if provided, otherwise manage internally
|
||||
const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
|
||||
const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (controlledIsOpen === undefined) {
|
||||
setInternalIsOpen(defaultOpen);
|
||||
}
|
||||
}, [defaultOpen, controlledIsOpen]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (controlledIsOpen === undefined) {
|
||||
setInternalIsOpen(!internalIsOpen);
|
||||
}
|
||||
onToggle?.();
|
||||
};
|
||||
|
||||
if (!isCollapsible) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="cursor-pointer select-none group"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="relative">
|
||||
{header}
|
||||
<div className={`
|
||||
absolute right-4 top-1/2 -translate-y-1/2
|
||||
transform transition-transform duration-200
|
||||
${isOpen ? 'rotate-180' : ''}
|
||||
text-gray-500 dark:text-gray-400
|
||||
group-hover:text-gray-700 dark:group-hover:text-gray-200
|
||||
`}>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`
|
||||
transition-all duration-300
|
||||
${isOpen ? 'max-h-none opacity-100' : 'max-h-0 opacity-0 overflow-hidden'}
|
||||
`}>
|
||||
<div className={isOpen ? 'pb-4' : ''}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,323 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ParsedMarkdownDocument, parseMarkdownToDocument, isDocumentWithMetadata, isMarkdownContent } from '../utils/markdownParser';
|
||||
import { MetadataSection } from '../sections/MetadataSection';
|
||||
import { MarkdownSectionRenderer } from './MarkdownSectionRenderer';
|
||||
|
||||
interface MarkdownDocumentRendererProps {
|
||||
content: any;
|
||||
isDarkMode?: boolean;
|
||||
sectionOverrides?: Record<string, React.ComponentType<any>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders markdown documents with metadata header and flowing content sections
|
||||
* Handles both pure markdown strings and documents with metadata + content structure
|
||||
*/
|
||||
/**
|
||||
* Processes JSON content and converts it to markdown format
|
||||
* Handles nested objects, arrays, and various data types
|
||||
*/
|
||||
function processContentToMarkdown(content: any): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (typeof content !== 'object' || content === null) {
|
||||
return String(content);
|
||||
}
|
||||
|
||||
const markdownSections: string[] = [];
|
||||
|
||||
// Extract metadata fields first (don't include in content conversion)
|
||||
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type', 'created_at', 'updated_at'];
|
||||
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
// Skip metadata fields as they're handled separately
|
||||
if (metadataFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip null or undefined values
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionTitle = formatSectionTitle(key);
|
||||
const sectionContent = formatSectionContent(value);
|
||||
|
||||
if (sectionContent.trim()) {
|
||||
markdownSections.push(`## ${sectionTitle}\n\n${sectionContent}`);
|
||||
}
|
||||
}
|
||||
|
||||
return markdownSections.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a section title from a JSON key
|
||||
*/
|
||||
function formatSectionTitle(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1') // Add space before capital letters
|
||||
.replace(/[_-]/g, ' ') // Replace underscores and hyphens with spaces
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats section content based on its type
|
||||
*/
|
||||
function formatSectionContent(value: any): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return formatArrayContent(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return formatObjectContent(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats array content as markdown list or nested structure
|
||||
*/
|
||||
function formatArrayContent(array: any[]): string {
|
||||
if (array.length === 0) {
|
||||
return '_No items_';
|
||||
}
|
||||
|
||||
// Check if all items are simple values (strings, numbers, booleans)
|
||||
const allSimple = array.every(item =>
|
||||
typeof item === 'string' ||
|
||||
typeof item === 'number' ||
|
||||
typeof item === 'boolean'
|
||||
);
|
||||
|
||||
if (allSimple) {
|
||||
return array.map(item => `- ${String(item)}`).join('\n');
|
||||
}
|
||||
|
||||
// Handle complex objects in array
|
||||
return array.map((item, index) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
const title = item.title || item.name || `Item ${index + 1}`;
|
||||
const content = formatObjectContent(item, true);
|
||||
return `### ${title}\n\n${content}`;
|
||||
}
|
||||
return `- ${String(item)}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats object content as key-value pairs or nested structure
|
||||
*/
|
||||
function formatObjectContent(obj: Record<string, any>, isNested: boolean = false): string {
|
||||
const entries = Object.entries(obj);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return '_Empty_';
|
||||
}
|
||||
|
||||
const formatted = entries.map(([key, value]) => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = formatSectionTitle(key);
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return `**${label}:** ${String(value)}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const arrayContent = formatArrayContent(value);
|
||||
return `**${label}:**\n${arrayContent}`;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const nestedContent = formatObjectContent(value, true);
|
||||
return `**${label}:**\n${nestedContent}`;
|
||||
}
|
||||
|
||||
return `**${label}:** ${String(value)}`;
|
||||
}).filter(Boolean);
|
||||
|
||||
return formatted.join('\n\n');
|
||||
}
|
||||
export const MarkdownDocumentRenderer: React.FC<MarkdownDocumentRendererProps> = ({
|
||||
content,
|
||||
isDarkMode = false,
|
||||
sectionOverrides = {}
|
||||
}) => {
|
||||
try {
|
||||
let parsedDocument: ParsedMarkdownDocument;
|
||||
let documentMetadata: any = {};
|
||||
|
||||
console.log('MarkdownDocumentRenderer: Processing content:', {
|
||||
type: typeof content,
|
||||
keys: typeof content === 'object' && content !== null ? Object.keys(content) : [],
|
||||
isDocWithMetadata: typeof content === 'object' && content !== null ? isDocumentWithMetadata(content) : false
|
||||
});
|
||||
|
||||
// Handle different content structures
|
||||
if (typeof content === 'string') {
|
||||
console.log('MarkdownDocumentRenderer: Processing pure markdown string');
|
||||
// Pure markdown string
|
||||
parsedDocument = parseMarkdownToDocument(content);
|
||||
// Create synthetic metadata for display
|
||||
documentMetadata = {
|
||||
title: parsedDocument.title || 'Document',
|
||||
document_type: 'markdown'
|
||||
};
|
||||
} else if (typeof content === 'object' && content !== null) {
|
||||
console.log('MarkdownDocumentRenderer: Processing object content');
|
||||
|
||||
// Extract all potential metadata fields first
|
||||
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type', 'created_at', 'updated_at'];
|
||||
metadataFields.forEach(field => {
|
||||
if (content[field]) {
|
||||
documentMetadata[field] = content[field];
|
||||
}
|
||||
});
|
||||
|
||||
// Find the markdown content in any field
|
||||
let markdownContent = '';
|
||||
|
||||
// First check common markdown field names
|
||||
if (typeof content.markdown === 'string') {
|
||||
markdownContent = content.markdown;
|
||||
console.log('MarkdownDocumentRenderer: Found markdown in "markdown" field');
|
||||
} else if (typeof content.content === 'string' && isMarkdownContent(content.content)) {
|
||||
markdownContent = content.content;
|
||||
console.log('MarkdownDocumentRenderer: Found markdown in "content" field');
|
||||
} else {
|
||||
// Look for markdown content in any field
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
if (typeof value === 'string' && isMarkdownContent(value)) {
|
||||
markdownContent = value;
|
||||
console.log(`MarkdownDocumentRenderer: Found markdown in field '${key}'`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no existing markdown found, try to convert JSON structure to markdown
|
||||
if (!markdownContent) {
|
||||
console.log('MarkdownDocumentRenderer: No markdown found, converting JSON to markdown');
|
||||
markdownContent = processContentToMarkdown(content);
|
||||
}
|
||||
|
||||
if (markdownContent) {
|
||||
console.log('MarkdownDocumentRenderer: Parsing markdown content:', {
|
||||
contentLength: markdownContent.length,
|
||||
contentPreview: markdownContent.substring(0, 100) + '...'
|
||||
});
|
||||
parsedDocument = parseMarkdownToDocument(markdownContent);
|
||||
console.log('MarkdownDocumentRenderer: Parsed document:', {
|
||||
sectionsCount: parsedDocument.sections.length,
|
||||
sections: parsedDocument.sections.map(s => ({ title: s.title, type: s.type }))
|
||||
});
|
||||
} else {
|
||||
// No markdown content found, create empty document
|
||||
console.log('MarkdownDocumentRenderer: No markdown content found in document');
|
||||
parsedDocument = { sections: [], metadata: {}, hasMetadata: false };
|
||||
}
|
||||
|
||||
// Use document title from metadata if available
|
||||
if (content.title && !parsedDocument.title) {
|
||||
parsedDocument.title = content.title;
|
||||
}
|
||||
} else {
|
||||
console.log('MarkdownDocumentRenderer: Unexpected content structure');
|
||||
// Fallback for unexpected content structure
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Unable to parse document content</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ALWAYS show metadata - force hasMetadata to true
|
||||
parsedDocument.hasMetadata = true;
|
||||
|
||||
// Combine parsed metadata with document metadata and add defaults
|
||||
const finalMetadata = {
|
||||
// Default values for better display
|
||||
document_type: 'prp',
|
||||
version: '1.0',
|
||||
status: 'draft',
|
||||
...parsedDocument.metadata,
|
||||
...documentMetadata,
|
||||
title: parsedDocument.title || documentMetadata.title || 'Untitled Document'
|
||||
};
|
||||
|
||||
console.log('MarkdownDocumentRenderer: Final render data:', {
|
||||
hasMetadata: parsedDocument.hasMetadata,
|
||||
finalMetadata,
|
||||
sectionsCount: parsedDocument.sections.length,
|
||||
sections: parsedDocument.sections.map(s => ({ title: s.title, type: s.type, templateType: s.templateType }))
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="markdown-document-renderer">
|
||||
{/* ALWAYS show metadata header */}
|
||||
<MetadataSection content={finalMetadata} isDarkMode={isDarkMode} />
|
||||
|
||||
{/* Document Sections */}
|
||||
<div className="space-y-2">
|
||||
{parsedDocument.sections.map((section, index) => (
|
||||
<MarkdownSectionRenderer
|
||||
key={`${section.sectionKey}-${index}`}
|
||||
section={section}
|
||||
index={index}
|
||||
isDarkMode={isDarkMode}
|
||||
sectionOverrides={sectionOverrides}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{parsedDocument.sections.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>No content sections found in this document.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('MarkdownDocumentRenderer: Error rendering content:', error);
|
||||
|
||||
// Provide a meaningful error display instead of black screen
|
||||
return (
|
||||
<div className="p-6 bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
|
||||
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">Error Rendering Document</h3>
|
||||
<p className="text-red-600 dark:text-red-300 text-sm mb-4">
|
||||
There was an error rendering this document. The content may be in an unexpected format.
|
||||
</p>
|
||||
|
||||
{/* Show raw content for debugging */}
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm text-red-600 dark:text-red-400 hover:underline">
|
||||
Show raw content
|
||||
</summary>
|
||||
<pre className="mt-2 p-4 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto max-h-96">
|
||||
{typeof content === 'string'
|
||||
? content
|
||||
: JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ParsedSection } from '../utils/markdownParser';
|
||||
import { SectionRenderer } from '../renderers/SectionRenderer';
|
||||
import { SimpleMarkdown } from './SimpleMarkdown';
|
||||
import { detectSectionType } from '../utils/sectionDetector';
|
||||
|
||||
interface MarkdownSectionRendererProps {
|
||||
section: ParsedSection;
|
||||
index: number;
|
||||
isDarkMode?: boolean;
|
||||
sectionOverrides?: Record<string, React.ComponentType<any>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders individual markdown sections with smart template detection
|
||||
* Uses specialized components for known PRP templates, beautiful styling for generic sections
|
||||
*/
|
||||
export const MarkdownSectionRenderer: React.FC<MarkdownSectionRendererProps> = ({
|
||||
section,
|
||||
index,
|
||||
isDarkMode = false,
|
||||
sectionOverrides = {}
|
||||
}) => {
|
||||
// If section matches a known PRP template, use the specialized component
|
||||
if (section.templateType) {
|
||||
const { type } = detectSectionType(section.sectionKey, section.rawContent);
|
||||
|
||||
// Use the existing SectionRenderer with the detected type
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<SectionRenderer
|
||||
sectionKey={section.sectionKey}
|
||||
data={section.rawContent}
|
||||
index={index}
|
||||
isDarkMode={isDarkMode}
|
||||
sectionOverrides={sectionOverrides}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For generic sections, render with beautiful floating styling
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<div className="relative">
|
||||
{/* Section Header */}
|
||||
<div className="mb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{section.title}
|
||||
</h2>
|
||||
<div className="mt-1 h-0.5 w-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
{/* Section Content */}
|
||||
<div className="relative">
|
||||
{/* Subtle background for sections with complex content */}
|
||||
{(section.type === 'code' || section.type === 'mixed') && (
|
||||
<div className="absolute inset-0 bg-gray-50/30 dark:bg-gray-900/20 rounded-xl -m-4 backdrop-blur-sm border border-gray-200/30 dark:border-gray-700/30"></div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10">
|
||||
<SimpleMarkdown
|
||||
content={section.content}
|
||||
className="prose prose-gray dark:prose-invert max-w-none prose-headings:text-gray-900 dark:prose-headings:text-white prose-p:text-gray-700 dark:prose-p:text-gray-300 prose-li:text-gray-700 dark:prose-li:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,340 +0,0 @@
|
||||
import React from 'react';
|
||||
import { formatValue } from '../utils/formatters';
|
||||
|
||||
interface SimpleMarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple markdown renderer that handles basic formatting without external dependencies
|
||||
*/
|
||||
export const SimpleMarkdown: React.FC<SimpleMarkdownProps> = ({ content, className = '' }) => {
|
||||
try {
|
||||
// Process image placeholders first
|
||||
const processedContent = formatValue(content);
|
||||
|
||||
// Split content into lines for processing
|
||||
const lines = processedContent.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let currentList: string[] = [];
|
||||
let listType: 'ul' | 'ol' | null = null;
|
||||
|
||||
const flushList = () => {
|
||||
if (currentList.length > 0 && listType) {
|
||||
const ListComponent = listType === 'ul' ? 'ul' : 'ol';
|
||||
elements.push(
|
||||
<div key={elements.length} className="my-3">
|
||||
<ListComponent className={`space-y-2 ${listType === 'ul' ? 'list-disc' : 'list-decimal'} pl-6 text-gray-700 dark:text-gray-300`}>
|
||||
{currentList.map((item, idx) => (
|
||||
<li key={idx} className="leading-relaxed">{processInlineMarkdown(item)}</li>
|
||||
))}
|
||||
</ListComponent>
|
||||
</div>
|
||||
);
|
||||
currentList = [];
|
||||
listType = null;
|
||||
}
|
||||
};
|
||||
|
||||
const processInlineMarkdown = (text: string): React.ReactNode => {
|
||||
const processed = text;
|
||||
const elements: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
// Process **bold** text
|
||||
const boldRegex = /\*\*(.*?)\*\*/g;
|
||||
let match;
|
||||
while ((match = boldRegex.exec(processed)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
elements.push(processed.slice(lastIndex, match.index));
|
||||
}
|
||||
elements.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Process *italic* text
|
||||
const italicRegex = /\*(.*?)\*/g;
|
||||
const remainingText = processed.slice(lastIndex);
|
||||
lastIndex = 0;
|
||||
const italicElements: React.ReactNode[] = [];
|
||||
|
||||
while ((match = italicRegex.exec(remainingText)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
italicElements.push(remainingText.slice(lastIndex, match.index));
|
||||
}
|
||||
italicElements.push(<em key={match.index} className="italic">{match[1]}</em>);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < remainingText.length) {
|
||||
italicElements.push(remainingText.slice(lastIndex));
|
||||
}
|
||||
|
||||
if (elements.length > 0) {
|
||||
elements.push(...italicElements);
|
||||
return <>{elements}</>;
|
||||
}
|
||||
|
||||
if (italicElements.length > 0) {
|
||||
return <>{italicElements}</>;
|
||||
}
|
||||
|
||||
// Process `inline code`
|
||||
const codeRegex = /`([^`]+)`/g;
|
||||
const parts = text.split(codeRegex);
|
||||
if (parts.length > 1) {
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
index % 2 === 0 ? (
|
||||
part
|
||||
) : (
|
||||
<code key={index} className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm font-mono text-gray-800 dark:text-gray-200">
|
||||
{part}
|
||||
</code>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{text}</span>;
|
||||
};
|
||||
|
||||
let inCodeBlock = false;
|
||||
let codeBlockContent: string[] = [];
|
||||
let codeBlockLanguage = '';
|
||||
let inTable = false;
|
||||
let tableRows: string[][] = [];
|
||||
let tableHeaders: string[] = [];
|
||||
|
||||
const flushTable = () => {
|
||||
if (tableRows.length > 0) {
|
||||
elements.push(
|
||||
<div key={elements.length} className="my-6 overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
|
||||
<table className="min-w-full">
|
||||
{tableHeaders.length > 0 && (
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
{tableHeaders.map((header, idx) => (
|
||||
<th key={idx} className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700">
|
||||
{processInlineMarkdown(header.trim())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{tableRows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
{row.map((cell, cellIdx) => (
|
||||
<td key={cellIdx} className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{processInlineMarkdown(cell.trim())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
tableRows = [];
|
||||
tableHeaders = [];
|
||||
inTable = false;
|
||||
}
|
||||
};
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// Handle code block start/end
|
||||
if (line.startsWith('```')) {
|
||||
if (!inCodeBlock) {
|
||||
// Starting code block
|
||||
flushList();
|
||||
inCodeBlock = true;
|
||||
codeBlockLanguage = line.substring(3).trim();
|
||||
codeBlockContent = [];
|
||||
} else {
|
||||
// Ending code block
|
||||
inCodeBlock = false;
|
||||
elements.push(
|
||||
<div key={index} className="my-4 rounded-lg overflow-hidden bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 border border-gray-700 shadow-lg">
|
||||
{codeBlockLanguage && (
|
||||
<div className="px-4 py-2 bg-gray-800/50 border-b border-gray-700 text-sm text-gray-300 font-mono">
|
||||
{codeBlockLanguage}
|
||||
</div>
|
||||
)}
|
||||
<pre className="p-4 overflow-x-auto">
|
||||
<code className="text-gray-100 font-mono text-sm leading-relaxed">
|
||||
{codeBlockContent.join('\n')}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
codeBlockContent = [];
|
||||
codeBlockLanguage = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If inside code block, collect content
|
||||
if (inCodeBlock) {
|
||||
codeBlockContent.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle table rows
|
||||
if (line.includes('|') && line.trim() !== '') {
|
||||
const cells = line.split('|').map(cell => cell.trim()).filter(cell => cell !== '');
|
||||
|
||||
if (cells.length > 0) {
|
||||
if (!inTable) {
|
||||
// Starting a new table
|
||||
flushList();
|
||||
inTable = true;
|
||||
tableHeaders = cells;
|
||||
} else if (cells.every(cell => cell.match(/^:?-+:?$/))) {
|
||||
// This is a header separator line (|---|---|), skip it
|
||||
return;
|
||||
} else {
|
||||
// This is a regular table row
|
||||
tableRows.push(cells);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if (inTable) {
|
||||
// End of table (empty line or non-table content)
|
||||
flushTable();
|
||||
}
|
||||
|
||||
// Handle headings
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
flushList();
|
||||
const level = headingMatch[1].length;
|
||||
const text = headingMatch[2];
|
||||
const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements;
|
||||
const sizeClasses = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs'];
|
||||
const colorClasses = ['text-gray-900 dark:text-white', 'text-gray-800 dark:text-gray-100', 'text-gray-700 dark:text-gray-200', 'text-gray-700 dark:text-gray-200', 'text-gray-600 dark:text-gray-300', 'text-gray-600 dark:text-gray-300'];
|
||||
|
||||
elements.push(
|
||||
<HeadingTag key={index} className={`font-bold mb-3 mt-6 ${sizeClasses[level - 1] || 'text-base'} ${colorClasses[level - 1] || 'text-gray-700 dark:text-gray-200'} border-b border-gray-200 dark:border-gray-700 pb-1`}>
|
||||
{processInlineMarkdown(text)}
|
||||
</HeadingTag>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle checkboxes (task lists)
|
||||
const checkboxMatch = line.match(/^[-*+]\s+\[([ x])\]\s+(.+)$/);
|
||||
if (checkboxMatch) {
|
||||
flushList();
|
||||
const isChecked = checkboxMatch[1] === 'x';
|
||||
const content = checkboxMatch[2];
|
||||
elements.push(
|
||||
<div key={index} className="flex items-start gap-3 my-2">
|
||||
<div className={`flex-shrink-0 w-5 h-5 rounded-md border-2 flex items-center justify-center mt-0.5 transition-colors ${
|
||||
isChecked
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
|
||||
}`}>
|
||||
{isChecked && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex-1 leading-relaxed ${isChecked ? 'text-gray-500 dark:text-gray-400 line-through' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
{processInlineMarkdown(content)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle bullet lists
|
||||
const bulletMatch = line.match(/^[-*+]\s+(.+)$/);
|
||||
if (bulletMatch) {
|
||||
if (listType !== 'ul') {
|
||||
flushList();
|
||||
listType = 'ul';
|
||||
}
|
||||
currentList.push(bulletMatch[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle numbered lists
|
||||
const numberMatch = line.match(/^\d+\.\s+(.+)$/);
|
||||
if (numberMatch) {
|
||||
if (listType !== 'ol') {
|
||||
flushList();
|
||||
listType = 'ol';
|
||||
}
|
||||
currentList.push(numberMatch[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle code blocks
|
||||
if (line.startsWith('```')) {
|
||||
flushList();
|
||||
// Simple code block handling - just skip the backticks
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle blockquotes
|
||||
if (line.startsWith('>')) {
|
||||
flushList();
|
||||
const content = line.substring(1).trim();
|
||||
elements.push(
|
||||
<blockquote key={index} className="border-l-4 border-blue-400 dark:border-blue-500 bg-blue-50/50 dark:bg-blue-900/20 pl-4 pr-4 py-3 italic my-4 rounded-r-lg backdrop-blur-sm">
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
{processInlineMarkdown(content)}
|
||||
</div>
|
||||
</blockquote>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle horizontal rules
|
||||
if (line.match(/^(-{3,}|_{3,}|\*{3,})$/)) {
|
||||
flushList();
|
||||
elements.push(<hr key={index} className="my-4 border-gray-300 dark:border-gray-700" />);
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
if (line.trim()) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<p key={index} className="mb-3 leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
{processInlineMarkdown(line)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Flush any remaining list or table
|
||||
flushList();
|
||||
flushTable();
|
||||
|
||||
return (
|
||||
<div className={`max-w-none ${className}`}>
|
||||
<div className="space-y-1">
|
||||
{elements}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error rendering markdown:', error, content);
|
||||
return (
|
||||
<div className={`text-gray-700 dark:text-gray-300 ${className}`}>
|
||||
<p>Error rendering markdown content</p>
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded mt-2 whitespace-pre-wrap">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
// Main component exports
|
||||
export { PRPViewer } from './PRPViewer';
|
||||
|
||||
// Section component exports
|
||||
export { MetadataSection } from './sections/MetadataSection';
|
||||
export { ContextSection } from './sections/ContextSection';
|
||||
export { PersonaSection } from './sections/PersonaSection';
|
||||
export { FlowSection } from './sections/FlowSection';
|
||||
export { MetricsSection } from './sections/MetricsSection';
|
||||
export { PlanSection } from './sections/PlanSection';
|
||||
export { ListSection } from './sections/ListSection';
|
||||
export { ObjectSection } from './sections/ObjectSection';
|
||||
export { KeyValueSection } from './sections/KeyValueSection';
|
||||
export { FeatureSection } from './sections/FeatureSection';
|
||||
export { GenericSection } from './sections/GenericSection';
|
||||
|
||||
// Renderer exports
|
||||
export { SectionRenderer } from './renderers/SectionRenderer';
|
||||
|
||||
// Type exports
|
||||
export * from './types/prp.types';
|
||||
|
||||
// Utility exports
|
||||
export { detectSectionType, formatSectionTitle, getSectionIcon } from './utils/sectionDetector';
|
||||
export { formatKey, formatValue, truncateText, getAccentColor } from './utils/formatters';
|
||||
@@ -1,141 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Brain, Users, Workflow, BarChart3, Clock, Shield,
|
||||
Code, Layers, FileText, List, Hash, Box
|
||||
} from 'lucide-react';
|
||||
import { detectSectionType, formatSectionTitle } from '../utils/sectionDetector';
|
||||
import { getAccentColor } from '../utils/formatters';
|
||||
|
||||
// Import all section components
|
||||
import { ContextSection } from '../sections/ContextSection';
|
||||
import { PersonaSection } from '../sections/PersonaSection';
|
||||
import { FlowSection } from '../sections/FlowSection';
|
||||
import { MetricsSection } from '../sections/MetricsSection';
|
||||
import { PlanSection } from '../sections/PlanSection';
|
||||
import { ListSection } from '../sections/ListSection';
|
||||
import { ObjectSection } from '../sections/ObjectSection';
|
||||
import { KeyValueSection } from '../sections/KeyValueSection';
|
||||
import { FeatureSection } from '../sections/FeatureSection';
|
||||
import { GenericSection } from '../sections/GenericSection';
|
||||
import { RolloutPlanSection } from '../sections/RolloutPlanSection';
|
||||
import { TokenSystemSection } from '../sections/TokenSystemSection';
|
||||
|
||||
interface SectionRendererProps {
|
||||
sectionKey: string;
|
||||
data: any;
|
||||
index: number;
|
||||
isDarkMode?: boolean;
|
||||
sectionOverrides?: Record<string, React.ComponentType<any>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically renders sections based on their type
|
||||
*/
|
||||
export const SectionRenderer: React.FC<SectionRendererProps> = ({
|
||||
sectionKey,
|
||||
data,
|
||||
index,
|
||||
isDarkMode = false,
|
||||
sectionOverrides = {},
|
||||
}) => {
|
||||
// Skip metadata fields (handled by MetadataSection)
|
||||
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type'];
|
||||
if (metadataFields.includes(sectionKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for custom override first
|
||||
if (sectionOverrides[sectionKey]) {
|
||||
const CustomComponent = sectionOverrides[sectionKey];
|
||||
return <CustomComponent data={data} title={formatSectionTitle(sectionKey)} />;
|
||||
}
|
||||
|
||||
// Detect section type
|
||||
const { type } = detectSectionType(sectionKey, data);
|
||||
|
||||
// Get appropriate icon based on section key
|
||||
const getIcon = () => {
|
||||
const normalizedKey = sectionKey.toLowerCase();
|
||||
if (normalizedKey.includes('context') || normalizedKey.includes('overview')) return <Brain className="w-5 h-5" />;
|
||||
if (normalizedKey.includes('persona') || normalizedKey.includes('user')) return <Users className="w-5 h-5" />;
|
||||
if (normalizedKey.includes('flow') || normalizedKey.includes('journey')) return <Workflow className="w-5 h-5" />;
|
||||
if (normalizedKey.includes('metric') || normalizedKey.includes('success')) return <BarChart3 className="w-5 h-5" />;
|
||||
if (normalizedKey.includes('plan') || normalizedKey.includes('implementation')) return <Clock className="w-5 h-5" />;
|
||||
if (normalizedKey.includes('validation') || normalizedKey.includes('gate')) return <Shield className="w-5 h-5" />;
|
||||
if (normalizedKey.includes('technical') || normalizedKey.includes('tech')) return <Code className="w-5 h-5" />;
|
||||
if (normalizedKey.includes('architecture')) return <Layers className="w-5 h-5" />;
|
||||
if (Array.isArray(data)) return <List className="w-5 h-5" />;
|
||||
if (typeof data === 'object') return <Box className="w-5 h-5" />;
|
||||
return <FileText className="w-5 h-5" />;
|
||||
};
|
||||
|
||||
// Get accent color based on section or index
|
||||
const getColor = () => {
|
||||
const normalizedKey = sectionKey.toLowerCase();
|
||||
if (normalizedKey.includes('context')) return 'blue';
|
||||
if (normalizedKey.includes('persona')) return 'purple';
|
||||
if (normalizedKey.includes('flow') || normalizedKey.includes('journey')) return 'orange';
|
||||
if (normalizedKey.includes('metric')) return 'green';
|
||||
if (normalizedKey.includes('plan')) return 'cyan';
|
||||
if (normalizedKey.includes('validation')) return 'emerald';
|
||||
return getAccentColor(index);
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
title: formatSectionTitle(sectionKey),
|
||||
data,
|
||||
icon: getIcon(),
|
||||
accentColor: getColor(),
|
||||
isDarkMode,
|
||||
defaultOpen: index < 5, // Open first 5 sections by default
|
||||
isCollapsible: true, // Make all sections collapsible by default
|
||||
};
|
||||
|
||||
// Check for specific section types by key name first
|
||||
const normalizedKey = sectionKey.toLowerCase();
|
||||
|
||||
// Special handling for rollout plans
|
||||
if (normalizedKey.includes('rollout') || normalizedKey === 'rollout_plan') {
|
||||
return <RolloutPlanSection {...commonProps} />;
|
||||
}
|
||||
|
||||
// Special handling for token systems
|
||||
if (normalizedKey.includes('token') || normalizedKey === 'token_system' ||
|
||||
normalizedKey === 'design_tokens' || normalizedKey === 'design_system') {
|
||||
return <TokenSystemSection {...commonProps} />;
|
||||
}
|
||||
|
||||
// Render based on detected type
|
||||
switch (type) {
|
||||
case 'context':
|
||||
return <ContextSection {...commonProps} />;
|
||||
|
||||
case 'personas':
|
||||
return <PersonaSection {...commonProps} />;
|
||||
|
||||
case 'flows':
|
||||
return <FlowSection {...commonProps} />;
|
||||
|
||||
case 'metrics':
|
||||
return <MetricsSection {...commonProps} />;
|
||||
|
||||
case 'plan':
|
||||
return <PlanSection {...commonProps} />;
|
||||
|
||||
case 'list':
|
||||
return <ListSection {...commonProps} />;
|
||||
|
||||
case 'keyvalue':
|
||||
return <KeyValueSection {...commonProps} />;
|
||||
|
||||
case 'object':
|
||||
return <ObjectSection {...commonProps} />;
|
||||
|
||||
case 'features':
|
||||
return <FeatureSection {...commonProps} />;
|
||||
|
||||
case 'generic':
|
||||
default:
|
||||
return <GenericSection {...commonProps} />;
|
||||
}
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Target, BookOpen, Sparkles, CheckCircle2 } from 'lucide-react';
|
||||
import { SectionProps } from '../types/prp.types';
|
||||
// Temporarily disabled to debug black screen issue
|
||||
// import { renderValue, renderValueInline } from '../utils/objectRenderer';
|
||||
|
||||
/**
|
||||
* Renders context sections like scope, background, objectives
|
||||
*/
|
||||
export const ContextSection: React.FC<SectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon,
|
||||
accentColor = 'blue',
|
||||
defaultOpen = true,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const renderContextItem = (key: string, value: any) => {
|
||||
const getItemIcon = (itemKey: string) => {
|
||||
const normalizedKey = itemKey.toLowerCase();
|
||||
if (normalizedKey.includes('scope')) return <Target className="w-4 h-4 text-blue-500" />;
|
||||
if (normalizedKey.includes('background')) return <BookOpen className="w-4 h-4 text-purple-500" />;
|
||||
if (normalizedKey.includes('objective')) return <Sparkles className="w-4 h-4 text-green-500" />;
|
||||
if (normalizedKey.includes('requirement')) return <CheckCircle2 className="w-4 h-4 text-orange-500" />;
|
||||
return <CheckCircle2 className="w-4 h-4 text-gray-500" />;
|
||||
};
|
||||
|
||||
const getItemColor = (itemKey: string) => {
|
||||
const normalizedKey = itemKey.toLowerCase();
|
||||
if (normalizedKey.includes('scope')) return 'blue';
|
||||
if (normalizedKey.includes('background')) return 'purple';
|
||||
if (normalizedKey.includes('objective')) return 'green';
|
||||
if (normalizedKey.includes('requirement')) return 'orange';
|
||||
return 'gray';
|
||||
};
|
||||
|
||||
const color = getItemColor(key);
|
||||
const colorMap = {
|
||||
blue: 'bg-blue-50/50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
|
||||
purple: 'bg-purple-50/50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',
|
||||
green: 'bg-green-50/50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
orange: 'bg-orange-50/50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800',
|
||||
gray: 'bg-gray-50/50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800',
|
||||
};
|
||||
|
||||
const itemTitle = key.replace(/_/g, ' ').charAt(0).toUpperCase() + key.replace(/_/g, ' ').slice(1);
|
||||
|
||||
return (
|
||||
<div key={key} className={`p-4 rounded-lg border ${colorMap[color as keyof typeof colorMap]}`}>
|
||||
<h4 className="font-semibold text-gray-800 dark:text-white mb-2 flex items-center gap-2">
|
||||
{getItemIcon(key)}
|
||||
{itemTitle}
|
||||
</h4>
|
||||
|
||||
{Array.isArray(value) ? (
|
||||
<ul className="space-y-2">
|
||||
{value.map((item: any, idx: number) => (
|
||||
<li key={idx} className="flex items-start gap-2 text-gray-700 dark:text-gray-300">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
{typeof item === 'string' ? item : JSON.stringify(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : typeof value === 'string' ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">{value}</p>
|
||||
) : (
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(data).map(([key, value]) => renderContextItem(key, value))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Package, Star, FileText } from 'lucide-react';
|
||||
import { PRPSectionProps } from '../types/prp.types';
|
||||
import { formatKey, formatValue } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* Specialized component for feature requirements and capabilities
|
||||
* Renders features in organized categories with proper hierarchy
|
||||
*/
|
||||
export const FeatureSection: React.FC<PRPSectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon = <Package className="w-5 h-5" />,
|
||||
accentColor = 'blue',
|
||||
isDarkMode = false,
|
||||
defaultOpen = true
|
||||
}) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const colorMap = {
|
||||
blue: 'from-blue-400 to-blue-600',
|
||||
purple: 'from-purple-400 to-purple-600',
|
||||
green: 'from-green-400 to-green-600',
|
||||
orange: 'from-orange-400 to-orange-600',
|
||||
pink: 'from-pink-400 to-pink-600',
|
||||
cyan: 'from-cyan-400 to-cyan-600',
|
||||
indigo: 'from-indigo-400 to-indigo-600',
|
||||
emerald: 'from-emerald-400 to-emerald-600',
|
||||
};
|
||||
|
||||
const bgColorMap = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-950',
|
||||
purple: 'bg-purple-50 dark:bg-purple-950',
|
||||
green: 'bg-green-50 dark:bg-green-950',
|
||||
orange: 'bg-orange-50 dark:bg-orange-950',
|
||||
pink: 'bg-pink-50 dark:bg-pink-950',
|
||||
cyan: 'bg-cyan-50 dark:bg-cyan-950',
|
||||
indigo: 'bg-indigo-50 dark:bg-indigo-950',
|
||||
emerald: 'bg-emerald-50 dark:bg-emerald-950',
|
||||
};
|
||||
|
||||
const renderFeatureGroup = (groupName: string, features: any, isPremium: boolean = false) => {
|
||||
if (!features || typeof features !== 'object') return null;
|
||||
|
||||
const IconComponent = isPremium ? Star : FileText;
|
||||
const iconColor = isPremium ? 'text-yellow-500' : 'text-blue-500';
|
||||
|
||||
return (
|
||||
<div key={groupName} className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<IconComponent className={`w-5 h-5 ${iconColor}`} />
|
||||
<h4 className="font-semibold text-gray-800 dark:text-white text-lg">
|
||||
{formatKey(groupName)}
|
||||
</h4>
|
||||
{isPremium && (
|
||||
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 text-xs rounded-full font-medium">
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 ml-8">
|
||||
{Object.entries(features).map(([featureName, featureData]) => (
|
||||
<div key={featureName} className="border-l-2 border-gray-200 dark:border-gray-700 pl-4">
|
||||
<h5 className="font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{formatKey(featureName)}
|
||||
</h5>
|
||||
|
||||
{Array.isArray(featureData) ? (
|
||||
<ul className="space-y-1">
|
||||
{featureData.map((item, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-gray-600 dark:text-gray-400">
|
||||
<span className="text-gray-400 mt-1">•</span>
|
||||
<span>{formatValue(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : typeof featureData === 'string' ? (
|
||||
<p className="text-gray-600 dark:text-gray-400">{featureData}</p>
|
||||
) : (
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
{formatValue(featureData)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFeatureList = (features: any) => {
|
||||
if (Array.isArray(features)) {
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Package className="w-4 h-4 text-blue-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">{formatValue(feature)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof features === 'object' && features !== null) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(features).map(([key, value]) => {
|
||||
const isPremium = key.toLowerCase().includes('premium') ||
|
||||
key.toLowerCase().includes('advanced') ||
|
||||
key.toLowerCase().includes('pro');
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return renderFeatureGroup(key, value, isPremium);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Package className="w-5 h-5 text-blue-500 mt-1 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-800 dark:text-white mb-1">
|
||||
{formatKey(key)}
|
||||
</h4>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
{formatValue(value)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="text-gray-600 dark:text-gray-400">{formatValue(features)}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-lg p-6 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.blue} border-l-4 border-blue-500`}>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap] || colorMap.blue} text-white shadow-lg`}>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{renderFeatureList(data)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Workflow, Navigation } from 'lucide-react';
|
||||
import { SectionProps } from '../types/prp.types';
|
||||
import { formatKey } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* Renders user flows and journey diagrams
|
||||
*/
|
||||
export const FlowSection: React.FC<SectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon,
|
||||
accentColor = 'orange',
|
||||
defaultOpen = true,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const renderFlowNode = (obj: any, depth: number = 0): React.ReactNode => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return <span className="text-gray-600 dark:text-gray-400">{String(obj)}</span>;
|
||||
}
|
||||
|
||||
return Object.entries(obj).map(([key, value]) => {
|
||||
const nodeKey = `${key}-${depth}-${Math.random()}`;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return (
|
||||
<div key={nodeKey} className="flex items-center gap-2 p-2" style={{ marginLeft: depth * 24 }}>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{formatKey(key)}:
|
||||
</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{value}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
return (
|
||||
<div key={nodeKey} className="mb-3">
|
||||
<div className="flex items-center gap-2 p-2 font-medium text-gray-800 dark:text-white" style={{ marginLeft: depth * 24 }}>
|
||||
<Navigation className="w-4 h-4 text-purple-500" />
|
||||
{formatKey(key)}
|
||||
</div>
|
||||
<div className="border-l-2 border-purple-200 dark:border-purple-800 ml-6">
|
||||
{renderFlowNode(value, depth + 1)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{Object.entries(data).map(([flowName, flow]) => (
|
||||
<div
|
||||
key={flowName}
|
||||
className="p-4 rounded-lg bg-gradient-to-br from-purple-50/50 to-pink-50/50 dark:from-purple-900/20 dark:to-pink-900/20 border border-purple-200 dark:border-purple-800"
|
||||
>
|
||||
<h4 className="font-semibold text-gray-800 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Workflow className="w-5 h-5 text-purple-500" />
|
||||
{formatKey(flowName)}
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
{renderFlowNode(flow)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,233 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FileText, Hash, List, Box, Type, ToggleLeft } from 'lucide-react';
|
||||
import { SectionProps } from '../types/prp.types';
|
||||
import { formatKey, formatValue } from '../utils/formatters';
|
||||
import { hasComplexNesting } from '../utils/normalizer';
|
||||
import { CollapsibleSectionWrapper } from '../components/CollapsibleSectionWrapper';
|
||||
import { SimpleMarkdown } from '../components/SimpleMarkdown';
|
||||
|
||||
/**
|
||||
* Generic fallback section component that intelligently renders any data structure
|
||||
* This component provides comprehensive rendering for any data type with proper formatting
|
||||
*/
|
||||
export const GenericSection: React.FC<SectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon = <FileText className="w-5 h-5" />,
|
||||
accentColor = 'gray',
|
||||
defaultOpen = true,
|
||||
isDarkMode = false,
|
||||
isCollapsible = true,
|
||||
isOpen,
|
||||
onToggle
|
||||
}) => {
|
||||
// Auto-detect appropriate icon based on data type
|
||||
const getAutoIcon = () => {
|
||||
if (typeof data === 'string') return <Type className="w-5 h-5" />;
|
||||
if (typeof data === 'number') return <Hash className="w-5 h-5" />;
|
||||
if (typeof data === 'boolean') return <ToggleLeft className="w-5 h-5" />;
|
||||
if (Array.isArray(data)) return <List className="w-5 h-5" />;
|
||||
if (typeof data === 'object' && data !== null) return <Box className="w-5 h-5" />;
|
||||
return icon;
|
||||
};
|
||||
const renderValue = (value: any, depth: number = 0): React.ReactNode => {
|
||||
const indent = depth * 16;
|
||||
const maxDepth = 5; // Prevent infinite recursion
|
||||
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
return <span className="text-gray-400 italic">Empty</span>;
|
||||
}
|
||||
|
||||
// Handle primitives
|
||||
if (typeof value === 'string') {
|
||||
// Check if the string looks like markdown content
|
||||
const hasMarkdownIndicators = /^#{1,6}\s+.+$|^[-*+]\s+.+$|^\d+\.\s+.+$|```|^\>.+$|\*\*.+\*\*|\*.+\*|`[^`]+`/m.test(value);
|
||||
|
||||
if (hasMarkdownIndicators && value.length > 20) {
|
||||
// Render as markdown for content with markdown syntax
|
||||
// Remove any leading headers since the section already has a title
|
||||
const contentWithoutLeadingHeaders = value.replace(/^#{1,6}\s+.+$/m, '').trim();
|
||||
const finalContent = contentWithoutLeadingHeaders || value;
|
||||
|
||||
return <SimpleMarkdown content={finalContent} className="text-gray-700 dark:text-gray-300" />;
|
||||
}
|
||||
|
||||
// For shorter strings or non-markdown, use simple formatting
|
||||
return <span className="text-gray-700 dark:text-gray-300">{formatValue(value)}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return <span className="text-gray-700 dark:text-gray-300 font-mono">{formatValue(value)}</span>;
|
||||
}
|
||||
|
||||
// Prevent deep recursion
|
||||
if (depth >= maxDepth) {
|
||||
return (
|
||||
<span className="text-gray-500 italic text-sm">
|
||||
[Complex nested structure - too deep to display]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-gray-400 italic">No items</span>;
|
||||
}
|
||||
|
||||
// Check if it's an array of primitives
|
||||
const isSimpleArray = value.every(item =>
|
||||
typeof item === 'string' ||
|
||||
typeof item === 'number' ||
|
||||
typeof item === 'boolean' ||
|
||||
item === null ||
|
||||
item === undefined
|
||||
);
|
||||
|
||||
if (isSimpleArray) {
|
||||
// For very long arrays, show first 10 and count
|
||||
const displayItems = value.length > 10 ? value.slice(0, 10) : value;
|
||||
const hasMore = value.length > 10;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="space-y-1 mt-2">
|
||||
{displayItems.map((item, index) => (
|
||||
<li key={index} className="flex items-start gap-2" style={{ marginLeft: indent }}>
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{formatValue(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{hasMore && (
|
||||
<p className="text-sm text-gray-500 italic mt-2" style={{ marginLeft: indent + 16 }}>
|
||||
... and {value.length - 10} more items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Array of objects
|
||||
const displayItems = value.length > 5 ? value.slice(0, 5) : value;
|
||||
const hasMore = value.length > 5;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 mt-2">
|
||||
{displayItems.map((item, index) => (
|
||||
<div key={index} className="relative" style={{ marginLeft: indent }}>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-gray-300 to-transparent dark:from-gray-600"></div>
|
||||
<div className="pl-4">
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
[{index}]
|
||||
</div>
|
||||
{renderValue(item, depth + 1)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<p className="text-sm text-gray-500 italic" style={{ marginLeft: indent + 16 }}>
|
||||
... and {value.length - 5} more items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// Simplified object rendering to debug black screen
|
||||
return (
|
||||
<div className="mt-2 text-gray-700 dark:text-gray-300">
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded whitespace-pre-wrap">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for any other type (functions, symbols, etc.)
|
||||
return (
|
||||
<span className="text-gray-500 italic text-sm">
|
||||
[{typeof value}]
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
const colorMap = {
|
||||
blue: 'bg-blue-50/50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
|
||||
purple: 'bg-purple-50/50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',
|
||||
green: 'bg-green-50/50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
orange: 'bg-orange-50/50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800',
|
||||
pink: 'bg-pink-50/50 dark:bg-pink-900/20 border-pink-200 dark:border-pink-800',
|
||||
cyan: 'bg-cyan-50/50 dark:bg-cyan-900/20 border-cyan-200 dark:border-cyan-800',
|
||||
gray: 'bg-gray-50/50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800',
|
||||
};
|
||||
return colorMap[accentColor as keyof typeof colorMap] || colorMap.gray;
|
||||
};
|
||||
|
||||
const finalIcon = icon === <FileText className="w-5 h-5" /> ? getAutoIcon() : icon;
|
||||
|
||||
// Enhanced styling based on data complexity
|
||||
const isComplexData = hasComplexNesting(data);
|
||||
const headerClass = isComplexData
|
||||
? `p-6 rounded-lg border-2 shadow-sm ${getBackgroundColor()}`
|
||||
: `p-4 rounded-lg border ${getBackgroundColor()}`;
|
||||
|
||||
const header = (
|
||||
<div className={headerClass}>
|
||||
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<div className="p-1.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
|
||||
{finalIcon}
|
||||
</div>
|
||||
<span className="flex-1">{title}</span>
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
const contentClass = isComplexData
|
||||
? `px-6 pb-6 -mt-1 rounded-b-lg border-2 border-t-0 shadow-sm ${getBackgroundColor()}`
|
||||
: `px-4 pb-4 -mt-1 rounded-b-lg border border-t-0 ${getBackgroundColor()}`;
|
||||
|
||||
const content = (
|
||||
<div className={contentClass}>
|
||||
<div className="overflow-x-auto">
|
||||
{/* Add a subtle background for complex data */}
|
||||
{isComplexData ? (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded p-3 -mx-2">
|
||||
{renderValue(data)}
|
||||
</div>
|
||||
) : (
|
||||
renderValue(data)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
try {
|
||||
return (
|
||||
<CollapsibleSectionWrapper
|
||||
header={header}
|
||||
isCollapsible={isCollapsible}
|
||||
defaultOpen={defaultOpen}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{content}
|
||||
</CollapsibleSectionWrapper>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error rendering GenericSection:', error, { title, data });
|
||||
return (
|
||||
<div className="p-4 border border-red-300 rounded bg-red-50 dark:bg-red-900">
|
||||
<h3 className="text-red-800 dark:text-red-200 font-semibold">{title}</h3>
|
||||
<p className="text-red-600 dark:text-red-300 text-sm mt-2">Error rendering section content</p>
|
||||
<pre className="text-xs mt-2 bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Hash } from 'lucide-react';
|
||||
import { PRPSectionProps } from '../types/prp.types';
|
||||
import { formatKey, formatValue } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* Component for rendering simple key-value pairs
|
||||
* Used for sections like budget, resources, team, etc.
|
||||
*/
|
||||
export const KeyValueSection: React.FC<PRPSectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon = <Hash className="w-5 h-5" />,
|
||||
accentColor = 'green',
|
||||
isDarkMode = false,
|
||||
defaultOpen = true
|
||||
}) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const colorMap = {
|
||||
blue: 'from-blue-400 to-blue-600',
|
||||
purple: 'from-purple-400 to-purple-600',
|
||||
green: 'from-green-400 to-green-600',
|
||||
orange: 'from-orange-400 to-orange-600',
|
||||
pink: 'from-pink-400 to-pink-600',
|
||||
cyan: 'from-cyan-400 to-cyan-600',
|
||||
indigo: 'from-indigo-400 to-indigo-600',
|
||||
emerald: 'from-emerald-400 to-emerald-600',
|
||||
};
|
||||
|
||||
const borderColorMap = {
|
||||
blue: 'border-blue-200 dark:border-blue-800',
|
||||
purple: 'border-purple-200 dark:border-purple-800',
|
||||
green: 'border-green-200 dark:border-green-800',
|
||||
orange: 'border-orange-200 dark:border-orange-800',
|
||||
pink: 'border-pink-200 dark:border-pink-800',
|
||||
cyan: 'border-cyan-200 dark:border-cyan-800',
|
||||
indigo: 'border-indigo-200 dark:border-indigo-800',
|
||||
emerald: 'border-emerald-200 dark:border-emerald-800',
|
||||
};
|
||||
|
||||
const renderValue = (value: any): React.ReactNode => {
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-1 mt-1">
|
||||
{value.map((item, index) => (
|
||||
<li key={index} className="text-gray-600 dark:text-gray-400">
|
||||
{formatValue(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return (
|
||||
<div className="mt-2 space-y-2 bg-gray-50 dark:bg-gray-700 p-3 rounded">
|
||||
{Object.entries(value).map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||
{formatKey(k)}
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300 font-semibold">
|
||||
{formatValue(v)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-semibold">
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap] || colorMap.green} text-white shadow-lg`}>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`pb-4 border-b ${borderColorMap[accentColor as keyof typeof borderColorMap] || borderColorMap.green} last:border-0 last:pb-0`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h4 className="font-semibold text-gray-700 dark:text-gray-300 min-w-[120px]">
|
||||
{formatKey(key)}
|
||||
</h4>
|
||||
<div className="flex-1 text-right">
|
||||
{renderValue(value)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, Circle } from 'lucide-react';
|
||||
import { SectionProps } from '../types/prp.types';
|
||||
|
||||
/**
|
||||
* Renders simple list/array data
|
||||
*/
|
||||
export const ListSection: React.FC<SectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon,
|
||||
accentColor = 'green',
|
||||
defaultOpen = true,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
if (!Array.isArray(data)) return null;
|
||||
|
||||
const getItemIcon = (item: any, index: number) => {
|
||||
// Use checkmarks for validation/success items
|
||||
if (title.toLowerCase().includes('validation') ||
|
||||
title.toLowerCase().includes('success') ||
|
||||
title.toLowerCase().includes('complete')) {
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />;
|
||||
}
|
||||
// Use circles for general items
|
||||
return <Circle className="w-3 h-3 text-gray-400 mt-1 flex-shrink-0" />;
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
const colorMap = {
|
||||
green: 'bg-gradient-to-br from-green-50/50 to-emerald-50/50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-200 dark:border-green-800',
|
||||
blue: 'bg-gradient-to-br from-blue-50/50 to-cyan-50/50 dark:from-blue-900/20 dark:to-cyan-900/20 border-blue-200 dark:border-blue-800',
|
||||
purple: 'bg-gradient-to-br from-purple-50/50 to-pink-50/50 dark:from-purple-900/20 dark:to-pink-900/20 border-purple-200 dark:border-purple-800',
|
||||
orange: 'bg-gradient-to-br from-orange-50/50 to-yellow-50/50 dark:from-orange-900/20 dark:to-yellow-900/20 border-orange-200 dark:border-orange-800',
|
||||
gray: 'bg-gradient-to-br from-gray-50/50 to-slate-50/50 dark:from-gray-900/20 dark:to-slate-900/20 border-gray-200 dark:border-gray-800',
|
||||
};
|
||||
return colorMap[accentColor as keyof typeof colorMap] || colorMap.gray;
|
||||
};
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={`p-4 rounded-lg border ${getBackgroundColor()}`}>
|
||||
<p className="text-gray-500 dark:text-gray-500 italic">No items</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-lg border ${getBackgroundColor()}`}>
|
||||
<ul className="space-y-2">
|
||||
{data.map((item: any, idx: number) => (
|
||||
<li key={idx} className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{getItemIcon(item, idx)}
|
||||
<div className="flex-1">
|
||||
{typeof item === 'string' ? (
|
||||
<span className="text-gray-700 dark:text-gray-300">{item}</span>
|
||||
) : typeof item === 'object' && item !== null ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(item).map(([key, value]) => (
|
||||
<div key={key} className="flex items-start gap-2">
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400 min-w-[80px] capitalize">
|
||||
{key.replace(/_/g, ' ')}:
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300 flex-1">
|
||||
{typeof value === 'string' ? value : JSON.stringify(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-700 dark:text-gray-300">{String(item)}</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Award, Users, Clock, Tag, FileText } from 'lucide-react';
|
||||
import { PRPContent } from '../types/prp.types';
|
||||
|
||||
interface MetadataSectionProps {
|
||||
content: PRPContent;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the metadata header section of a PRP document
|
||||
*/
|
||||
export const MetadataSection: React.FC<MetadataSectionProps> = ({ content, isDarkMode = false }) => {
|
||||
const getIcon = (field: string) => {
|
||||
switch (field) {
|
||||
case 'version': return <Award className="w-4 h-4 text-blue-500" />;
|
||||
case 'author': return <Users className="w-4 h-4 text-purple-500" />;
|
||||
case 'date': return <Clock className="w-4 h-4 text-green-500" />;
|
||||
case 'status': return <Tag className="w-4 h-4 text-orange-500" />;
|
||||
default: return <FileText className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
const statusColors = {
|
||||
draft: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
review: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
approved: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
published: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
};
|
||||
|
||||
const colorClass = statusColors[status.toLowerCase() as keyof typeof statusColors] ||
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const metadataFields = ['version', 'author', 'date', 'status'];
|
||||
const hasMetadata = metadataFields.some(field => content[field]);
|
||||
|
||||
if (!hasMetadata && !content.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8 p-6 rounded-xl bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-200 dark:border-blue-800">
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-4">
|
||||
{content.title || 'Product Requirements Prompt'}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{metadataFields.map(field => {
|
||||
const value = content[field];
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<div key={field} className="flex items-center gap-2">
|
||||
{getIcon(field)}
|
||||
{field === 'status' ? (
|
||||
formatStatus(value)
|
||||
) : (
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{field === 'version' && 'Version'} {value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{content.document_type && (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-indigo-500" />
|
||||
<span className="text-gray-600 dark:text-gray-400 capitalize">
|
||||
{content.document_type}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from 'react';
|
||||
import { BarChart3, Settings, Users, Gauge } from 'lucide-react';
|
||||
import { SectionProps } from '../types/prp.types';
|
||||
import { formatKey } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* Renders success metrics and KPIs
|
||||
*/
|
||||
export const MetricsSection: React.FC<SectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon,
|
||||
accentColor = 'green',
|
||||
defaultOpen = true,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const getCategoryColor = (category: string): string => {
|
||||
const normalizedCategory = category.toLowerCase();
|
||||
if (normalizedCategory.includes('admin')) return 'from-blue-400 to-blue-600';
|
||||
if (normalizedCategory.includes('business')) return 'from-purple-400 to-purple-600';
|
||||
if (normalizedCategory.includes('customer')) return 'from-green-400 to-green-600';
|
||||
if (normalizedCategory.includes('technical')) return 'from-orange-400 to-orange-600';
|
||||
if (normalizedCategory.includes('performance')) return 'from-red-400 to-red-600';
|
||||
return 'from-gray-400 to-gray-600';
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string): React.ReactNode => {
|
||||
const normalizedCategory = category.toLowerCase();
|
||||
if (normalizedCategory.includes('admin')) return <Settings className="w-4 h-4" />;
|
||||
if (normalizedCategory.includes('business')) return <BarChart3 className="w-4 h-4" />;
|
||||
if (normalizedCategory.includes('customer')) return <Users className="w-4 h-4" />;
|
||||
return <Gauge className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
const renderMetric = (metric: string, category: string, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={`${category}-${index}`}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-white/50 dark:bg-black/30 border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 transition-all duration-200 group"
|
||||
>
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${getCategoryColor(category)} text-white shadow-md group-hover:scale-110 transition-transform duration-200`}>
|
||||
{getCategoryIcon(category)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 flex-1">{metric}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{Object.entries(data).map(([category, metrics]: [string, any]) => (
|
||||
<div key={category}>
|
||||
<h4 className="font-semibold text-gray-800 dark:text-white mb-3 capitalize">
|
||||
{formatKey(category)}
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
{Array.isArray(metrics) ?
|
||||
metrics.map((metric: string, idx: number) =>
|
||||
renderMetric(metric, category, idx)
|
||||
) :
|
||||
typeof metrics === 'object' && metrics !== null ?
|
||||
Object.entries(metrics).map(([key, value], idx) =>
|
||||
renderMetric(`${formatKey(key)}: ${value}`, category, idx)
|
||||
) :
|
||||
renderMetric(String(metrics), category, 0)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, FileText } from 'lucide-react';
|
||||
import { PRPSectionProps } from '../types/prp.types';
|
||||
import { formatKey, formatValue } from '../utils/formatters';
|
||||
import { CollapsibleSectionWrapper } from '../components/CollapsibleSectionWrapper';
|
||||
|
||||
/**
|
||||
* Component for rendering complex object structures with nested data
|
||||
* Used for sections like design systems, architecture, etc.
|
||||
*/
|
||||
export const ObjectSection: React.FC<PRPSectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon = <Box className="w-5 h-5" />,
|
||||
accentColor = 'indigo',
|
||||
isDarkMode = false,
|
||||
defaultOpen = true,
|
||||
isCollapsible = true,
|
||||
isOpen,
|
||||
onToggle
|
||||
}) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const colorMap = {
|
||||
blue: 'from-blue-400 to-blue-600 border-blue-500',
|
||||
purple: 'from-purple-400 to-purple-600 border-purple-500',
|
||||
green: 'from-green-400 to-green-600 border-green-500',
|
||||
orange: 'from-orange-400 to-orange-600 border-orange-500',
|
||||
pink: 'from-pink-400 to-pink-600 border-pink-500',
|
||||
cyan: 'from-cyan-400 to-cyan-600 border-cyan-500',
|
||||
indigo: 'from-indigo-400 to-indigo-600 border-indigo-500',
|
||||
emerald: 'from-emerald-400 to-emerald-600 border-emerald-500',
|
||||
};
|
||||
|
||||
const bgColorMap = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-950',
|
||||
purple: 'bg-purple-50 dark:bg-purple-950',
|
||||
green: 'bg-green-50 dark:bg-green-950',
|
||||
orange: 'bg-orange-50 dark:bg-orange-950',
|
||||
pink: 'bg-pink-50 dark:bg-pink-950',
|
||||
cyan: 'bg-cyan-50 dark:bg-cyan-950',
|
||||
indigo: 'bg-indigo-50 dark:bg-indigo-950',
|
||||
emerald: 'bg-emerald-50 dark:bg-emerald-950',
|
||||
};
|
||||
|
||||
const renderNestedObject = (obj: any, depth: number = 0): React.ReactNode => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return <span className="text-gray-700 dark:text-gray-300">{formatValue(obj)}</span>;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
// Handle empty arrays
|
||||
if (obj.length === 0) {
|
||||
return <span className="text-gray-500 italic">No items</span>;
|
||||
}
|
||||
|
||||
// Check if it's a simple array (strings/numbers/booleans)
|
||||
const isSimpleArray = obj.every(item =>
|
||||
typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean'
|
||||
);
|
||||
|
||||
if (isSimpleArray) {
|
||||
return (
|
||||
<ul className="space-y-1 mt-2">
|
||||
{obj.map((item, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{String(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// Complex array with objects
|
||||
return (
|
||||
<div className="space-y-3 mt-2">
|
||||
{obj.map((item, index) => (
|
||||
<div key={index} className={`${depth > 0 ? 'border-l-2 border-gray-200 dark:border-gray-700 pl-4' : ''}`}>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Item {index + 1}</div>
|
||||
{renderNestedObject(item, depth + 1)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
const entries = Object.entries(obj);
|
||||
|
||||
// Group entries by type for better organization
|
||||
const stringEntries = entries.filter(([_, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean');
|
||||
const arrayEntries = entries.filter(([_, v]) => Array.isArray(v));
|
||||
const objectEntries = entries.filter(([_, v]) => typeof v === 'object' && v !== null && !Array.isArray(v));
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${depth > 0 ? 'mt-2' : ''}`}>
|
||||
{/* Render simple key-value pairs first */}
|
||||
{stringEntries.length > 0 && (
|
||||
<div className={`${depth > 0 ? 'ml-4' : ''} space-y-2`}>
|
||||
{stringEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-start gap-2">
|
||||
<span className="text-gray-600 dark:text-gray-400 min-w-[100px] text-sm">
|
||||
{formatKey(key)}:
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300 text-sm">
|
||||
{String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render arrays */}
|
||||
{arrayEntries.map(([key, value]) => (
|
||||
<div key={key} className={`${depth > 0 ? 'ml-4' : ''}`}>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<FileText className="w-4 h-4 text-gray-400 mt-1 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h5 className={`font-semibold text-gray-700 dark:text-gray-300 ${depth > 2 ? 'text-sm' : ''}`}>
|
||||
{formatKey(key)}
|
||||
</h5>
|
||||
<div className="text-sm">
|
||||
{renderNestedObject(value, depth + 1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Render nested objects */}
|
||||
{objectEntries.map(([key, value]) => {
|
||||
// Determine if this is a complex nested structure
|
||||
const isComplex = Object.values(value as object).some(v =>
|
||||
typeof v === 'object' && v !== null
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={key} className={`${depth > 0 ? 'ml-4' : ''}`}>
|
||||
<div className={`
|
||||
${isComplex ? 'border-l-4 border-gray-300 dark:border-gray-600 pl-4' : ''}
|
||||
${depth > 1 ? 'mt-4' : ''}
|
||||
`}>
|
||||
<h5 className={`
|
||||
font-semibold text-gray-700 dark:text-gray-300 mb-2
|
||||
${depth === 0 ? 'text-base' : depth === 1 ? 'text-sm' : 'text-xs'}
|
||||
`}>
|
||||
{formatKey(key)}
|
||||
</h5>
|
||||
<div className={depth > 2 ? 'text-xs' : 'text-sm'}>
|
||||
{renderNestedObject(value, depth + 1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className={`rounded-lg p-6 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.indigo} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap].split(' ').slice(0, 2).join(' ')} text-white shadow-lg`}>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white flex-1">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className={`rounded-b-lg px-6 pb-6 -mt-1 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.indigo} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
|
||||
{renderNestedObject(data)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<CollapsibleSectionWrapper
|
||||
header={header}
|
||||
isCollapsible={isCollapsible}
|
||||
defaultOpen={defaultOpen}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{content}
|
||||
</CollapsibleSectionWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Target, Zap } from 'lucide-react';
|
||||
import { SectionProps, PRPPersona } from '../types/prp.types';
|
||||
|
||||
/**
|
||||
* Renders user personas with expandable cards
|
||||
*/
|
||||
export const PersonaSection: React.FC<SectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon,
|
||||
accentColor = 'purple',
|
||||
defaultOpen = true,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{Object.entries(data).map(([key, persona]) => (
|
||||
<PersonaCard key={key} persona={persona as PRPPersona} personaKey={key} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PersonaCardProps {
|
||||
persona: PRPPersona;
|
||||
personaKey: string;
|
||||
}
|
||||
|
||||
const PersonaCard: React.FC<PersonaCardProps> = ({ persona, personaKey }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const getPersonaIcon = (key: string) => {
|
||||
if (key.includes('admin')) return '👨💼';
|
||||
if (key.includes('formulator')) return '🧪';
|
||||
if (key.includes('purchasing')) return '💰';
|
||||
if (key.includes('developer')) return '👨💻';
|
||||
if (key.includes('designer')) return '🎨';
|
||||
if (key.includes('manager')) return '👔';
|
||||
if (key.includes('customer')) return '🛍️';
|
||||
return '👤';
|
||||
};
|
||||
|
||||
const renderJourney = (journey: Record<string, any>) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Object.entries(journey).map(([stage, description]) => (
|
||||
<div key={stage} className="flex items-start gap-2 text-sm">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300 capitalize min-w-[100px]">
|
||||
{stage}:
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{typeof description === 'string' ? description : JSON.stringify(description)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWorkflow = (workflow: Record<string, any>) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Object.entries(workflow).map(([time, task]) => (
|
||||
<div key={time} className="flex items-start gap-2 text-sm">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300 capitalize min-w-[100px]">
|
||||
{time}:
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{typeof task === 'string' ? task : JSON.stringify(task)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
<div
|
||||
className="p-6 rounded-xl bg-gradient-to-br from-white/80 to-white/60 dark:from-gray-800/50 dark:to-gray-900/50 border border-gray-200 dark:border-gray-700 hover:border-purple-400 dark:hover:border-purple-500 transition-all duration-300 shadow-lg hover:shadow-xl hover:scale-[1.02] cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-4xl">{getPersonaIcon(personaKey)}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white mb-1">
|
||||
{persona.name || personaKey}
|
||||
</h3>
|
||||
{persona.role && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{persona.role}</p>
|
||||
)}
|
||||
|
||||
{/* Always visible goals */}
|
||||
{persona.goals && Array.isArray(persona.goals) && persona.goals.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-green-500" />
|
||||
Goals
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{persona.goals.slice(0, isExpanded ? undefined : 2).map((goal: string, idx: number) => (
|
||||
<li key={idx} className="text-sm text-gray-600 dark:text-gray-400 flex items-start gap-2">
|
||||
<span className="text-green-500 mt-0.5">•</span>
|
||||
{goal}
|
||||
</li>
|
||||
))}
|
||||
{!isExpanded && persona.goals.length > 2 && (
|
||||
<li className="text-sm text-gray-500 dark:text-gray-500 italic">
|
||||
+{persona.goals.length - 2} more...
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable content */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
{persona.pain_points && Array.isArray(persona.pain_points) && persona.pain_points.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-orange-500" />
|
||||
Pain Points
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{persona.pain_points.map((point: string, idx: number) => (
|
||||
<li key={idx} className="text-sm text-gray-600 dark:text-gray-400 flex items-start gap-2">
|
||||
<span className="text-orange-500 mt-0.5">•</span>
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{persona.journey && Object.keys(persona.journey).length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
User Journey
|
||||
</h4>
|
||||
{renderJourney(persona.journey)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{persona.workflow && Object.keys(persona.workflow).length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Daily Workflow
|
||||
</h4>
|
||||
{renderWorkflow(persona.workflow)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render any other fields */}
|
||||
{Object.entries(persona).map(([key, value]) => {
|
||||
if (['name', 'role', 'goals', 'pain_points', 'journey', 'workflow'].includes(key)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={key} className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</h4>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-gray-500 dark:text-gray-500 text-right">
|
||||
Click to {isExpanded ? 'collapse' : 'expand'} details
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Clock, Zap, CheckCircle2 } from 'lucide-react';
|
||||
import { SectionProps, PRPPhase } from '../types/prp.types';
|
||||
|
||||
/**
|
||||
* Renders implementation plans and phases
|
||||
*/
|
||||
export const PlanSection: React.FC<SectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon,
|
||||
accentColor = 'orange',
|
||||
defaultOpen = true,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const getPhaseColor = (index: number): string => {
|
||||
const colors = ['orange', 'yellow', 'green', 'blue', 'purple'];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
const renderPhase = (phaseKey: string, phase: PRPPhase, index: number) => {
|
||||
const color = getPhaseColor(index);
|
||||
const colorMap = {
|
||||
orange: 'from-orange-50/50 to-yellow-50/50 dark:from-orange-900/20 dark:to-yellow-900/20 border-orange-200 dark:border-orange-800',
|
||||
yellow: 'from-yellow-50/50 to-amber-50/50 dark:from-yellow-900/20 dark:to-amber-900/20 border-yellow-200 dark:border-yellow-800',
|
||||
green: 'from-green-50/50 to-emerald-50/50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-200 dark:border-green-800',
|
||||
blue: 'from-blue-50/50 to-cyan-50/50 dark:from-blue-900/20 dark:to-cyan-900/20 border-blue-200 dark:border-blue-800',
|
||||
purple: 'from-purple-50/50 to-pink-50/50 dark:from-purple-900/20 dark:to-pink-900/20 border-purple-200 dark:border-purple-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={phaseKey}
|
||||
className={`p-4 rounded-lg bg-gradient-to-r ${colorMap[color as keyof typeof colorMap]} border`}
|
||||
>
|
||||
<h4 className="font-bold text-gray-800 dark:text-white mb-2 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-orange-500" />
|
||||
{phaseKey.toUpperCase()}
|
||||
{phase.duration && (
|
||||
<span className="text-sm font-normal text-gray-600 dark:text-gray-400 ml-2">
|
||||
({phase.duration})
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{phase.deliverables && Array.isArray(phase.deliverables) && (
|
||||
<div className="mb-3">
|
||||
<h5 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Deliverables
|
||||
</h5>
|
||||
<ul className="space-y-1">
|
||||
{phase.deliverables.map((item: string, idx: number) => (
|
||||
<li key={idx} className="text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.tasks && Array.isArray(phase.tasks) && (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tasks
|
||||
</h5>
|
||||
<ul className="space-y-1">
|
||||
{phase.tasks.map((task: any, idx: number) => (
|
||||
<li key={idx} className="text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600 mt-0.5 flex-shrink-0" />
|
||||
{typeof task === 'string' ? task : task.description || JSON.stringify(task)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render any other phase properties */}
|
||||
{Object.entries(phase).map(([key, value]) => {
|
||||
if (['duration', 'deliverables', 'tasks'].includes(key)) return null;
|
||||
return (
|
||||
<div key={key} className="mt-3">
|
||||
<h5 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1 capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</h5>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Check if this is a phased plan or a general plan structure
|
||||
const isPhased = Object.values(data).some(value =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value.duration || value.deliverables || value.tasks)
|
||||
);
|
||||
|
||||
if (isPhased) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(data).map(([phaseKey, phase], index) =>
|
||||
renderPhase(phaseKey, phase as PRPPhase, index)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to generic rendering for non-phased plans
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-gradient-to-r from-orange-50/50 to-yellow-50/50 dark:from-orange-900/20 dark:to-yellow-900/20 border border-orange-200 dark:border-orange-800">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-orange-500" />
|
||||
{title}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{key.replace(/_/g, ' ').charAt(0).toUpperCase() + key.replace(/_/g, ' ').slice(1)}:
|
||||
</span>{' '}
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,236 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Calendar, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { PRPSectionProps } from '../types/prp.types';
|
||||
import { formatKey, formatValue } from '../utils/formatters';
|
||||
import { CollapsibleSectionWrapper } from '../components/CollapsibleSectionWrapper';
|
||||
|
||||
/**
|
||||
* Component for rendering rollout plans and deployment strategies
|
||||
*/
|
||||
export const RolloutPlanSection: React.FC<PRPSectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon = <Calendar className="w-5 h-5" />,
|
||||
accentColor = 'orange',
|
||||
isDarkMode = false,
|
||||
defaultOpen = true,
|
||||
isCollapsible = true,
|
||||
isOpen,
|
||||
onToggle
|
||||
}) => {
|
||||
if (!data) return null;
|
||||
|
||||
const colorMap = {
|
||||
blue: 'from-blue-400 to-blue-600 border-blue-500',
|
||||
purple: 'from-purple-400 to-purple-600 border-purple-500',
|
||||
green: 'from-green-400 to-green-600 border-green-500',
|
||||
orange: 'from-orange-400 to-orange-600 border-orange-500',
|
||||
pink: 'from-pink-400 to-pink-600 border-pink-500',
|
||||
cyan: 'from-cyan-400 to-cyan-600 border-cyan-500',
|
||||
indigo: 'from-indigo-400 to-indigo-600 border-indigo-500',
|
||||
emerald: 'from-emerald-400 to-emerald-600 border-emerald-500',
|
||||
};
|
||||
|
||||
const bgColorMap = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-950',
|
||||
purple: 'bg-purple-50 dark:bg-purple-950',
|
||||
green: 'bg-green-50 dark:bg-green-950',
|
||||
orange: 'bg-orange-50 dark:bg-orange-950',
|
||||
pink: 'bg-pink-50 dark:bg-pink-950',
|
||||
cyan: 'bg-cyan-50 dark:bg-cyan-950',
|
||||
indigo: 'bg-indigo-50 dark:bg-indigo-950',
|
||||
emerald: 'bg-emerald-50 dark:bg-emerald-950',
|
||||
};
|
||||
|
||||
const renderPhase = (phase: any, index: number) => {
|
||||
if (typeof phase === 'string') {
|
||||
return (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-sm font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-700 dark:text-gray-300">{phase}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof phase === 'object' && phase !== null) {
|
||||
const phaseName = phase.name || phase.title || phase.phase || `Phase ${index + 1}`;
|
||||
const duration = phase.duration || phase.timeline || phase.timeframe;
|
||||
const description = phase.description || phase.details || phase.summary;
|
||||
const tasks = phase.tasks || phase.activities || phase.items;
|
||||
const risks = phase.risks || phase.considerations;
|
||||
|
||||
return (
|
||||
<div key={index} className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 ml-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-orange-400 to-orange-600 text-white flex items-center justify-center font-bold shadow-md">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-gray-800 dark:text-white text-lg">{phaseName}</h4>
|
||||
{duration && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{duration}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-3 ml-13">{description}</p>
|
||||
)}
|
||||
|
||||
{tasks && Array.isArray(tasks) && tasks.length > 0 && (
|
||||
<div className="ml-13 mb-3">
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">Tasks:</p>
|
||||
<ul className="space-y-1">
|
||||
{tasks.map((task, taskIndex) => (
|
||||
<li key={taskIndex} className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">{formatValue(task)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{risks && Array.isArray(risks) && risks.length > 0 && (
|
||||
<div className="ml-13">
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">Risks & Considerations:</p>
|
||||
<ul className="space-y-1">
|
||||
{risks.map((risk, riskIndex) => (
|
||||
<li key={riskIndex} className="flex items-start gap-2 text-sm">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">{formatValue(risk)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render any other properties */}
|
||||
{Object.entries(phase).map(([key, value]) => {
|
||||
if (['name', 'title', 'phase', 'duration', 'timeline', 'timeframe', 'description', 'details', 'summary', 'tasks', 'activities', 'items', 'risks', 'considerations'].includes(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="ml-13 mt-3">
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400">{formatKey(key)}:</p>
|
||||
<div className="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{typeof value === 'string' || typeof value === 'number' ? (
|
||||
<span>{value}</span>
|
||||
) : Array.isArray(value) ? (
|
||||
<ul className="space-y-1 mt-1">
|
||||
{value.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>{formatValue(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-x-auto">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderRolloutPlan = () => {
|
||||
// Handle array of phases
|
||||
if (Array.isArray(data)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{data.map((phase, index) => renderPhase(phase, index))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle object with phases
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
const phases = data.phases || data.plan || data.steps || data.stages;
|
||||
|
||||
if (phases && Array.isArray(phases)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{phases.map((phase, index) => renderPhase(phase, index))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle object with other properties
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<h4 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{formatKey(key)}
|
||||
</h4>
|
||||
{Array.isArray(value) ? (
|
||||
<div className="space-y-4">
|
||||
{value.map((item, index) => renderPhase(item, index))}
|
||||
</div>
|
||||
) : typeof value === 'object' && value !== null ? (
|
||||
<div className="pl-4 border-l-2 border-gray-200 dark:border-gray-700">
|
||||
{renderPhase(value, 0)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300">{formatValue(value)}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle string
|
||||
if (typeof data === 'string') {
|
||||
return <p className="text-gray-700 dark:text-gray-300">{data}</p>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className={`rounded-lg p-6 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.orange} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap].split(' ').slice(0, 2).join(' ')} text-white shadow-lg`}>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white flex-1">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className={`rounded-b-lg px-6 pb-6 -mt-1 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.orange} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
|
||||
{renderRolloutPlan()}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<CollapsibleSectionWrapper
|
||||
header={header}
|
||||
isCollapsible={isCollapsible}
|
||||
defaultOpen={defaultOpen}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{content}
|
||||
</CollapsibleSectionWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,235 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Palette, Layers } from 'lucide-react';
|
||||
import { PRPSectionProps } from '../types/prp.types';
|
||||
import { formatKey, formatValue } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* Component for rendering design token systems and style guides
|
||||
*/
|
||||
export const TokenSystemSection: React.FC<PRPSectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
icon = <Palette className="w-5 h-5" />,
|
||||
accentColor = 'indigo',
|
||||
isDarkMode = false,
|
||||
defaultOpen = true
|
||||
}) => {
|
||||
if (!data) return null;
|
||||
|
||||
const colorMap = {
|
||||
blue: 'from-blue-400 to-blue-600 border-blue-500',
|
||||
purple: 'from-purple-400 to-purple-600 border-purple-500',
|
||||
green: 'from-green-400 to-green-600 border-green-500',
|
||||
orange: 'from-orange-400 to-orange-600 border-orange-500',
|
||||
pink: 'from-pink-400 to-pink-600 border-pink-500',
|
||||
cyan: 'from-cyan-400 to-cyan-600 border-cyan-500',
|
||||
indigo: 'from-indigo-400 to-indigo-600 border-indigo-500',
|
||||
emerald: 'from-emerald-400 to-emerald-600 border-emerald-500',
|
||||
};
|
||||
|
||||
const bgColorMap = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-950',
|
||||
purple: 'bg-purple-50 dark:bg-purple-950',
|
||||
green: 'bg-green-50 dark:bg-green-950',
|
||||
orange: 'bg-orange-50 dark:bg-orange-950',
|
||||
pink: 'bg-pink-50 dark:bg-pink-950',
|
||||
cyan: 'bg-cyan-50 dark:bg-cyan-950',
|
||||
indigo: 'bg-indigo-50 dark:bg-indigo-950',
|
||||
emerald: 'bg-emerald-50 dark:bg-emerald-950',
|
||||
};
|
||||
|
||||
const renderColorSwatch = (color: string, name: string) => {
|
||||
// Check if it's a valid color value
|
||||
const isHex = /^#[0-9A-F]{6}$/i.test(color);
|
||||
const isRgb = /^rgb/.test(color);
|
||||
const isHsl = /^hsl/.test(color);
|
||||
const isNamedColor = /^[a-z]+$/i.test(color);
|
||||
|
||||
if (isHex || isRgb || isHsl || isNamedColor) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 dark:text-gray-300">{name}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{color}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderSpacingValue = (value: string | number, name: string) => {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
const unit = typeof value === 'string' ? value.replace(/[0-9.-]/g, '') : 'px';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="bg-indigo-500 rounded"
|
||||
style={{
|
||||
width: `${Math.min(numValue * 2, 100)}px`,
|
||||
height: '24px'
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 dark:text-gray-300">{name}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{value}{unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTokenGroup = (tokens: any, groupName: string) => {
|
||||
if (!tokens || typeof tokens !== 'object') return null;
|
||||
|
||||
const entries = Object.entries(tokens);
|
||||
const isColorGroup = groupName.toLowerCase().includes('color') ||
|
||||
entries.some(([_, v]) => typeof v === 'string' && (v.startsWith('#') || v.startsWith('rgb')));
|
||||
const isSpacingGroup = groupName.toLowerCase().includes('spacing') ||
|
||||
groupName.toLowerCase().includes('size') ||
|
||||
groupName.toLowerCase().includes('radius');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
{formatKey(groupName)}
|
||||
</h4>
|
||||
<div className={`grid gap-4 ${isColorGroup ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1'}`}>
|
||||
{entries.map(([key, value]) => {
|
||||
if (isColorGroup && typeof value === 'string') {
|
||||
const swatch = renderColorSwatch(value, formatKey(key));
|
||||
if (swatch) return <div key={key}>{swatch}</div>;
|
||||
}
|
||||
|
||||
if (isSpacingGroup && (typeof value === 'string' || typeof value === 'number')) {
|
||||
return <div key={key}>{renderSpacingValue(value, formatKey(key))}</div>;
|
||||
}
|
||||
|
||||
// Handle nested token groups
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return (
|
||||
<div key={key} className="col-span-full">
|
||||
{renderTokenGroup(value, key)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default rendering
|
||||
return (
|
||||
<div key={key} className="flex items-start gap-2">
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">{formatKey(key)}:</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{formatValue(value)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTokenSystem = () => {
|
||||
// Handle string description
|
||||
if (typeof data === 'string') {
|
||||
return <p className="text-gray-700 dark:text-gray-300">{data}</p>;
|
||||
}
|
||||
|
||||
// Handle array of token groups
|
||||
if (Array.isArray(data)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{data.map((group, index) => (
|
||||
<div key={index}>
|
||||
{typeof group === 'object' && group !== null ? (
|
||||
renderTokenGroup(group, `Group ${index + 1}`)
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300">{formatValue(group)}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle object with token categories
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
const categories = Object.entries(data);
|
||||
|
||||
// Special handling for common token categories
|
||||
const colorTokens = categories.filter(([k]) => k.toLowerCase().includes('color'));
|
||||
const typographyTokens = categories.filter(([k]) => k.toLowerCase().includes('typography') || k.toLowerCase().includes('font'));
|
||||
const spacingTokens = categories.filter(([k]) => k.toLowerCase().includes('spacing') || k.toLowerCase().includes('size'));
|
||||
const otherTokens = categories.filter(([k]) =>
|
||||
!k.toLowerCase().includes('color') &&
|
||||
!k.toLowerCase().includes('typography') &&
|
||||
!k.toLowerCase().includes('font') &&
|
||||
!k.toLowerCase().includes('spacing') &&
|
||||
!k.toLowerCase().includes('size')
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Colors */}
|
||||
{colorTokens.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{colorTokens.map(([key, value]) => (
|
||||
<div key={key}>{renderTokenGroup(value, key)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Typography */}
|
||||
{typographyTokens.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{typographyTokens.map(([key, value]) => (
|
||||
<div key={key}>{renderTokenGroup(value, key)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacing */}
|
||||
{spacingTokens.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{spacingTokens.map(([key, value]) => (
|
||||
<div key={key}>{renderTokenGroup(value, key)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Others */}
|
||||
{otherTokens.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{otherTokens.map(([key, value]) => (
|
||||
<div key={key}>{renderTokenGroup(value, key)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-lg p-6 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.indigo} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap].split(' ').slice(0, 2).join(' ')} text-white shadow-lg`}>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{renderTokenSystem()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// Base section types
|
||||
export type SectionType =
|
||||
| 'metadata'
|
||||
| 'context'
|
||||
| 'personas'
|
||||
| 'flows'
|
||||
| 'metrics'
|
||||
| 'plan'
|
||||
| 'list'
|
||||
| 'object'
|
||||
| 'keyvalue'
|
||||
| 'features'
|
||||
| 'generic';
|
||||
|
||||
export interface SectionProps {
|
||||
title: string;
|
||||
data: any;
|
||||
icon?: ReactNode;
|
||||
accentColor?: string;
|
||||
defaultOpen?: boolean;
|
||||
isDarkMode?: boolean;
|
||||
isCollapsible?: boolean;
|
||||
onToggle?: () => void;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
// Alias for component compatibility
|
||||
export type PRPSectionProps = SectionProps;
|
||||
|
||||
export interface PRPMetadata {
|
||||
title?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
date?: string;
|
||||
status?: string;
|
||||
document_type?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PRPContext {
|
||||
scope?: string;
|
||||
background?: string;
|
||||
objectives?: string[];
|
||||
requirements?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PRPPersona {
|
||||
name?: string;
|
||||
role?: string;
|
||||
goals?: string[];
|
||||
pain_points?: string[];
|
||||
journey?: Record<string, any>;
|
||||
workflow?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PRPPhase {
|
||||
duration?: string;
|
||||
deliverables?: string[];
|
||||
tasks?: any[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PRPContent {
|
||||
// Common fields
|
||||
title?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
date?: string;
|
||||
status?: string;
|
||||
document_type?: string;
|
||||
|
||||
// Section fields
|
||||
context?: PRPContext;
|
||||
user_personas?: Record<string, PRPPersona>;
|
||||
user_flows?: Record<string, any>;
|
||||
success_metrics?: Record<string, string[] | Record<string, any>>;
|
||||
implementation_plan?: Record<string, PRPPhase>;
|
||||
validation_gates?: Record<string, string[]>;
|
||||
technical_implementation?: Record<string, any>;
|
||||
ui_improvements?: Record<string, any>;
|
||||
information_architecture?: Record<string, any>;
|
||||
current_state_analysis?: Record<string, any>;
|
||||
component_architecture?: Record<string, any>;
|
||||
|
||||
// Allow any other fields
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SectionDetectorResult {
|
||||
type: SectionType;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface SectionComponentProps extends SectionProps {
|
||||
content: PRPContent;
|
||||
sectionKey: string;
|
||||
}
|
||||
|
||||
// Color maps for consistent theming
|
||||
export const sectionColorMap: Record<string, string> = {
|
||||
metadata: 'blue',
|
||||
context: 'purple',
|
||||
personas: 'pink',
|
||||
flows: 'orange',
|
||||
metrics: 'green',
|
||||
plan: 'cyan',
|
||||
technical: 'indigo',
|
||||
validation: 'emerald',
|
||||
generic: 'gray'
|
||||
};
|
||||
|
||||
// Icon size constants
|
||||
export const ICON_SIZES = {
|
||||
section: 'w-5 h-5',
|
||||
subsection: 'w-4 h-4',
|
||||
item: 'w-3 h-3'
|
||||
} as const;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { normalizeImagePlaceholders } from './normalizer';
|
||||
|
||||
/**
|
||||
* Formats a key into a human-readable label
|
||||
*/
|
||||
export function formatKey(key: string): string {
|
||||
return key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates text with ellipsis
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number = 100): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a value for display
|
||||
*/
|
||||
export function formatValue(value: any): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (typeof value === 'number') return value.toLocaleString();
|
||||
if (typeof value === 'string') {
|
||||
// Temporarily disabled to debug black screen issue
|
||||
// return normalizeImagePlaceholders(value);
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) return `${value.length} items`;
|
||||
if (typeof value === 'object') return `${Object.keys(value).length} properties`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets accent color based on index for variety
|
||||
*/
|
||||
export function getAccentColor(index: number): string {
|
||||
const colors = ['blue', 'purple', 'green', 'orange', 'pink', 'cyan', 'indigo', 'emerald'];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique key for React components
|
||||
*/
|
||||
export function generateKey(prefix: string, ...parts: (string | number)[]): string {
|
||||
return [prefix, ...parts].filter(Boolean).join('-');
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
/**
|
||||
* Markdown Parser for PRP Documents
|
||||
*
|
||||
* Parses raw markdown content into structured sections that can be rendered
|
||||
* by the PRPViewer component with collapsible sections and beautiful formatting.
|
||||
*/
|
||||
|
||||
export interface ParsedSection {
|
||||
title: string;
|
||||
content: string;
|
||||
level: number;
|
||||
type: 'text' | 'list' | 'code' | 'mixed';
|
||||
rawContent: string;
|
||||
sectionKey: string;
|
||||
templateType?: string; // For matching to PRP templates
|
||||
}
|
||||
|
||||
export interface ParsedMarkdownDocument {
|
||||
title?: string;
|
||||
sections: ParsedSection[];
|
||||
metadata: Record<string, any>;
|
||||
hasMetadata: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedMarkdown {
|
||||
title?: string;
|
||||
sections: Record<string, ParsedSection>;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses markdown content into structured sections based on headers
|
||||
*/
|
||||
export function parseMarkdownToPRP(content: string): ParsedMarkdown {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { sections: {}, metadata: {} };
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const sections: Record<string, ParsedSection> = {};
|
||||
let currentSection: ParsedSection | null = null;
|
||||
let documentTitle: string | undefined;
|
||||
let sectionCounter = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Check for headers (## Section Name or # Document Title)
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const title = headerMatch[2].trim();
|
||||
|
||||
// Save previous section if exists
|
||||
if (currentSection) {
|
||||
const sectionKey = generateSectionKey(currentSection.title, sectionCounter);
|
||||
sections[sectionKey] = {
|
||||
...currentSection,
|
||||
content: currentSection.content.trim(),
|
||||
rawContent: currentSection.rawContent.trim(),
|
||||
type: detectContentType(currentSection.content)
|
||||
};
|
||||
sectionCounter++;
|
||||
}
|
||||
|
||||
// Handle document title (# level headers)
|
||||
if (level === 1 && !documentTitle) {
|
||||
documentTitle = title;
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start new section
|
||||
currentSection = {
|
||||
title,
|
||||
content: '',
|
||||
level,
|
||||
type: 'text',
|
||||
rawContent: ''
|
||||
};
|
||||
} else if (currentSection) {
|
||||
// Add content to current section
|
||||
currentSection.content += line + '\n';
|
||||
currentSection.rawContent += line + '\n';
|
||||
} else if (!documentTitle && line.trim()) {
|
||||
// If we haven't found a title yet and encounter content, treat first non-empty line as title
|
||||
documentTitle = line.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Save final section
|
||||
if (currentSection) {
|
||||
const sectionKey = generateSectionKey(currentSection.title, sectionCounter);
|
||||
sections[sectionKey] = {
|
||||
...currentSection,
|
||||
content: currentSection.content.trim(),
|
||||
rawContent: currentSection.rawContent.trim(),
|
||||
type: detectContentType(currentSection.content)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: documentTitle,
|
||||
sections,
|
||||
metadata: {
|
||||
document_type: 'prp',
|
||||
parsed_from_markdown: true,
|
||||
section_count: Object.keys(sections).length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a consistent section key for use in the sections object
|
||||
*/
|
||||
function generateSectionKey(title: string, counter: number): string {
|
||||
// Convert title to a key format
|
||||
const baseKey = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, '')
|
||||
.replace(/\s+/g, '_')
|
||||
.substring(0, 30); // Limit length
|
||||
|
||||
return baseKey || `section_${counter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the type of content in a section
|
||||
*/
|
||||
function detectContentType(content: string): 'text' | 'list' | 'code' | 'mixed' {
|
||||
if (!content.trim()) return 'text';
|
||||
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
let hasText = false;
|
||||
let hasList = false;
|
||||
let hasCode = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('```')) {
|
||||
hasCode = true;
|
||||
} else if (line.match(/^[-*+]\s/) || line.match(/^\d+\.\s/)) {
|
||||
hasList = true;
|
||||
} else if (line.trim()) {
|
||||
hasText = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCode) return 'code';
|
||||
if (hasList && hasText) return 'mixed';
|
||||
if (hasList) return 'list';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts parsed markdown back to a structure compatible with PRPViewer
|
||||
* Each section becomes a separate collapsible section in the viewer
|
||||
*/
|
||||
export function convertParsedMarkdownToPRPStructure(parsed: ParsedMarkdown): any {
|
||||
const result: any = {
|
||||
title: parsed.title || 'Untitled Document',
|
||||
...parsed.metadata
|
||||
};
|
||||
|
||||
// Add each section as a top-level property
|
||||
// The content will be the raw markdown for that section only
|
||||
for (const [key, section] of Object.entries(parsed.sections)) {
|
||||
result[key] = section.rawContent;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if content appears to be raw markdown
|
||||
*/
|
||||
export function isMarkdownContent(content: any): boolean {
|
||||
if (typeof content !== 'string') return false;
|
||||
|
||||
// Look for markdown indicators
|
||||
const markdownIndicators = [
|
||||
/^#{1,6}\s+.+$/m, // Headers
|
||||
/^[-*+]\s+.+$/m, // Bullet lists
|
||||
/^\d+\.\s+.+$/m, // Numbered lists
|
||||
/```/, // Code blocks
|
||||
/^\>.+$/m, // Blockquotes
|
||||
/\*\*.+\*\*/, // Bold text
|
||||
/\*.+\*/, // Italic text
|
||||
];
|
||||
|
||||
return markdownIndicators.some(pattern => pattern.test(content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses markdown content into a flowing document structure
|
||||
*/
|
||||
export function parseMarkdownToDocument(content: string): ParsedMarkdownDocument {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { sections: [], metadata: {}, hasMetadata: false };
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const sections: ParsedSection[] = [];
|
||||
let currentSection: Partial<ParsedSection> | null = null;
|
||||
let documentTitle: string | undefined;
|
||||
let sectionCounter = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Check for headers (## Section Name or # Document Title)
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const title = headerMatch[2].trim();
|
||||
|
||||
// Save previous section if exists
|
||||
if (currentSection && currentSection.title) {
|
||||
sections.push({
|
||||
title: currentSection.title,
|
||||
content: (currentSection.content || '').trim(),
|
||||
level: currentSection.level || 2,
|
||||
type: detectContentType(currentSection.content || ''),
|
||||
rawContent: (currentSection.rawContent || '').trim(),
|
||||
sectionKey: generateSectionKey(currentSection.title, sectionCounter),
|
||||
templateType: detectTemplateType(currentSection.title)
|
||||
});
|
||||
sectionCounter++;
|
||||
}
|
||||
|
||||
// Handle document title (# level headers)
|
||||
if (level === 1 && !documentTitle) {
|
||||
documentTitle = title;
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start new section
|
||||
currentSection = {
|
||||
title,
|
||||
content: '',
|
||||
level,
|
||||
rawContent: ''
|
||||
};
|
||||
} else if (currentSection) {
|
||||
// Add content to current section
|
||||
currentSection.content = (currentSection.content || '') + line + '\n';
|
||||
currentSection.rawContent = (currentSection.rawContent || '') + line + '\n';
|
||||
} else if (!documentTitle && line.trim()) {
|
||||
// If we haven't found a title yet and encounter content, treat first non-empty line as title
|
||||
documentTitle = line.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Save final section
|
||||
if (currentSection && currentSection.title) {
|
||||
sections.push({
|
||||
title: currentSection.title,
|
||||
content: (currentSection.content || '').trim(),
|
||||
level: currentSection.level || 2,
|
||||
type: detectContentType(currentSection.content || ''),
|
||||
rawContent: (currentSection.rawContent || '').trim(),
|
||||
sectionKey: generateSectionKey(currentSection.title, sectionCounter),
|
||||
templateType: detectTemplateType(currentSection.title)
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title: documentTitle,
|
||||
sections,
|
||||
metadata: {
|
||||
document_type: 'prp', // Set as PRP to get the right styling
|
||||
section_count: sections.length,
|
||||
parsed_from_markdown: true
|
||||
},
|
||||
hasMetadata: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a section title matches a known PRP template type
|
||||
*/
|
||||
function detectTemplateType(title: string): string | undefined {
|
||||
const normalizedTitle = title.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim();
|
||||
|
||||
// Map common PRP section names to template types
|
||||
const templateMap: Record<string, string> = {
|
||||
'goal': 'context',
|
||||
'objective': 'context',
|
||||
'purpose': 'context',
|
||||
'why': 'context',
|
||||
'rationale': 'context',
|
||||
'what': 'context',
|
||||
'description': 'context',
|
||||
'overview': 'context',
|
||||
'context': 'context',
|
||||
'background': 'context',
|
||||
'problem statement': 'context',
|
||||
|
||||
'success metrics': 'metrics',
|
||||
'metrics': 'metrics',
|
||||
'kpis': 'metrics',
|
||||
'success criteria': 'metrics',
|
||||
'estimated impact': 'metrics',
|
||||
|
||||
'implementation plan': 'plan',
|
||||
'plan': 'plan',
|
||||
'roadmap': 'plan',
|
||||
'timeline': 'plan',
|
||||
'phases': 'plan',
|
||||
'rollout plan': 'plan',
|
||||
'migration strategy': 'plan',
|
||||
|
||||
'personas': 'personas',
|
||||
'users': 'personas',
|
||||
'stakeholders': 'personas',
|
||||
'target audience': 'personas',
|
||||
|
||||
'user flow': 'flows',
|
||||
'user journey': 'flows',
|
||||
'workflow': 'flows',
|
||||
'user experience': 'flows',
|
||||
|
||||
'validation': 'list',
|
||||
'testing': 'list',
|
||||
'quality gates': 'list',
|
||||
'acceptance criteria': 'list',
|
||||
|
||||
'features': 'features',
|
||||
'feature requirements': 'features',
|
||||
'capabilities': 'features',
|
||||
|
||||
'technical requirements': 'object',
|
||||
'architecture': 'object',
|
||||
'design': 'object',
|
||||
'components': 'object',
|
||||
|
||||
'budget': 'keyvalue',
|
||||
'resources': 'keyvalue',
|
||||
'team': 'keyvalue',
|
||||
'cost': 'keyvalue'
|
||||
};
|
||||
|
||||
return templateMap[normalizedTitle];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if content is a document with metadata structure
|
||||
*/
|
||||
export function isDocumentWithMetadata(content: any): boolean {
|
||||
if (typeof content !== 'object' || content === null) return false;
|
||||
|
||||
// Check if it has typical document metadata fields
|
||||
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type', 'created_at', 'updated_at'];
|
||||
const hasMetadata = metadataFields.some(field => field in content);
|
||||
|
||||
// Check if it has a content field that looks like markdown
|
||||
const hasMarkdownContent = typeof content.content === 'string' &&
|
||||
isMarkdownContent(content.content);
|
||||
|
||||
// Also check if any field contains markdown content (broader detection)
|
||||
const hasAnyMarkdownField = Object.values(content).some(value =>
|
||||
typeof value === 'string' && isMarkdownContent(value)
|
||||
);
|
||||
|
||||
// Return true if it has metadata AND markdown content, OR if it has obvious document structure
|
||||
return (hasMetadata && (hasMarkdownContent || hasAnyMarkdownField)) ||
|
||||
(hasMetadata && Object.keys(content).length <= 10); // Simple document structure
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to process content for PRPViewer
|
||||
*/
|
||||
export function processContentForPRP(content: any): any {
|
||||
// If it's already an object, return as-is
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// If it's a string that looks like markdown, parse it
|
||||
if (typeof content === 'string' && isMarkdownContent(content)) {
|
||||
const parsed = parseMarkdownToPRP(content);
|
||||
return convertParsedMarkdownToPRPStructure(parsed);
|
||||
}
|
||||
|
||||
// For any other string content, wrap it in a generic structure
|
||||
if (typeof content === 'string') {
|
||||
return {
|
||||
title: 'Document Content',
|
||||
content: content,
|
||||
document_type: 'text'
|
||||
};
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* Normalizes PRP document data to ensure consistent rendering
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes image placeholders to proper markdown format
|
||||
*/
|
||||
export function normalizeImagePlaceholders(content: string): string {
|
||||
return content.replace(/\[Image #(\d+)\]/g, (match, num) => {
|
||||
return ``;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse JSON strings into objects
|
||||
*/
|
||||
export function parseJsonStrings(value: any): any {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
// Return original string if parsing fails
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize image placeholders in strings
|
||||
return normalizeImagePlaceholders(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => parseJsonStrings(item));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const normalized: any = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
normalized[key] = parseJsonStrings(val);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens nested content fields
|
||||
*/
|
||||
export function flattenNestedContent(data: any): any {
|
||||
// Handle nested content field
|
||||
if (data && typeof data === 'object' && 'content' in data) {
|
||||
const { content, ...rest } = data;
|
||||
|
||||
// If content is an object, merge it with the rest
|
||||
if (content && typeof content === 'object' && !Array.isArray(content)) {
|
||||
return flattenNestedContent({ ...rest, ...content });
|
||||
}
|
||||
|
||||
// If content is a string or array, keep it as a field
|
||||
return { ...rest, content };
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes section names to be more readable
|
||||
*/
|
||||
export function normalizeSectionName(name: string): string {
|
||||
// Common abbreviations and their expansions
|
||||
const expansions: Record<string, string> = {
|
||||
'ui': 'User Interface',
|
||||
'ux': 'User Experience',
|
||||
'api': 'API',
|
||||
'kpi': 'KPI',
|
||||
'prp': 'PRP',
|
||||
'prd': 'PRD',
|
||||
'mvp': 'MVP',
|
||||
'poc': 'Proof of Concept',
|
||||
};
|
||||
|
||||
// Split by underscore or camelCase
|
||||
const words = name
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(' ')
|
||||
.filter(word => word.length > 0);
|
||||
|
||||
// Process each word
|
||||
const processed = words.map(word => {
|
||||
const lower = word.toLowerCase();
|
||||
|
||||
// Check if it's a known abbreviation
|
||||
if (expansions[lower]) {
|
||||
return expansions[lower];
|
||||
}
|
||||
|
||||
// Otherwise, capitalize first letter
|
||||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
});
|
||||
|
||||
return processed.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the entire PRP document structure
|
||||
*/
|
||||
export function normalizePRPDocument(content: any): any {
|
||||
if (!content) return content;
|
||||
|
||||
// First, flatten any nested content fields
|
||||
let normalized = flattenNestedContent(content);
|
||||
|
||||
// Then parse any JSON strings
|
||||
normalized = parseJsonStrings(normalized);
|
||||
|
||||
// Handle raw markdown content
|
||||
if (typeof normalized === 'string') {
|
||||
// For strings, just normalize image placeholders and return as-is
|
||||
// The PRPViewer will handle the markdown parsing
|
||||
return normalizeImagePlaceholders(normalized);
|
||||
}
|
||||
|
||||
// For objects, process each field recursively
|
||||
if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) {
|
||||
const result: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
// Skip empty values
|
||||
if (value === null || value === undefined ||
|
||||
(typeof value === 'string' && value.trim() === '') ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === 'object' && Object.keys(value).length === 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursively process nested values
|
||||
if (typeof value === 'string') {
|
||||
result[key] = normalizeImagePlaceholders(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
result[key] = value.map(item =>
|
||||
typeof item === 'string' ? normalizeImagePlaceholders(item) : normalizePRPDocument(item)
|
||||
);
|
||||
} else if (typeof value === 'object') {
|
||||
result[key] = normalizePRPDocument(value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// For arrays, process each item
|
||||
if (Array.isArray(normalized)) {
|
||||
return normalized.map(item =>
|
||||
typeof item === 'string' ? normalizeImagePlaceholders(item) : normalizePRPDocument(item)
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value contains complex nested structures
|
||||
*/
|
||||
export function hasComplexNesting(value: any): boolean {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(item =>
|
||||
typeof item === 'object' && item !== null
|
||||
);
|
||||
}
|
||||
|
||||
return Object.values(value).some(val =>
|
||||
(typeof val === 'object' && val !== null) ||
|
||||
(Array.isArray(val) && val.some(item => typeof item === 'object'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts metadata fields from content
|
||||
*/
|
||||
export function extractMetadata(content: any): { metadata: any; sections: any } {
|
||||
if (!content || typeof content !== 'object') {
|
||||
return { metadata: {}, sections: content };
|
||||
}
|
||||
|
||||
const metadataFields = [
|
||||
'title', 'version', 'author', 'date', 'status',
|
||||
'document_type', 'created_at', 'updated_at',
|
||||
'id', '_id', 'project_id'
|
||||
];
|
||||
|
||||
const metadata: any = {};
|
||||
const sections: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
if (metadataFields.includes(key)) {
|
||||
metadata[key] = value;
|
||||
} else {
|
||||
sections[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { metadata, sections };
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import React from 'react';
|
||||
import { formatKey, formatValue } from './formatters';
|
||||
|
||||
/**
|
||||
* Renders any value in a formatted way without using JSON.stringify
|
||||
*/
|
||||
export function renderValue(value: any, depth: number = 0): React.ReactNode {
|
||||
try {
|
||||
// Prevent infinite recursion
|
||||
if (depth > 10) {
|
||||
return <span className="text-gray-500 italic">Too deeply nested</span>;
|
||||
}
|
||||
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
return <span className="text-gray-400 italic">Empty</span>;
|
||||
}
|
||||
|
||||
// Handle primitives
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return <span className="text-gray-700 dark:text-gray-300">{formatValue(value)}</span>;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-gray-400 italic">No items</span>;
|
||||
}
|
||||
|
||||
// Check if it's a simple array
|
||||
const isSimple = value.every(item =>
|
||||
typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean'
|
||||
);
|
||||
|
||||
if (isSimple) {
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{value.map((item, index) => (
|
||||
<li key={index} className="text-gray-700 dark:text-gray-300">
|
||||
{formatValue(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// Complex array
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className="pl-4 border-l-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Item {index + 1}</div>
|
||||
{renderValue(item, depth + 1)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) {
|
||||
return <span className="text-gray-400 italic">No properties</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, val]) => (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||
{formatKey(key)}:
|
||||
</span>
|
||||
<div className="pl-4">
|
||||
{renderValue(val, depth + 1)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <span className="text-gray-700 dark:text-gray-300">{String(value)}</span>;
|
||||
} catch (error) {
|
||||
console.error('Error rendering value:', error, value);
|
||||
return <span className="text-red-500 italic">Error rendering content</span>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a value inline for simple display
|
||||
*/
|
||||
export function renderValueInline(value: any): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return formatValue(value);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) return value.map(v => renderValueInline(v)).join(', ');
|
||||
if (typeof value === 'object') {
|
||||
// For objects, just show a summary
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length === 0) return 'Empty object';
|
||||
if (keys.length <= 3) return keys.map(k => `${k}: ${renderValueInline(value[k])}`).join(', ');
|
||||
return `${keys.length} properties`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { SectionType, SectionDetectorResult } from '../types/prp.types';
|
||||
|
||||
/**
|
||||
* Detects the type of a section based on its key and content structure
|
||||
*/
|
||||
export function detectSectionType(key: string, value: any): SectionDetectorResult {
|
||||
const normalizedKey = key.toLowerCase().replace(/_/g, '').replace(/\s+/g, '');
|
||||
|
||||
// Check metadata fields
|
||||
if (['title', 'version', 'author', 'date', 'status', 'documenttype'].includes(normalizedKey)) {
|
||||
return { type: 'metadata', confidence: 1.0 };
|
||||
}
|
||||
|
||||
// Check context sections (including common markdown headers)
|
||||
if (normalizedKey === 'context' || normalizedKey === 'overview' ||
|
||||
normalizedKey === 'executivesummary' || normalizedKey === 'problemstatement' ||
|
||||
normalizedKey === 'visionstatement' || normalizedKey === 'proposedsolution' ||
|
||||
normalizedKey === 'goal' || normalizedKey === 'objective' || normalizedKey === 'purpose' ||
|
||||
normalizedKey === 'why' || normalizedKey === 'rationale' || normalizedKey === 'what' ||
|
||||
normalizedKey === 'description' || normalizedKey === 'background') {
|
||||
return { type: 'context', confidence: 1.0 };
|
||||
}
|
||||
|
||||
// Check personas
|
||||
if (normalizedKey.includes('persona') || normalizedKey.includes('user') ||
|
||||
normalizedKey === 'stakeholders' || normalizedKey === 'targetaudience') {
|
||||
// Always treat these as personas, even if structure doesn't match perfectly
|
||||
return { type: 'personas', confidence: 0.9 };
|
||||
}
|
||||
|
||||
// Check flows/journeys
|
||||
if (normalizedKey.includes('flow') || normalizedKey.includes('journey') ||
|
||||
normalizedKey.includes('workflow') || normalizedKey === 'userexperience') {
|
||||
return { type: 'flows', confidence: 0.9 };
|
||||
}
|
||||
|
||||
// Check metrics (including common markdown headers)
|
||||
if (normalizedKey.includes('metric') || normalizedKey.includes('success') ||
|
||||
normalizedKey.includes('kpi') || normalizedKey === 'estimatedimpact' ||
|
||||
normalizedKey === 'successmetrics' || normalizedKey === 'successcriteria') {
|
||||
return { type: 'metrics', confidence: 0.9 };
|
||||
}
|
||||
|
||||
// Check implementation plans (including common markdown headers)
|
||||
if (normalizedKey.includes('plan') || normalizedKey.includes('phase') ||
|
||||
normalizedKey.includes('implementation') || normalizedKey.includes('roadmap') ||
|
||||
normalizedKey === 'timeline' || normalizedKey === 'rolloutplan' ||
|
||||
normalizedKey === 'migrationstrategy' || normalizedKey === 'implementationplan') {
|
||||
return { type: 'plan', confidence: 0.9 };
|
||||
}
|
||||
|
||||
// Check validation/testing (including common markdown headers)
|
||||
if (normalizedKey.includes('validation') || normalizedKey.includes('test') ||
|
||||
normalizedKey.includes('gate') || normalizedKey === 'compliance' ||
|
||||
normalizedKey.includes('quality') || normalizedKey === 'accessibilitystandards' ||
|
||||
normalizedKey === 'acceptancecriteria' || normalizedKey === 'qualitygates') {
|
||||
return { type: 'list', confidence: 0.8 };
|
||||
}
|
||||
|
||||
// Check risk assessment
|
||||
if (normalizedKey.includes('risk') || normalizedKey === 'riskassessment') {
|
||||
return { type: 'list', confidence: 0.9 };
|
||||
}
|
||||
|
||||
// Check design/architecture sections
|
||||
if (normalizedKey.includes('design') || normalizedKey.includes('architecture') ||
|
||||
normalizedKey.includes('component') || normalizedKey === 'tokensystem' ||
|
||||
normalizedKey === 'designprinciples' || normalizedKey === 'designguidelines') {
|
||||
return { type: 'object', confidence: 0.8 };
|
||||
}
|
||||
|
||||
// Check budget/resources
|
||||
if (normalizedKey.includes('budget') || normalizedKey.includes('resource') ||
|
||||
normalizedKey.includes('cost') || normalizedKey === 'team' ||
|
||||
normalizedKey === 'budgetestimate' || normalizedKey === 'budgetandresources') {
|
||||
return { type: 'keyvalue', confidence: 0.9 };
|
||||
}
|
||||
|
||||
// Check feature requirements specifically
|
||||
if (normalizedKey === 'featurerequirements' || normalizedKey === 'features' ||
|
||||
normalizedKey === 'capabilities') {
|
||||
return { type: 'features', confidence: 0.9 };
|
||||
}
|
||||
|
||||
// Check requirements
|
||||
if (normalizedKey.includes('requirement') ||
|
||||
normalizedKey === 'technicalrequirements') {
|
||||
return { type: 'object', confidence: 0.8 };
|
||||
}
|
||||
|
||||
// Check data/information sections
|
||||
if (normalizedKey.includes('data') || normalizedKey.includes('information') ||
|
||||
normalizedKey === 'currentstateanalysis' || normalizedKey === 'informationarchitecture') {
|
||||
return { type: 'object', confidence: 0.8 };
|
||||
}
|
||||
|
||||
// Check governance/process sections
|
||||
if (normalizedKey.includes('governance') || normalizedKey.includes('process') ||
|
||||
normalizedKey === 'governancemodel' || normalizedKey === 'testingstrategy') {
|
||||
return { type: 'object', confidence: 0.8 };
|
||||
}
|
||||
|
||||
// Check technical sections
|
||||
if (normalizedKey.includes('technical') || normalizedKey.includes('tech') ||
|
||||
normalizedKey === 'aimodelspecifications' || normalizedKey === 'performancerequirements' ||
|
||||
normalizedKey === 'toolingandinfrastructure' || normalizedKey === 'monitoringandanalytics') {
|
||||
return { type: 'object', confidence: 0.8 };
|
||||
}
|
||||
|
||||
// Analyze value structure
|
||||
if (Array.isArray(value)) {
|
||||
return { type: 'list', confidence: 0.7 };
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// Check if it's a simple key-value object
|
||||
if (isSimpleKeyValue(value)) {
|
||||
return { type: 'keyvalue', confidence: 0.7 };
|
||||
}
|
||||
|
||||
// Check if it's a complex nested object
|
||||
if (hasNestedObjects(value)) {
|
||||
return { type: 'object', confidence: 0.7 };
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return { type: 'generic', confidence: 0.5 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value structure matches a persona pattern
|
||||
*/
|
||||
function isPersonaStructure(value: any): boolean {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
|
||||
// Check if it's a collection of personas
|
||||
const values = Object.values(value);
|
||||
if (values.length === 0) return false;
|
||||
|
||||
// Check if first value has persona-like properties
|
||||
const firstValue = values[0];
|
||||
if (typeof firstValue !== 'object') return false;
|
||||
|
||||
const personaKeys = ['name', 'role', 'goals', 'pain_points', 'journey', 'workflow'];
|
||||
return personaKeys.some(key => key in firstValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an object is a simple key-value structure
|
||||
*/
|
||||
function isSimpleKeyValue(obj: any): boolean {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
|
||||
const values = Object.values(obj);
|
||||
return values.every(val =>
|
||||
typeof val === 'string' ||
|
||||
typeof val === 'number' ||
|
||||
typeof val === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an object has nested objects
|
||||
*/
|
||||
function hasNestedObjects(obj: any): boolean {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
|
||||
const values = Object.values(obj);
|
||||
return values.some(val =>
|
||||
typeof val === 'object' &&
|
||||
val !== null &&
|
||||
!Array.isArray(val)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a suggested icon based on section key
|
||||
*/
|
||||
export function getSectionIcon(key: string): string {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
|
||||
if (normalizedKey.includes('persona') || normalizedKey.includes('user')) return 'Users';
|
||||
if (normalizedKey.includes('flow') || normalizedKey.includes('journey')) return 'Workflow';
|
||||
if (normalizedKey.includes('metric') || normalizedKey.includes('success')) return 'BarChart3';
|
||||
if (normalizedKey.includes('plan') || normalizedKey.includes('implementation')) return 'Clock';
|
||||
if (normalizedKey.includes('context') || normalizedKey.includes('overview')) return 'Brain';
|
||||
if (normalizedKey.includes('technical') || normalizedKey.includes('tech')) return 'Code';
|
||||
if (normalizedKey.includes('validation') || normalizedKey.includes('test')) return 'Shield';
|
||||
if (normalizedKey.includes('component') || normalizedKey.includes('architecture')) return 'Layers';
|
||||
|
||||
return 'FileText';
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a section key into a human-readable title
|
||||
*/
|
||||
export function formatSectionTitle(key: string): string {
|
||||
return key
|
||||
.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user