Converted to Material UI v7 - bare.

This commit is contained in:
2026-01-31 11:52:15 +01:00
parent 57371feeb0
commit 3dd352df92
12 changed files with 1451 additions and 777 deletions

View File

@@ -71,11 +71,9 @@ The UI generates an API key at startup then load the sample data at startup and
The main components of the application are located in the `src` directory and target Node 24 LTS environment. The main components of the application are located in the `src` directory and target Node 24 LTS environment.
Framework to be used: - Material UI v7 for building the user interface.
- React for building the component logic.
- React for building the user interface.
- JavaScript (ES6+) for scripting. - JavaScript (ES6+) for scripting.
- Bootstrap for styling and layout.
- Express.js for serving the application and handling API requests. - Express.js for serving the application and handling API requests.
### API ### API

View File

@@ -4,7 +4,7 @@ A React-based web application for testing and validating JMESPath expressions ag
![JMESPath Testing Tool](https://img.shields.io/badge/React-18.2.0-blue?style=flat-square&logo=react) ![JMESPath Testing Tool](https://img.shields.io/badge/React-18.2.0-blue?style=flat-square&logo=react)
![Node.js](https://img.shields.io/badge/Node.js-24%20LTS-green?style=flat-square&logo=node.js) ![Node.js](https://img.shields.io/badge/Node.js-24%20LTS-green?style=flat-square&logo=node.js)
![Bootstrap](https://img.shields.io/badge/Bootstrap-5.3.2-purple?style=flat-square&logo=bootstrap) ![Material UI](https://img.shields.io/badge/Material--UI-v7-007FFF?style=flat-square&logo=mui)
## Features ## Features
@@ -127,7 +127,7 @@ MAX_SESSIONS=200 MAX_SAMPLE_SIZE=2097152 LISTEN_PORT=8080 node server.js
## Technology Stack ## Technology Stack
- **React 18.2.0**: Frontend framework with modern hooks and components - **React 18.2.0**: Frontend framework with modern hooks and components
- **Bootstrap 5.3.2**: CSS framework with dark/light theme support - **Material UI v7**: Modern React component library following Material 3 Design principles.
- **JMESPath 0.16.0**: JMESPath expression evaluation library - **JMESPath 0.16.0**: JMESPath expression evaluation library
- **Express.js 4.19.2**: Backend API server with session management - **Express.js 4.19.2**: Backend API server with session management
- **Node.js 24 LTS**: Runtime environment - **Node.js 24 LTS**: Runtime environment

763
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,10 +17,13 @@
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@testing-library/jest-dom": "^6.1.4", "@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"bootstrap": "^5.3.2",
"express": "^4.19.2", "express": "^4.19.2",
"jmespath": "^0.16.0", "jmespath": "^0.16.0",
"react": "^18.2.0", "react": "^18.2.0",

View File

@@ -1,318 +1,46 @@
/* JMESPath Testing Tool Custom Styles */ /* JMESPath Testing Tool - Minimal Styles */
:root { :root {
/* Common variables */ --font-sans: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
--font-mono: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace; "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
--accent-color: #007bff; "Helvetica Neue", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", "Noto Sans Mono", "Consolas", "Monaco", "Courier New", monospace;
/* Brand colors */
--brand-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--brand-white: #ffffff;
--brand-dark: #212529;
--brand-warning: #ffc107;
/* Brand opacity levels */
--brand-white-60: rgba(255, 255, 255, 0.6);
--brand-white-10: rgba(255, 255, 255, 0.1);
--brand-warning-50: rgba(255, 193, 7, 0.5);
--brand-warning-10: rgba(255, 193, 7, 0.1);
/* Elevation and overlays */
--shadow-light: rgba(0, 0, 0, 0.1);
--focus-ring: rgba(0, 123, 255, 0.25);
/* Button variants */
--btn-success: #28a745;
--btn-info: #17a2b8;
--btn-primary: #007bff;
--btn-danger: #dc3545;
--btn-secondary: #6c757d;
/* Common transitions */
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
/* Font families */
--font-sans: 'Noto Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'Noto Sans Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
} }
/* Base font family */
body { body {
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
transition: background-color var(--transition-normal), color var(--transition-normal); margin: 0;
}
/* Layout structure */
.vh-100 {
height: 100vh;
}
/* Header section styling - more compact */
.header-section {
background: var(--brand-gradient);
color: var(--brand-white);
padding: 1.2rem 0;
margin-bottom: 1rem;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
} }
.header-section h2 { #root {
color: var(--brand-white); display: flex;
flex-direction: column;
min-height: 100vh;
} }
/* Ensure buttons in header are clearly visible against gradient */ /* Scrollbar styling for a cleaner look */
.header-section .btn-light.active { ::-webkit-scrollbar {
background-color: var(--brand-white); width: 8px;
color: var(--brand-dark) !important; /* Deep dark text for selected states */ height: 8px;
border-color: var(--brand-white);
} }
.header-section .btn-outline-light { ::-webkit-scrollbar-track {
color: var(--brand-white); background: transparent;
border-color: var(--brand-white-60);
} }
.header-section .btn-outline-light:hover { ::-webkit-scrollbar-thumb {
background-color: var(--brand-white-10); background: rgba(0, 0, 0, 0.1);
color: var(--brand-white); border-radius: 4px;
} }
.header-section .btn-outline-warning { [data-mui-color-scheme="dark"] ::-webkit-scrollbar-thumb {
color: var(--brand-warning); background: rgba(255, 255, 255, 0.1);
border-color: var(--brand-warning-50);
} }
.header-section .btn-outline-warning:hover { ::-webkit-scrollbar-thumb:hover {
background-color: var(--brand-warning-10); background: rgba(0, 0, 0, 0.2);
color: var(--brand-warning);
}
/* Custom card styling */
.card {
border: none;
box-shadow: 0 2px 8px var(--shadow-light);
border-radius: 8px;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.card-header {
background-color: var(--bg-secondary);
border-bottom: 2px solid var(--border);
font-weight: 600;
color: var(--text-primary);
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
/* Input and textarea styling */
.jmespath-input, .json-input, .result-output {
font-family: var(--font-mono);
font-weight: 400;
transition: background-color var(--transition-normal), border-color var(--transition-normal), color var(--transition-normal);
}
.jmespath-input {
font-size: 14px;
padding: 10px;
}
.json-input, .result-output {
font-size: 13px;
line-height: 1.4;
}
/* Button styling */
.btn {
transition: all var(--transition-fast);
}
.btn:hover {
transform: translateY(-1px);
}
/* Footer styling */
footer {
flex-shrink: 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.header-section {
padding: 1.5rem 0 !important;
}
.display-4 {
font-size: 2rem;
}
.lead {
font-size: 1rem;
}
.btn-sm {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
}
.card-body textarea {
min-height: 300px !important;
}
}
/* Bootstrap theme integration */
[data-bs-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #6c757d;
--border: #dee2e6;
--border-input: #ced4da;
--success-bg: #d4edda;
--success-border: #c3e6cb;
--success-text: #155724;
--error-bg: #f8d7da;
--error-border: #f5c6cb;
--error-text: #721c24;
}
[data-bs-theme="dark"] {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-card: #323232;
--text-primary: #ffffff;
--text-secondary: #e9ecef;
--text-muted: #adb5bd;
--border: #495057;
--border-input: #6c757d;
--success-bg: #1e4a1e;
--success-border: #2c6d2c;
--success-text: #d4edda;
--error-bg: #4a1e1e;
--error-border: #6d2c2c;
--error-text: #f8d7da;
}
/* Apply theme colors */
body {
background-color: var(--bg-primary);
color: var(--text-secondary);
}
.card {
background-color: var(--bg-primary);
border-color: var(--border);
color: var(--text-primary);
}
.card-header {
background-color: var(--bg-secondary);
border-bottom-color: var(--border);
color: var(--text-primary);
}
.jmespath-input {
background-color: var(--bg-primary);
border-color: var(--border-input);
color: var(--text-secondary);
}
.json-input, .result-output {
background-color: var(--bg-secondary);
border-color: var(--border);
color: var(--text-secondary);
}
footer {
background-color: var(--bg-secondary);
color: var(--text-secondary);
}
footer.bg-light {
background-color: var(--bg-secondary) !important;
}
footer a {
color: var(--text-muted);
}
footer a:hover {
color: var(--text-secondary);
}
/* State styles */
.jmespath-input.success {
background-color: var(--success-bg) !important;
border-color: var(--success-border) !important;
color: var(--success-text) !important;
}
.jmespath-input.error {
background-color: var(--error-bg) !important;
border-color: var(--error-border) !important;
color: var(--error-text) !important;
}
.json-input.success {
background-color: var(--success-bg) !important;
border-color: var(--success-border) !important;
color: var(--success-text) !important;
}
.json-input.error {
background-color: var(--error-bg) !important;
border-color: var(--error-border) !important;
color: var(--error-text) !important;
}
/* Focus states */
.jmespath-input:focus {
border-color: var(--accent-color, #007bff);
box-shadow: 0 0 0 0.2rem var(--focus-ring);
}
.json-input:focus,
.result-output:focus {
background-color: var(--bg-primary);
border-color: var(--accent-color, #007bff);
color: var(--text-secondary);
box-shadow: 0 0 0 0.2rem var(--focus-ring);
}
/* Placeholder colors */
.jmespath-input::placeholder,
.json-input::placeholder,
.result-output::placeholder {
color: var(--text-muted);
}
/* Alert styles */
.alert-danger {
background-color: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.alert-success {
background-color: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
/* Code block styles */
pre.bg-light {
background-color: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
border-color: var(--border) !important;
}
code {
color: var(--text-secondary);
} }

View File

@@ -1,4 +1,12 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import {
ThemeProvider,
createTheme,
CssBaseline,
Box,
Container,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import Header from "./components/Header"; import Header from "./components/Header";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import MainPage from "./components/MainPage"; import MainPage from "./components/MainPage";
@@ -65,39 +73,22 @@ function App() {
return newKey; return newKey;
}); });
// Theme management const getApiHeaders = () => ({
useEffect(() => { "Accept": "application/json",
const applyTheme = (selectedTheme) => { "x-api-key": apiKey,
const effectiveTheme = });
selectedTheme === "auto"
? window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: selectedTheme;
document.documentElement.setAttribute("data-bs-theme", effectiveTheme); const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
};
applyTheme(theme);
// Save theme preference
localStorage.setItem("theme", theme);
}, [theme]);
// Shell type management
useEffect(() => {
localStorage.setItem("jmespath-shell-type", shellType);
}, [shellType]);
// Get headers for API requests
const getApiHeaders = () => {
return {
Accept: "application/json",
"X-API-Key": apiKey,
};
};
const muiTheme = useMemo(() => {
return createTheme({
cssVariables: true,
colorSchemes: {
light: true,
dark: true,
},
});
}, []);
// Load sample data from API on startup and setup periodic state checking // Load sample data from API on startup and setup periodic state checking
useEffect(() => { useEffect(() => {
loadSampleData(); loadSampleData();
@@ -172,41 +163,56 @@ function App() {
}; };
return ( return (
<div className="vh-100 d-flex flex-column"> <ThemeProvider theme={muiTheme}>
<Header <CssBaseline />
theme={theme} <Box
onThemeChange={handleThemeChange} sx={{
currentPage={currentPage} display: "flex",
onPageChange={handlePageChange} flexDirection: "column",
/> minHeight: "100vh",
bgcolor: "background.default",
{/* Main Content Section - flex-grow to fill space */} }}
<div
className="container-fluid flex-grow-1 d-flex flex-column"
style={{ minHeight: 0 }}
> >
{currentPage === "main" ? ( <Header
<MainPage theme={theme}
apiKey={apiKey} onThemeChange={handleThemeChange}
showReloadButton={showReloadButton} currentPage={currentPage}
onReloadSampleData={loadSampleData} onPageChange={handlePageChange}
jmespathExpression={jmespathExpression} />
setJmespathExpression={setJmespathExpression}
jsonData={jsonData}
setJsonData={setJsonData}
/>
) : (
<ApiKeyPage
apiKey={apiKey}
onRegenerateApiKey={regenerateApiKey}
shellType={shellType}
onShellTypeChange={setShellType}
/>
)}
</div>
<Footer /> {/* Main Content Section - flex-grow to fill space */}
</div> <Box
component="main"
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
>
{currentPage === "main" ? (
<MainPage
apiKey={apiKey}
showReloadButton={showReloadButton}
onReloadSampleData={loadSampleData}
jmespathExpression={jmespathExpression}
setJmespathExpression={setJmespathExpression}
jsonData={jsonData}
setJsonData={setJsonData}
/>
) : (
<ApiKeyPage
apiKey={apiKey}
onRegenerateApiKey={regenerateApiKey}
shellType={shellType}
onShellTypeChange={setShellType}
/>
)}
</Box>
<Footer />
</Box>
</ThemeProvider>
); );
} }

View File

@@ -49,7 +49,7 @@ describe('App Component', () => {
describe('Basic Rendering', () => { describe('Basic Rendering', () => {
test('renders JMESPath Testing Tool title', () => { test('renders JMESPath Testing Tool title', () => {
render(<App />); render(<App />);
const titleElement = screen.getByRole('heading', { name: /JMESPath Testing Tool/i }); const titleElement = screen.getByText(/JMESPath Playground/i);
expect(titleElement).toBeInTheDocument(); expect(titleElement).toBeInTheDocument();
}); });
@@ -90,11 +90,11 @@ describe('App Component', () => {
test('renders all toolbar buttons', () => { test('renders all toolbar buttons', () => {
render(<App />); render(<App />);
expect(screen.getByTitle('Load JSON object from file')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Load from Disk/i })).toBeInTheDocument();
expect(screen.getByTitle('Load JSON Lines log file')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Load Logs/i })).toBeInTheDocument();
expect(screen.getByTitle('Load sample data')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Load Sample/i })).toBeInTheDocument();
expect(screen.getByTitle('Format JSON')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Format/i })).toBeInTheDocument();
expect(screen.getByTitle('Clear all inputs')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Clear all inputs/i })).toBeInTheDocument();
}); });
}); });
@@ -108,7 +108,7 @@ describe('App Component', () => {
const resultArea = screen.getByPlaceholderText(/Results will appear here/i); const resultArea = screen.getByPlaceholderText(/Results will appear here/i);
// Clear all inputs first to start fresh // Clear all inputs first to start fresh
const clearButton = screen.getByTitle('Clear all inputs'); const clearButton = screen.getByRole('button', { name: /Clear all inputs/i });
await user.click(clearButton); await user.click(clearButton);
// Set JSON data directly after clearing // Set JSON data directly after clearing
@@ -153,7 +153,7 @@ describe('App Component', () => {
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i); const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
// Clear all inputs first // Clear all inputs first
const clearButton = screen.getByTitle('Clear all inputs'); const clearButton = screen.getByRole('button', { name: /Clear all inputs/i });
await user.click(clearButton); await user.click(clearButton);
// Set invalid JSON directly // Set invalid JSON directly
@@ -187,31 +187,55 @@ describe('App Component', () => {
test('renders theme switcher buttons', () => { test('renders theme switcher buttons', () => {
render(<App />); render(<App />);
expect(screen.getByTitle('Auto (follow system)')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Auto/i })).toBeInTheDocument();
expect(screen.getByTitle('Light theme')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Light/i })).toBeInTheDocument();
expect(screen.getByTitle('Dark theme')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Dark/i })).toBeInTheDocument();
}); });
test('switches to light theme when clicked', async () => { test('switches to light theme when clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
const lightButton = screen.getByTitle('Light theme'); const lightButton = screen.getByRole('button', { name: /Light/i });
await user.click(lightButton); await user.click(lightButton);
// Check if button becomes active // Check if button becomes active
expect(lightButton).toHaveClass('btn-primary'); expect(lightButton).toHaveClass('Mui-selected');
}); });
test('switches to dark theme when clicked', async () => { test('switches to dark theme when clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
const darkButton = screen.getByTitle('Dark theme'); const darkButton = screen.getByRole('button', { name: /Dark/i });
await user.click(darkButton); await user.click(darkButton);
// Check if button becomes active // Check if button becomes active
expect(darkButton).toHaveClass('btn-primary'); expect(darkButton).toHaveClass('Mui-selected');
});
});
describe('Navigation', () => {
test('switches to API Keys page and back', async () => {
const user = userEvent.setup();
render(<App />);
// Find and click API Keys button in Header
// MUI Tooltip might set aria-label to title "API Key Management"
const apiKeyButton = screen.getByRole('button', { name: /API Key Management/i });
await user.click(apiKeyButton);
// Check if API Key Management title is visible
expect(screen.getByText(/API Key Management/i)).toBeInTheDocument();
expect(screen.getByText(/YOUR API KEY/i)).toBeInTheDocument();
// Find and click Home button to go back
// MUI Tooltip title "Back to Testing" becomes the accessible name
const homeButton = screen.getByRole('button', { name: /Back to Testing/i });
await user.click(homeButton);
// Check if we are back on main page
expect(screen.getByRole('heading', { name: /JMESPath Expression/i })).toBeInTheDocument();
}); });
}); });
@@ -222,7 +246,7 @@ describe('App Component', () => {
const jmespathInput = screen.getByPlaceholderText(/Enter JMESPath expression/i); const jmespathInput = screen.getByPlaceholderText(/Enter JMESPath expression/i);
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i); const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
const clearButton = screen.getByTitle('Clear all inputs'); const clearButton = screen.getByRole('button', { name: /Clear all inputs/i });
// Add some content // Add some content
await user.type(jmespathInput, 'test.expression'); await user.type(jmespathInput, 'test.expression');
@@ -241,7 +265,7 @@ describe('App Component', () => {
render(<App />); render(<App />);
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i); const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
const formatButton = screen.getByTitle('Format JSON'); const formatButton = screen.getByRole('button', { name: "Format" });
// Add minified JSON directly // Add minified JSON directly
fireEvent.change(jsonInput, { target: { value: '{"name":"Alice","age":30,"skills":["React","Node"]}' } }); fireEvent.change(jsonInput, { target: { value: '{"name":"Alice","age":30,"skills":["React","Node"]}' } });
@@ -260,7 +284,7 @@ describe('App Component', () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
const loadSampleButton = screen.getByTitle('Load sample data'); const loadSampleButton = screen.getByRole('button', { name: "Load Sample" });
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i); const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
const jmespathInput = screen.getByPlaceholderText(/Enter JMESPath expression/i); const jmespathInput = screen.getByPlaceholderText(/Enter JMESPath expression/i);
@@ -331,7 +355,7 @@ describe('App Component', () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
const loadObjectButton = screen.getByTitle('Load JSON object from file'); const loadObjectButton = screen.getByRole('button', { name: "Load from Disk" });
// Create a mock file // Create a mock file
const file = new File(['{"test": "file data"}'], 'test.json', { const file = new File(['{"test": "file data"}'], 'test.json', {

View File

@@ -1,4 +1,23 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import {
Box,
Typography,
Paper,
TextField,
Button,
Grid,
Tooltip,
IconButton,
ToggleButtonGroup,
ToggleButton,
Divider,
} from "@mui/material";
import {
ContentCopy as ContentCopyIcon,
Autorenew as AutorenewIcon,
Check as CheckIcon,
Key as KeyIcon,
} from "@mui/icons-material";
function CodeBlock({ code }) { function CodeBlock({ code }) {
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false);
@@ -9,28 +28,57 @@ function CodeBlock({ code }) {
setCopySuccess(true); setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000); setTimeout(() => setCopySuccess(false), 2000);
} catch (err) { } catch (err) {
console.error('Failed to copy to clipboard:', err); console.error("Failed to copy to clipboard:", err);
} }
}; };
return ( return (
<div className="position-relative"> <Box sx={{ position: "relative", my: 2 }}>
<pre className="bg-light p-3 pe-5 rounded border shadow-sm"> <Paper
<code className="d-block" style={{ whiteSpace: 'pre-wrap' }}>{code}</code> variant="outlined"
</pre> sx={{
<button p: 2,
className={`btn btn-sm ${copySuccess ? 'btn-success' : 'btn-outline-secondary'} position-absolute top-0 end-0 m-2`} pr: 6,
onClick={handleCopy} bgcolor: "action.hover",
title="Copy code to clipboard" fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
style={{ opacity: 0.8 }} fontSize: "0.85rem",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
position: "relative",
borderRadius: 2,
borderColor: "divider",
}}
> >
{copySuccess ? '✓' : '📋'} <code>{code}</code>
</button> <Tooltip title={copySuccess ? "Copied!" : "Copy code"}>
</div> <IconButton
size="small"
onClick={handleCopy}
sx={{
position: "absolute",
top: 8,
right: 8,
color: copySuccess ? "success.main" : "primary.main",
}}
>
{copySuccess ? (
<CheckIcon fontSize="small" />
) : (
<ContentCopyIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
</Paper>
</Box>
); );
} }
function ApiKeyPage({ apiKey, onRegenerateApiKey, shellType, onShellTypeChange }) { function ApiKeyPage({
apiKey,
onRegenerateApiKey,
shellType,
onShellTypeChange,
}) {
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false);
const handleCopyToClipboard = async () => { const handleCopyToClipboard = async () => {
@@ -39,107 +87,117 @@ function ApiKeyPage({ apiKey, onRegenerateApiKey, shellType, onShellTypeChange }
setCopySuccess(true); setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000); setTimeout(() => setCopySuccess(false), 2000);
} catch (err) { } catch (err) {
console.error('Failed to copy to clipboard:', err); console.error("Failed to copy to clipboard:", err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = apiKey;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} }
}; };
return ( return (
<div className="row justify-content-center"> <Box component="main" sx={{ flexGrow: 1, py: 4, px: 2 }}>
<div className="col-md-8"> <Grid container justifyContent="center">
<div className="card"> <Grid size={{ xs: 12, md: 8, lg: 6 }}>
<div className="card-header"> <Paper elevation={1} sx={{ p: { xs: 3, md: 5 }, borderRadius: 4, bgcolor: "background.paper", border: 1, borderColor: "divider" }}>
<h5 className="mb-0">🔐 API Key Management</h5> <Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 700, display: "flex", alignItems: "center", gap: 1.5, color: "text.primary" }}>
</div> <KeyIcon color="primary" /> API Key Management
<div className="card-body"> </Typography>
<div className="mb-4">
<label className="form-label fw-bold">Your API Key:</label> <Box sx={{ mb: 6 }}>
<div className="input-group"> <Typography variant="subtitle2" gutterBottom color="text.secondary">
<input YOUR API KEY
type="text" </Typography>
className="form-control font-monospace" <Box sx={{ display: "flex", gap: 1.5, alignItems: "center" }}>
value={apiKey} <TextField
readOnly fullWidth
/> value={apiKey}
<button slotProps={{
className={`btn ${copySuccess ? 'btn-success' : 'btn-outline-secondary'}`} input: {
onClick={handleCopyToClipboard} readOnly: true,
title="Copy API key to clipboard" style: { fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "0.9rem" },
> },
{copySuccess ? '✓ Copied!' : '📋 Copy'} }}
</button> variant="outlined"
<button sx={{ "& .MuiOutlinedInput-root": { borderRadius: 4, bgcolor: "background.paper" } }}
className="btn btn-outline-primary" />
onClick={onRegenerateApiKey} <Tooltip title="Copy API Key">
title="Generate new API key" <IconButton
> onClick={handleCopyToClipboard}
🔄 Regenerate color={copySuccess ? "success" : "primary"}
</button> size="medium"
</div> sx={{ border: 1, borderColor: "divider" }}
<div className="form-text"> >
This API key is used to encrypt and authenticate data uploads. {copySuccess ? <CheckIcon /> : <ContentCopyIcon />}
</div> </IconButton>
</div> </Tooltip>
<Tooltip title="Regenerate Key">
<IconButton
onClick={onRegenerateApiKey}
color="primary"
size="medium"
sx={{ border: 1, borderColor: "divider" }}
>
<AutorenewIcon />
</IconButton>
</Tooltip>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5, display: "block" }}>
This key is stored locally in your browser. Use it to authenticate remote data uploads.
</Typography>
</Box>
<Divider sx={{ my: 4, borderColor: "divider" }} />
<Box sx={{ mb: 4 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
>
<Typography variant="h6" fontWeight="600" color="text.primary">📡 Remote Upload API</Typography>
<ToggleButtonGroup
size="small"
value={shellType}
exclusive
onChange={(e, value) => value && onShellTypeChange(value)}
aria-label="shell type"
>
<ToggleButton value="bash">UNIX (Bash)</ToggleButton>
<ToggleButton value="powershell">Windows (PS)</ToggleButton>
</ToggleButtonGroup>
</Box>
<Typography variant="body2" color="text.secondary" paragraph>
Use this endpoint to upload data from external scripts. Set these environment variables:
</Typography>
<div className="mb-4">
<div className="d-flex justify-content-between align-items-center mb-2">
<h6 className="mb-0">📡 Remote Data Upload API</h6>
<div className="btn-group btn-group-sm" role="group" aria-label="Shell type selector">
<input
type="radio"
className="btn-check"
name="shellType"
id="shellBash"
autoComplete="off"
checked={shellType === 'bash'}
onChange={() => onShellTypeChange('bash')}
/>
<label className="btn btn-outline-secondary" htmlFor="shellBash">UNIX (Bash)</label>
<input
type="radio"
className="btn-check"
name="shellType"
id="shellPowerShell"
autoComplete="off"
checked={shellType === 'powershell'}
onChange={() => onShellTypeChange('powershell')}
/>
<label className="btn btn-outline-secondary" htmlFor="shellPowerShell">Windows (PowerShell)</label>
</div>
</div>
<p className="text-muted">
External tools can upload sample data remotely using the REST API.
The API key is required for authentication. Define two
environment variables in your {shellType === 'bash' ? <code>.bashrc</code> : <code>PowerShell profile</code>}.
</p>
<CodeBlock <CodeBlock
code={shellType === 'bash' code={
? `export JMESPATH_PLAYGROUND_API_URL=${window.location.origin}\nexport JMESPATH_PLAYGROUND_API_KEY=${apiKey}` shellType === "bash"
: `$env:JMESPATH_PLAYGROUND_API_URL = "${window.location.origin}"\n$env:JMESPATH_PLAYGROUND_API_KEY = "${apiKey}"`} ? `export JMESPATH_PLAYGROUND_API_URL="${window.location.origin}"\nexport JMESPATH_PLAYGROUND_API_KEY="${apiKey}"`
: `$env:JMESPATH_PLAYGROUND_API_URL = "${window.location.origin}"\n$env:JMESPATH_PLAYGROUND_API_KEY = "${apiKey}"`
}
/> />
<p className="text-muted">Then, use the following {shellType === 'bash' ? <code>curl</code> : <code>PowerShell</code>} command to upload your data:</p>
<CodeBlock <CodeBlock
code={shellType === 'bash' code={
? `curl -s -X POST \\\n -H "Content-Type: application/json" \\\n -H "Accept: application/json" \\\n -H "X-API-Key: $JMESPATH_PLAYGROUND_API_KEY" \\\n --data @__JSON_FILE_NAME__ \\\n "$JMESPATH_PLAYGROUND_API_URL/api/v1/upload"` shellType === "bash"
: `Invoke-RestMethod -Uri "$env:JMESPATH_PLAYGROUND_API_URL/api/v1/upload" \\\n -Method Post \\\n -ContentType "application/json" \\\n -Headers @{\n "X-API-Key" = $env:JMESPATH_PLAYGROUND_API_KEY\n "Accept" = "application/json"\n } \\\n -InFile __JSON_FILE_NAME__`} ? `curl -X POST "$JMESPATH_PLAYGROUND_API_URL/api/v1/upload" \\
-H "Accept: application/json" \\
-H "x-api-key: $JMESPATH_PLAYGROUND_API_KEY" \\
-d '{ "users": [ { "id": 1, "name": "Remote User" } ] }'`
: `Invoke-RestMethod -Method Post -Uri "$env:JMESPATH_PLAYGROUND_API_URL/api/v1/upload" \`
-Headers @{ "Accept" = "application/json"; "x-api-key" = $env:JMESPATH_PLAYGROUND_API_KEY } \`
-Body '{ "users": [ { "id": 1, "name": "Remote User" } ] }'`
}
/> />
<div className="form-text"> </Box>
Replace <code>{"__JSON_FILE_NAME__"}</code> with the path to your </Paper>
JSON file containing the sample data. {shellType === 'bash' && <span>or use <code>-</code> to read from standard input.</span>} </Grid>
</div> </Grid>
</div> </Box>
</div>
</div>
</div>
</div>
); );
} }

View File

@@ -1,27 +1,58 @@
import React from 'react'; import React from "react";
import { VERSION } from '../version'; import { Box, Typography, Container, Link, Grid } from "@mui/material";
import { VERSION } from "../version";
function Footer() { function Footer() {
return ( return (
<footer className="bg-light border-top mt-2 py-2 flex-shrink-0"> <Box
<div className="container"> component="footer"
<div className="row"> sx={{
<div className="col-md-6"> py: 2,
<p className="mb-0 text-muted small"> mt: 2,
<strong>JMESPath Testing Tool</strong> {VERSION === 'unknown' ? VERSION : `v${VERSION}`} - Created for testing and validating JMESPath expressions borderTop: 1,
</p> borderColor: "divider",
</div> bgcolor: "background.paper",
<div className="col-md-6 text-md-end"> flexShrink: 0,
<p className="mb-0 text-muted small"> }}
Licensed under <a href="https://opensource.org/licenses/MIT" target="_blank" rel="noopener noreferrer" className="text-decoration-none">MIT License</a> | >
<a href="https://jmespath.org/" target="_blank" rel="noopener noreferrer" className="text-decoration-none ms-2"> <Container maxWidth="xl">
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2" color="text.secondary">
<strong>JMESPath Testing Tool</strong>{" "}
{VERSION === "unknown" ? VERSION : `v${VERSION}`} - Created for
testing and validating JMESPath expressions
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }} sx={{ textAlign: { md: "right" } }}>
<Typography variant="body2" color="text.secondary">
Licensed under{" "}
<Link
href="https://opensource.org/licenses/MIT"
target="_blank"
rel="noopener noreferrer"
color="primary"
underline="hover"
sx={{ fontWeight: 500 }}
>
MIT License
</Link>{" "}
|{" "}
<Link
href="https://jmespath.org/"
target="_blank"
rel="noopener noreferrer"
color="primary"
underline="hover"
sx={{ ml: 1, fontWeight: 500 }}
>
Learn JMESPath Learn JMESPath
</a> </Link>
</p> </Typography>
</div> </Grid>
</div> </Grid>
</div> </Container>
</footer> </Box>
); );
} }

View File

@@ -1,71 +1,88 @@
import React from 'react'; import React from "react";
import {
Box,
Typography,
Button,
ToggleButton,
ToggleButtonGroup,
Tooltip,
AppBar,
Toolbar,
Container,
Divider,
} from "@mui/material";
import KeyIcon from "@mui/icons-material/Key";
import HomeIcon from "@mui/icons-material/Home";
import BrightnessAutoIcon from "@mui/icons-material/BrightnessAuto";
import LightModeIcon from "@mui/icons-material/LightMode";
import DarkModeIcon from "@mui/icons-material/DarkMode";
function Header({ theme, onThemeChange, currentPage, onPageChange }) { function Header({ theme, onThemeChange, currentPage, onPageChange }) {
return ( return (
<div className="header-section"> <AppBar position="static" color="default" elevation={1} sx={{ borderBottom: 1, borderColor: "divider" }}>
<div className="container-fluid px-4"> <Container maxWidth="xl">
<div className="row align-items-center"> <Toolbar disableGutters sx={{ display: "flex", justifyContent: "space-between", height: 64 }}>
<div className="col-12 text-center position-relative"> {/* Brand/Title */}
<h2 className="mb-1">JMESPath Testing Tool</h2> <Box sx={{ display: "flex", alignItems: "center" }}>
{/* Right side controls - better positioning */} <Typography
<div className="position-absolute top-50 end-0 translate-middle-y d-flex align-items-center gap-2 me-4"> variant="h6"
{/* API Key Management Button - more prominent */} noWrap
<button component="div"
type="button" sx={{
className={`btn btn-sm ${ fontWeight: 700,
currentPage === 'apikey' color: "primary.main",
? 'btn-warning fw-bold text-dark' letterSpacing: ".05rem",
: 'btn-outline-warning' }}
}`} >
onClick={() => onPageChange(currentPage === 'main' ? 'apikey' : 'main')} JMESPath Playground
title="API Key Management" </Typography>
</Box>
{/* Right side controls */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{/* API Key Management Button */}
<Tooltip title={currentPage === "main" ? "API Key Management" : "Back to Testing"}>
<Button
variant={currentPage === "apikey" ? "contained" : "text"}
color={currentPage === "apikey" ? "primary" : "primary"}
size="medium"
startIcon={currentPage === "main" ? <KeyIcon /> : <HomeIcon />}
onClick={() => onPageChange(currentPage === "main" ? "apikey" : "main")}
> >
🔐 API Keys {currentPage === "main" ? "API Keys" : "Home"}
</button> </Button>
{/* Theme switcher with theme-aware classes */} </Tooltip>
<div className="btn-group btn-group-sm" role="group" aria-label="Theme switcher">
<button <Divider orientation="vertical" flexItem sx={{ my: 2, mx: 1 }} />
type="button"
className={`btn ${ {/* Theme switcher */}
theme === 'auto' <ToggleButtonGroup
? 'btn-light active' value={theme}
: 'btn-outline-light' exclusive
}`} onChange={(e, nextTheme) => nextTheme && onThemeChange(nextTheme)}
onClick={() => onThemeChange('auto')} aria-label="theme switcher"
title="Auto (follow system)" size="small"
> >
🌓 Auto <Tooltip title="Follow system theme">
</button> <ToggleButton value="auto" aria-label="Auto">
<button <BrightnessAutoIcon sx={{ fontSize: "1.2rem" }} />
type="button" </ToggleButton>
className={`btn ${ </Tooltip>
theme === 'light' <Tooltip title="Light mode">
? 'btn-light active' <ToggleButton value="light" aria-label="Light">
: 'btn-outline-light' <LightModeIcon sx={{ fontSize: "1.2rem" }} />
}`} </ToggleButton>
onClick={() => onThemeChange('light')} </Tooltip>
title="Light theme" <Tooltip title="Dark mode">
> <ToggleButton value="dark" aria-label="Dark">
Light <DarkModeIcon sx={{ fontSize: "1.2rem" }} />
</button> </ToggleButton>
<button </Tooltip>
type="button" </ToggleButtonGroup>
className={`btn ${ </Box>
theme === 'dark' </Toolbar>
? 'btn-light active' </Container>
: 'btn-outline-light' </AppBar>
}`}
onClick={() => onThemeChange('dark')}
title="Dark theme"
>
🌙 Dark
</button>
</div>
</div>
</div>
</div>
</div>
</div>
); );
} }

View File

@@ -1,4 +1,31 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import {
Box,
Typography,
Paper,
TextField,
Button,
Grid,
Tooltip,
IconButton,
Alert,
Stack,
Divider,
} from "@mui/material";
import {
Search as SearchIcon,
DataObject as DataObjectIcon,
Output as OutputIcon,
UploadFile as UploadFileIcon,
FileOpen as FileOpenIcon,
Restore as RestoreIcon,
FormatAlignLeft as FormatAlignLeftIcon,
Clear as ClearIcon,
ContentCopy as ContentCopyIcon,
Download as DownloadIcon,
Check as CheckIcon,
Refresh as RefreshIcon,
} from "@mui/icons-material";
import jmespath from "jmespath"; import jmespath from "jmespath";
function MainPage({ function MainPage({
@@ -160,173 +187,275 @@ function MainPage({
}; };
return ( return (
<> <Box component="main" sx={{ flexGrow: 1, py: 3, px: { xs: 2, md: 4 } }}>
{/* Description paragraph */} <Box sx={{ mb: 4, maxWidth: 800, mx: "auto" }}>
<div className="row mb-2"> <Typography
<div className="col-12"> variant="body1"
<p className="text-muted text-center mb-2 small"> color="text.secondary"
Validate and test JMESPath expressions against JSON data in align="center"
real-time. Enter your JMESPath query and JSON data below to see the sx={{ mb: 3 }}
results instantly. >
</p> Validate and test JMESPath expressions against JSON data in real-time.
</div> Enter your JMESPath query and JSON data below to see the results
</div> instantly.
</Typography>
</Box>
{/* Middle Section: JMESPath Expression Input */} <Paper
<div className="row mb-2"> sx={{
<div className="col-12"> mb: 4,
<div className="card"> p: 0,
<div className="card-header py-2"> borderRadius: 2,
<h6 className="mb-0"> overflow: "hidden",
<i className="bi bi-search me-2"></i> bgcolor: "background.paper",
JMESPath Expression }}
</h6> >
</div> <Box
<div className="card-body"> sx={{
<input px: 3,
type="text" py: 1.5,
className={`form-control jmespath-input ${error ? "error" : "success"}`} display: "flex",
value={jmespathExpression} alignItems: "center",
onChange={handleJmespathChange} gap: 1.5,
placeholder="Enter JMESPath expression (e.g., people[*].name)" bgcolor: "action.hover",
/> }}
<div >
className={`alert mt-2 mb-0 d-flex justify-content-between align-items-center ${error ? "alert-danger" : "alert-success"}`} <SearchIcon sx={{ fontSize: 24 }} color="primary" />
<Typography variant="subtitle1" fontWeight="600">
JMESPath Expression
</Typography>
</Box>
<Box sx={{ px: 3, pb: 2 }}>
<TextField
fullWidth
placeholder="Enter JMESPath expression (e.g., people[*].name)"
value={jmespathExpression}
onChange={handleJmespathChange}
error={!!error}
helperText={error || " "}
FormHelperTextProps={{
sx: { color: error ? "error.main" : "success.main", fontWeight: 500 }
}}
slotProps={{
input: {
style: { fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "1rem" },
},
}}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 3,
bgcolor: "background.paper",
}
}}
/>
{showReloadButton && (
<Box sx={{ display: "flex", justifyContent: "center", mt: -1, mb: 2 }}>
<Button
variant="contained"
color="secondary"
onClick={onReloadSampleData}
startIcon={<RefreshIcon />}
size="small"
> >
<small className="mb-0"> New data available - Reload
{error || "Expression is correct"} </Button>
</small> </Box>
{showReloadButton && ( )}
<button </Box>
className="btn btn-light btn-sm ms-2 border" </Paper>
onClick={() => {
onReloadSampleData();
}}
title="New sample data is available"
>
<i className="bi bi-arrow-clockwise me-1"></i>
Reload Sample Data
</button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Lower Middle Section: Input and Output Areas */} <Grid container spacing={3} sx={{ flexGrow: 1, minHeight: 0 }}>
<div className="row flex-grow-1" style={{ minHeight: 0 }}> <Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex", flexDirection: "column" }}>
{/* Left Panel: JSON Data Input */} <Paper
<div className="col-md-6"> sx={{
<div className="card h-100 d-flex flex-column"> flexGrow: 1,
<div className="card-header d-flex justify-content-between align-items-center py-2"> display: "flex",
<h6 className="mb-0"> flexDirection: "column",
<i className="bi bi-file-earmark-code me-2"></i> borderRadius: 4,
JSON Data overflow: "hidden",
</h6> bgcolor: "background.paper",
<div> }}
<button >
className="btn btn-outline-success btn-sm me-2" <Box
onClick={loadFromDisk} sx={{
title="Load JSON object from file" px: 2,
> py: 2,
📄 Load an Object bgcolor: "action.hover",
</button> borderBottom: 1,
<button borderColor: "divider",
className="btn btn-outline-info btn-sm me-2" display: "flex",
onClick={loadLogFile} justifyContent: "space-between",
title="Load JSON Lines log file" alignItems: "center",
> flexWrap: "wrap",
📋 Load a Log File gap: 1,
</button> }}
<button
className="btn btn-outline-primary btn-sm me-2"
onClick={loadSample}
title="Load sample data"
>
Load Sample
</button>
<button
className="btn btn-outline-secondary btn-sm me-2"
onClick={formatJson}
title="Format JSON"
>
Format JSON
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={clearAll}
title="Clear all inputs"
>
Clear All
</button>
</div>
</div>
<div
className="card-body flex-grow-1 d-flex flex-column"
style={{ minHeight: 0 }}
> >
<textarea <Box sx={{ display: "flex", alignItems: "center" }}>
className={`form-control json-input flex-grow-1 ${jsonError ? "error" : "success"}`} <DataObjectIcon sx={{ mr: 1, fontSize: 20 }} color="primary" />
<Typography variant="subtitle2" color="text.primary">
JSON Input
</Typography>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Tooltip title="Load from Disk">
<span>
<IconButton size="medium" onClick={loadFromDisk} color="primary" aria-label="Load from Disk">
<FileOpenIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Load Logs">
<span>
<IconButton size="medium" onClick={loadLogFile} color="primary" aria-label="Load Logs">
<UploadFileIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Load Sample">
<span>
<IconButton size="medium" onClick={loadSample} color="primary" aria-label="Load Sample">
<RestoreIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Format">
<span>
<IconButton size="medium" onClick={formatJson} color="primary" aria-label="Format">
<FormatAlignLeftIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Tooltip title="Clear">
<span>
<IconButton size="medium" onClick={clearAll} color="secondary" aria-label="Clear all inputs">
<ClearIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Box>
<Box sx={{ p: 2, flexGrow: 1, display: "flex", flexDirection: "column" }}>
<TextField
multiline
fullWidth
value={jsonData} value={jsonData}
onChange={handleJsonChange} onChange={handleJsonChange}
placeholder="Enter JSON data here..." placeholder="Enter JSON data here..."
style={{ minHeight: 0, resize: "none" }} error={!!jsonError}
variant="standard"
slotProps={{
input: {
disableUnderline: true,
style: {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
height: "100%",
alignItems: "flex-start",
fontSize: "0.85rem",
lineHeight: 1.5,
},
},
}}
sx={{
flexGrow: 1,
"& .MuiInputBase-root": { height: "100%", overflow: "auto" },
}}
/> />
{jsonError && ( {jsonError && (
<div className="alert alert-danger mt-2 mb-0"> <Alert severity="error" sx={{ mt: 1, borderRadius: 2 }} variant="filled">
<small>{jsonError}</small> {jsonError}
</div> </Alert>
)} )}
</div> </Box>
</div> </Paper>
</div> </Grid>
{/* Right Panel: Results */} <Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex", flexDirection: "column" }}>
<div className="col-md-6"> <Paper
<div className="card h-100 d-flex flex-column"> sx={{
<div className="card-header py-2 d-flex justify-content-between align-items-center"> flexGrow: 1,
<h6 className="mb-0"> display: "flex",
<i className="bi bi-output me-2"></i> flexDirection: "column",
Results borderRadius: 4,
</h6> overflow: "hidden",
<div> bgcolor: "background.paper",
<button }}
className={`btn btn-sm me-2 ${copySuccess ? "btn-success" : "btn-outline-secondary"}`} >
onClick={copyToClipboard} <Box
disabled={!result || result === "null"} sx={{
title="Copy result to clipboard" px: 2,
> py: 2,
<i className={`bi ${copySuccess ? "bi-check-lg" : "bi-clipboard"} me-1`}></i> bgcolor: "action.hover",
{copySuccess ? "Copied!" : "Copy"} borderBottom: 1,
</button> borderColor: "divider",
<button display: "flex",
className="btn btn-outline-secondary btn-sm" justifyContent: "space-between",
onClick={downloadResult} alignItems: "center",
disabled={!result || result === "null"} }}
title="Download result as JSON file"
>
<i className="bi bi-download me-1"></i>
Download
</button>
</div>
</div>
<div
className="card-body flex-grow-1 d-flex flex-column"
style={{ minHeight: 0 }}
> >
<textarea <Box sx={{ display: "flex", alignItems: "center" }}>
className="form-control result-output flex-grow-1" <OutputIcon sx={{ mr: 1, fontSize: 20 }} color="primary" />
<Typography variant="subtitle2" color="text.primary">
Query Result
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Tooltip title="Copy to Clipboard">
<span>
<IconButton
size="medium"
onClick={copyToClipboard}
disabled={!result || result === "null"}
color={copySuccess ? "success" : "primary"}
>
{copySuccess ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Download Result">
<span>
<IconButton
size="medium"
onClick={downloadResult}
disabled={!result || result === "null"}
color="primary"
>
<DownloadIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Box>
<Box sx={{ p: 2, flexGrow: 1, display: "flex", flexDirection: "column" }}>
<TextField
multiline
fullWidth
value={result} value={result}
readOnly slotProps={{
input: {
readOnly: true,
disableUnderline: true,
style: {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
height: "100%",
alignItems: "flex-start",
fontSize: "0.85rem",
lineHeight: 1.5,
},
},
}}
variant="standard"
placeholder="Results will appear here..." placeholder="Results will appear here..."
style={{ minHeight: 0, resize: "none" }} sx={{
flexGrow: 1,
"& .MuiInputBase-root": { height: "100%", overflow: "auto" },
}}
/> />
</div> </Box>
</div> </Paper>
</div> </Grid>
</div> </Grid>
</> </Box>
); );
} }

View File

@@ -1,8 +1,7 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom/client'; import ReactDOM from "react-dom/client";
import 'bootstrap/dist/css/bootstrap.min.css'; import "./index.css";
import './index.css'; import App from "./App";
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(