trying in vain to buildmy own json editor

This commit is contained in:
Shoofle 2024-11-01 12:27:39 -04:00
parent 261fe25549
commit eb75c859f2
14 changed files with 465 additions and 137 deletions

View File

@ -12,6 +12,7 @@
"@emotion/styled": "^11.13.0",
"@libsql/client": "^0.11.0",
"@mdxeditor/editor": "^3.14.0",
"@mui/icons-material": "^6.1.5",
"@mui/material": "^6.1.5",
"@react-sigma/core": "^4.0.3",
"@tanstack/react-query": "^5.56.2",
@ -23,6 +24,7 @@
"graphology-layout": "^0.6.1",
"graphology-layout-force": "^0.2.4",
"highlight.js": "^11.10.0",
"json-edit-react": "^1.17.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
@ -1994,6 +1996,31 @@
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/icons-material": {
"version": "6.1.5",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.1.5.tgz",
"integrity": "sha512-SbxFtO5I4cXfvhjAMgGib/t2lQUzcEzcDFYiRHRufZUeMMeXuoKaGsptfwAHTepYkv0VqcCwvxtvtWbpZLAbjQ==",
"dependencies": {
"@babel/runtime": "^7.25.7"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@mui/material": "^6.1.5",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material": {
"version": "6.1.5",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.5.tgz",
@ -5515,6 +5542,19 @@
"node": ">=4"
}
},
"node_modules/json-edit-react": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/json-edit-react/-/json-edit-react-1.17.1.tgz",
"integrity": "sha512-UDGkBpgPoJplyT2MaPbGbKQhQjgvswaDWNbKCEkorq3r72DVm5qQErvribrc/ioZsqSnzCeDT8EwzMi4L2Az1g==",
"license": "MIT",
"dependencies": {
"object-property-assigner": "^1.3.0",
"object-property-extractor": "^1.0.11"
},
"peerDependencies": {
"react": ">=16.0.0"
}
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@ -6669,6 +6709,18 @@
"node": ">= 0.4"
}
},
"node_modules/object-property-assigner": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/object-property-assigner/-/object-property-assigner-1.3.1.tgz",
"integrity": "sha512-3R6x1l1sQA4jQBOqxkxH3DHw0PMh7K5UY4x1Yc5QYbbkzZpAJ8l9w+Trq/64ZRe2thiOsa8JpB4M7D61hnkjRA==",
"license": "MIT"
},
"node_modules/object-property-extractor": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/object-property-extractor/-/object-property-extractor-1.0.12.tgz",
"integrity": "sha512-X6rMK1O6O3EU4O06BtATezWKr+5idX6Yo0PndVqr8NlED3Eoa2YE+VKQgQL4EK0+vh+YA83Sjj9Nsi/PuXXeDQ==",
"license": "MIT"
},
"node_modules/object.assign": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz",

View File

@ -7,6 +7,7 @@
"@emotion/styled": "^11.13.0",
"@libsql/client": "^0.11.0",
"@mdxeditor/editor": "^3.14.0",
"@mui/icons-material": "^6.1.5",
"@mui/material": "^6.1.5",
"@react-sigma/core": "^4.0.3",
"@tanstack/react-query": "^5.56.2",
@ -18,6 +19,7 @@
"graphology-layout": "^0.6.1",
"graphology-layout-force": "^0.2.4",
"highlight.js": "^11.10.0",
"json-edit-react": "^1.17.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",

View File

@ -1,28 +1,22 @@
import { forwardRef } from 'react';
import { useForm } from "react-hook-form";
import { Fab } from '@mui/material';
import './CommandEntry.css';
const CommandEntry = forwardRef(({ setCommand, onSubmitCommand, command }, ref) => {
const { register, handleSubmit, setValue } = useForm();
return (
<div className="commandline" style={{display: "none"}}>
<form onSubmit={
handleSubmit((data) => { onSubmitCommand(data.command); })
}>
<input {...register("command")}
ref={ref}
onChange={(e) => {
setCommand(e.target.value);
setValue("command", e.target.value);
}}
autoFocus
type="text"
placeholder="Enter a command!"
value={command} />
<button type="submit">Do</button>
</form>
<div className="commandline" >
<input
ref={ref}
onChange={(e) => setCommand(e.target.value)}
autoFocus
type="text"
placeholder="Enter a command!"
value={command} />
<Fab onClick={ () => onSubmitCommand(command) } >
Send
</Fab>
</div>
);
});

View File

@ -9,7 +9,7 @@ import useWebSocket, { ReadyState } from 'react-use-websocket';
import Page from '../page/Page.jsx';
import ExtendedAttributes from '../page/ExtendedAttributes.jsx';
import MessageFeed from './MessageFeed.jsx';
import Sidebar from './Sidebar.jsx';
import WordSidebar from './WordSidebar.jsx';
import CommandEntry from './CommandEntry.jsx';
function Live({...props}) {
@ -23,6 +23,7 @@ function Live({...props}) {
const [ editing, setEditing ] = useState(false);
const [ connecting, setConnecting ] = useState(true);
const editorRef = useRef(null);
const commandEntryRef = useRef(null);
//setting up the websocket and using its data!
@ -148,6 +149,7 @@ function Live({...props}) {
onChangeTitle={(e) => setTitle(e.target.value)}
onChangeText={setText}
onChangeType={() => setType(!type)}
ref={editorRef}
{...props} />
<ExtendedAttributes
attributes={fetchAttributesQuery.data} />
@ -163,12 +165,12 @@ function Live({...props}) {
}[readyState]}</button>
<button onClick={()=> setMessageHistory([])}>Clear History</button>
</MessageFeed>
<Sidebar verbs={verbs} sendWord={(word) => {
<WordSidebar verbs={verbs} sendWord={(word) => {
setCommand((command + " " + word).trim());
commandEntryRef.current.focus();
// maybe set focus to the command entry?
}}>
</Sidebar>
</WordSidebar>
<CommandEntry
ref={commandEntryRef}
setCommand={(val) => {setCommand(val)}}

View File

@ -1,6 +1,10 @@
import { useEffect, useState, useRef } from 'react';
import { useForm } from "react-hook-form";
import { useNavigate } from 'react-router-dom';
import { IconButton, SwipeableDrawer } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import './MessageFeed.css';
@ -8,11 +12,34 @@ function MessageFeed({messages=[], children}) {
const [ open, setOpen ] = useState(false);
const listContainer = useRef(null);
useEffect(() => listContainer.current.scrollTo(0, listContainer.current.scrollHeight), [listContainer, messages])
useEffect(() => {
if (listContainer.current)
listContainer.current.scrollTo(0, listContainer.current.scrollHeight);
}, [listContainer, messages])
return (
<div className={`messages-tray ${!open?'messages-hidden':''}`}>
<button style={{float: 'right'}} onClick={() => setOpen(!open)}>{open?'Hide':'Show'}</button>
return (<>
<IconButton
size="large"
style={{
visible: open,
position: "fixed",
padding: "2rem",
left: 0,
top: 0
}}
onClick={() => setOpen(true)} >
<MenuIcon />
</IconButton>
<SwipeableDrawer
anchor="left"
open={open}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}>
<IconButton
variant="text"
onClick={() => setOpen(false)} >
<CloseIcon />
</IconButton>
<h2>Message history:</h2>
<ol ref={listContainer}>
{messages.map((message, idx) =>
@ -22,8 +49,8 @@ function MessageFeed({messages=[], children}) {
)}
</ol>
{children}
</div>
);
</SwipeableDrawer>
</>);
}
export default MessageFeed;

View File

@ -1,33 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useLoggedIn } from '../AuthProvider.jsx';
import { fetchCurrentVerbs } from '../apiTools.jsx';
import { SwipeableDrawer } from '@mui/material';
import './Sidebar.css';
function Sidebar({children, verbs, hidden=true, sendWord=(()=>null)}) {
const [open, setOpen] = useState(!hidden);
const loggedIn = useLoggedIn();
return (
<SwipeableDrawer
anchor="right"
open={open}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}>
<ul>
{verbs.map( (name) => (
<li key={name}>
<button onClick={() => { console.log("clicked button for", name); sendWord(name);}}>
{name}
</button>
</li>
) )}
{children}
</ul>
</SwipeableDrawer>
);
}
export default Sidebar;

View File

@ -0,0 +1,58 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useLoggedIn } from '../AuthProvider.jsx';
import { fetchCurrentVerbs } from '../apiTools.jsx';
import { Button, IconButton, SwipeableDrawer } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import './Sidebar.css';
function WordSidebar({children, verbs, hidden=true, sendWord=(()=>null)}) {
const [open, setOpen] = useState(!hidden);
const loggedIn = useLoggedIn();
return (<>
<IconButton
size="large"
variant="contained"
style={{
visible: open,
position: "fixed",
padding: "2rem",
right: 0,
top: 0
}}
onClick={() => setOpen(true)} >
<MenuIcon />
</IconButton>
<SwipeableDrawer
hideBackdrop={true}
disableBackdropTransition={true}
anchor="right"
open={open}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}>
<IconButton
onClick={() => setOpen(false)} >
<CloseIcon />
</IconButton>
<ul>
{verbs.map( (name) => (
<li key={name}>
<Button onClick={() => {
console.log("clicked button for", name);
sendWord(name);
}}>
{name}
</Button>
</li>
) )}
{children}
</ul>
</SwipeableDrawer>
</>);
}
export default WordSidebar;

View File

@ -1,36 +1,55 @@
function ExtendedAttributes({attributes, ...props}) {
import AttrList from './components/AttrList.jsx';
import JsonDisplay from './components/JsonDisplay.jsx';
function ExtendedAttributes({attributes, ...props}) {
console.log(attributes);
if (!attributes)
return <section className="page-contents">This object has no attributes.</section>;
const {parent, location, contents, verbs, prepositions} = attributes;
let newAttributes = {}
for (const prop in attributes) {
if (Object.hasOwn(attributes, prop)) {
newAttributes[prop] = attributes[prop]
}
}
delete newAttributes["parent"];
delete newAttributes["location"];
delete newAttributes["contents"];
delete newAttributes["verbs"];
delete newAttributes["prepositions"];
if (!attributes) return;
return <>
<section className="page-contents">
{attributes.parent ?
`The parent of this object is #${attributes.parent}.`
{parent ?
`The parent of this object is #${parent}.`
:
"This object has no parent."}
</section>
{attributes.location && <section className="page-contents">
This object lives in #{JSON.stringify(attributes.location)}.
</section>}
{attributes.contents && typeof attributes.contents.map === 'function' && <section className="page-contents">
<h4>Contents of this location:</h4>
<ul>
{attributes.contents.map((objectNum) => <li key={objectNum}>{objectNum}</li>)}
</ul>
</section>}
{attributes.verbs && <section className="page-contents">
<h4>Verbs defined on this object:</h4>
<ul>
{attributes.verbs.map((verbNum) => <li key={verbNum}>{verbNum}</li>)}
</ul>
</section>}
{attributes.prepositions && <section className="page-contents">
<h4>Prepositions this verb expects:</h4>
<ul>
{attributes.prepositions.map((prep) => <li key={prep}>{prep}</li>)}
</ul>
</section>}
<section className="page-contents">
{location ?
`This object lives in #${location}`
:
"This object has no location."}
</section>
<AttrList
list={contents}
title="Objects in this location:" />
<AttrList
list={verbs}
title="Verbs defined on this object:" />
<AttrList
list={prepositions}
title="Prepositions this verb expects:" />
<JsonDisplay obj={newAttributes} />
</>
}

View File

@ -0,0 +1,109 @@
import AttrListEdit from './components/AttrListEdit.jsx';
import JsonDisplay from './components/JsonDisplay.jsx';
function ExtendedAttributesEdit({attributes, setAttributes, ...props}) {
console.log("rendering with attributes: ", attributes);
if (!attributes) {
return <section className="page-contents">Attributes is falsy!</section>;
}
function attributeListSetter(attributeName) {
return (newValue) => setAttributes((a) => {
if (a === null) return a;
let newList = {...a}
newList[attributeName] = newValue;
return newList;
});
}
let newAttributes = {...attributes,
parent: undefined,
location: undefined,
contents: undefined,
verbs: undefined,
prepositions: undefined
}
let parentSnip;
if (!attributes.hasOwnProperty("parent") || attributes.parent === null || attributes.parent === undefined) {
parentSnip = <section className="page-contents">
This object does not have a parent.
<button
onClick={() => setAttributes((a) => {return {...a, parent: ""}}) }
>
Set Parent
</button>
</section>;
} else {
parentSnip = <section className="page-contents">
The parent of this object is #
<input
type="text"
value={`${attributes.parent}`}
onChange={(e) => {
const val = e.target.value;
setAttributes((a) => {return {...a, parent: Number(val) };});
}}
/>.
<button
onClick={() =>setAttributes((a) => {return {...a, parent: undefined};})} >
Clear Parent
</button>
</section>;
}
let locationSnip;
if (!attributes.hasOwnProperty("location") || attributes.location === null || attributes.location === undefined) {
locationSnip = <section className="page-contents">
This object does not have a location.
<button
onClick={() => setAttributes((a) => {return {...a, location: ""}}) }
>
Set Location
</button>
</section>;
} else {
locationSnip = <section className="page-contents">
The location of this object is #
<input
type="text"
value={attributes.location}
onChange={(e) => {
const val = e.target.value;
setAttributes((a) => {return {...a, location: Number(val) };});
}}
/>.
<button
onClick={() => setAttributes((a) => {return {...a, location: undefined};})} >
Clear Location
</button>
</section>;
}
return <>
{parentSnip}
{locationSnip}
<AttrListEdit
title="Contents of this location:"
list={attributes.contents}
onChange={attributeListSetter('contents')} />
<AttrListEdit
title="Verbs defined on this object:"
list={attributes.verbs}
onChange={attributeListSetter('verbs')} />
<AttrListEdit
title="Prepositions this verb expects:"
list={attributes.prepositions}
onChange={attributeListSetter('prepositions')} />
<JsonDisplay
obj={newAttributes} />
</>
}
export default ExtendedAttributesEdit;

View File

@ -1,10 +1,14 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { apiUrl, fetchPage, fetchPageAttributes, postPage } from '../apiTools.jsx';
import { useLoggedIn } from '../AuthProvider.jsx';
import Page from './Page.jsx';
import ExtendedAttributes from './ExtendedAttributes.jsx';
//import ExtendedAttributesEdit from './ExtendedAttributesEdit.jsx';
import { JsonEditor } from 'json-edit-react';
import './Pages.css';
@ -14,68 +18,74 @@ function GhostPage({editing, ...props}) {
const loggedIn = useLoggedIn();
const queryClient = useQueryClient();
const linkClick = (e) => {
if (e.target.tagName != "A") { return; }
if (!e.target.href.includes(window.location.origin)) return;
e.preventDefault();
navigate(e.target.href.replace(window.location.origin, ""));
};
const fetchPageQuery = useQuery({
queryKey: ['page', pagenumber, null],
queryFn: () => fetchPage(pagenumber),
});
const [title, setTitle] = useState(null);
useEffect(() => setTitle(fetchPageQuery.data?.title), [fetchPageQuery.data?.title]);
const [type, setType] = useState(false);
useEffect(() => setType(fetchPageQuery.data?.type == 1), [fetchPageQuery.data?.type]);
const editorRef = useRef(null);
const fetchAttributesQuery = useQuery({
queryKey: ['attributes', pagenumber],
queryFn: () => fetchPageAttributes(pagenumber)
});
const [title, setTitle] = useState(null);
useEffect(() => {
setTitle(fetchPageQuery.data?.title);
}, [fetchPageQuery.data?.title]);
const [text, setText] = useState(null);
useEffect(() => {
setText(fetchPageQuery.data?.description);
}, [fetchPageQuery.data?.description]);
const [type, setType] = useState(false);
useEffect(() => {
setType(fetchPageQuery.data?.type == 1);
}, [fetchPageQuery.data?.type]);
const [attributes, setAttributes] = useState(null);
useEffect(() => setAttributes(fetchAttributesQuery.data), [fetchAttributesQuery.data]);
const postMutation = useMutation({ // for changing the value when we're done with it
mutationFn: postPage,
onSettled: async (data, error, variables) => {
// Invalidate and refetch
await queryClient.invalidateQueries(['page', variables.number, null])
console.log("shoulda just invalidated the thing");
await queryClient.invalidateQueries(['page', variables.number, null]);
navigate(`/${pagenumber}`);
},
});
function submitChanges(e) {
postMutation.mutate({
number: pagenumber,
title: title,
description: text,
type: type ? 1 : 0,
});
if (editorRef.current) {
postMutation.mutate({
number: pagenumber,
title: title,
description: editorRef.current.getMarkdown(),
type: type ? 1 : 0,
});
}
}
const navigateLinkClick = (e) => {
if (e.target.tagName != "A") return;
if (!e.target.href.includes(window.location.origin)) return;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
e.preventDefault();
navigate(e.target.href.replace(window.location.origin, ""));
};
return <div className="main-column">
<Page
page={{...fetchPageQuery.data, description: text, title: title, type: type }}
page={{...fetchPageQuery.data, title: title, type: type }}
editing={editing}
linkClick={linkClick}
linkClick={navigateLinkClick}
onChangeTitle={(e) => setTitle(e.target.value)}
onChangeText={setText}
onChangeType={() => setType(!type)}
ref={editorRef}
{...props}/>
<ExtendedAttributes
attributes={fetchAttributesQuery.data} />
{ !editing ?
<ExtendedAttributes
attributes={attributes} />
:
<ExtendedAttributesEdit
attributes={attributes}
setAttributes={setAttributes} />
}
<button
onClick={() => navigate(`/${pagenumber}/history`)}>
History
@ -86,10 +96,15 @@ function GhostPage({editing, ...props}) {
onClick={submitChanges}>
{postMutation.isPending ? "Updating..." : "Update"}
</button>)}
{editing && (
<button
onClick={() => navigate(`/${pagenumber}`)}>
Return without saving
</button>)}
{!editing && !editid && (
<button
disabled={!loggedIn}
onClick={() => navigate(`/${pagenumber}/edit`, {replace: true})}>
onClick={() => navigate(`/${pagenumber}/edit`)}>
Edit Page
</button>)}
</div>;

View File

@ -1,8 +1,10 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, forwardRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiUrl, fetchPage, fetchPageAtEdit, postPage } from '../apiTools.jsx';
import { useFixLinks } from '../clientStuff.jsx';
import { Switch } from '@mui/material';
import { MDXEditor,
headingsPlugin, quotePlugin, listsPlugin,
thematicBreakPlugin, linkPlugin, diffSourcePlugin } from '@mdxeditor/editor';
@ -11,27 +13,27 @@ import './Pages.css';
import 'highlight.js/styles/a11y-dark.min.css';
import '@mdxeditor/editor/style.css';
function Page({
const Page = forwardRef(({
page=null,
editing, historical,
linkClick=()=>{},
onChangeTitle=()=>{},
onChangeType=()=>{},
onChangeText=()=>{},
}) {
}, ref) => {
let {title, description, html, lua, time, author, type} = page || {};
if (!title) title = "";
if (!html) html = "[body missing]";
if (!lua) lua = "[no definition]";
if (!description) description = "[body missing]";
useEffect(() => {
if (ref.current && description) {
ref.current.setMarkdown(description);
}
}, [description, ref.current, editing])
return (<>
<header>
<h1>
<a href="/" onClick={linkClick}>🌳</a>
{page?.number}.&nbsp;
{ editing ?
<input disabled={!editing} value={title} onChange={onChangeTitle}/>
<input disabled={!editing} value={title || ""} onChange={onChangeTitle}/>
:
<span>{title}</span>
}
@ -39,8 +41,11 @@ function Page({
{ historical && <p>saved at {time} by user #{author}</p> }
{ editing ?
<label>
<input type="checkbox" checked={type} onChange={onChangeType}/>
{type ? "verb" : "noun"}
noun
<Switch
checked={type}
onChange={onChangeType}/>
verb
</label>
:
<br/>
@ -50,7 +55,7 @@ function Page({
<section className="page-contents">
{ editing ?
<MDXEditor
markdown={description}
markdown="nothin here"
plugins={[
headingsPlugin(),
quotePlugin(),
@ -59,7 +64,7 @@ function Page({
linkPlugin(),
diffSourcePlugin({ diffMarkdown: 'ahhhh do not look upon me!', viewMode: 'source' }),
]}
onChange={onChangeText}
ref={ref}
/>
:
( type ?
@ -73,6 +78,6 @@ function Page({
</section>
</>
);
}
});
export default Page;

View File

@ -0,0 +1,19 @@
function AttrList({list, title, onChange, children}) {
if (!list)
return null;
if (!list || !list.map || typeof list.map != 'function')
return <section className="page-contents">
<h4>{title}</h4>
value is not a list: {list}
</section>;
return <section className="page-contents">
<h4>{title}</h4>
<ul>
{list.map((objectNum) => <li key={objectNum}>{objectNum}</li>)}
</ul>
</section>
}
export default AttrList;

View File

@ -0,0 +1,44 @@
function AttrListEdit({list, title, onChange, children}) {
if (list === null || list === undefined)
return <section className="page-contents">
<h4>{title}</h4>
Not set.
<button onClick={() => onChange([])}>Make empty list</button>
</section>;
if (!list.map || typeof list.map != 'function')
return <section className="page-contents">
<h4>{title}</h4>
value is not a list: {list}
<button onClick={() => onChange([])}>Make empty list</button>
<button onClick={() => onChange(undefined)}>Clear field</button>
</section>;
return <section className="page-contents">
<h4>{title}</h4>
<ul>
{list.map((objectNum, index) => (
<li key={index}>
<input
type="text"
value={objectNum}
onChange={(e) => onChange((list) => { list[index] = Number(e.target.value); } )}
/>
<button
onClick={() =>
onChange((list) => {
delete list[index];
return list;
})
}>
clear element
</button>
</li>)
)}
<li><input value="" placeholder="add element..."/></li>
</ul>
<button onClick={() => onChange(undefined)}>Clear field</button>
</section>
}
export default AttrListEdit;

View File

@ -0,0 +1,15 @@
function JsonDisplay({obj}) {
if (!obj)
return <section className="page-contents">No additional attributes found.</section>;
let empty = true;
for (const prop in obj) {
if (Object.hasOwn(obj, prop) && obj[prop] !== undefined) empty = false;
}
if (empty)
return <section className="page-contents">No additional attributes found.</section>;
return <section className="page-contents">{JSON.stringify(obj)}</section>;
}
export default JsonDisplay;