feat: add challenge editor tool (#45214)

* feat: add challenge editor tool

chore: prepare API/Client setup

feat: migrate to react!

feat: styling

fix: useEffect loop

feat: add challenge helpers

feat: use actual code editor

feat: styling

Bring it a bit more in line with /learn

* refactor: use workspaces

Which unfortunately required a rollback to React 16, because having
multiple React versions causes all sorts of issues.

* chore: apply Oliver's review suggestions

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* chore: remove test files for now

* fix: prettier issue

* chore: apply oliver's review suggestions

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* chore: move scripts to root

* fix: lint errors oops

* chore: remove reportWebVitals thing

* chore: DRY out paths

* fix: create-empty-steps takes one arg

* chore: start doesn't make sense now

* chore: DRY out button requests

* chore: one more review suggestion

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* chore: cleanup CRA files

* fix: correct note for creating new project

* feat: enable js and jsx highlighting

* feat: include all superblocks

* feat: improve button ux

* feat: add "breadcrumbs"

* feat: add link back to block from step tools

* chore: remove unused deps

* chore: apply oliver's review suggestions

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* chore: parity between file names and commands

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Naomi Carrigan
2022-04-26 10:33:43 -07:00
committed by GitHub
parent 22fd681db5
commit 56820d90f8
53 changed files with 15525 additions and 21171 deletions
+14345 -21171
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -24,6 +24,8 @@
"client/plugins/fcc-source-challenges", "client/plugins/fcc-source-challenges",
"client/plugins/gatsby-remark-node-identity", "client/plugins/gatsby-remark-node-identity",
"curriculum", "curriculum",
"tools/challenge-editor/api",
"tools/challenge-editor/client",
"tools/challenge-helper-scripts", "tools/challenge-helper-scripts",
"tools/challenge-parser", "tools/challenge-parser",
"tools/crowdin", "tools/crowdin",
@@ -39,6 +41,9 @@
"build:client": "cd ./client && npm run build", "build:client": "cd ./client && npm run build",
"build:curriculum": "cd ./curriculum && npm run build", "build:curriculum": "cd ./curriculum && npm run build",
"build:server": "cd ./api-server && npm run build", "build:server": "cd ./api-server && npm run build",
"challenge-editor": "npm-run-all -p challenge-editor:*",
"challenge-editor:client": "cd ./tools/challenge-editor/client && npm start",
"challenge-editor:server": "cd ./tools/challenge-editor/api && npm start",
"clean": "npm-run-all -p clean:*", "clean": "npm-run-all -p clean:*",
"clean-and-develop": "npm run clean && npm ci && npm run develop", "clean-and-develop": "npm run clean && npm ci && npm run develop",
"clean:client": "cd ./client && npm run clean", "clean:client": "cd ./client && npm run clean",
@@ -0,0 +1,21 @@
import { join } from 'path';
export const CHALLENGE_DIR = join(
process.cwd(),
'..',
'..',
'..',
'curriculum',
'challenges',
'english'
);
export const META_DIR = join(
process.cwd(),
'..',
'..',
'..',
'curriculum',
'challenges',
'_meta'
);
@@ -0,0 +1,54 @@
export const superBlockList = [
{
name: 'Responsive Web Design',
path: '01-responsive-web-design'
},
{
name: 'JavaScript Algorithms and Data Structures',
path: '02-javascript-algorithms-and-data-structures'
},
{
name: 'Front End Development Libraries',
path: '03-front-end-development-libraries'
},
{
name: 'Data Visualization',
path: '04-data-visualization'
},
{
name: 'Back End Development and APIs',
path: '05-back-end-development-and-apis'
},
{
name: 'Quality Assurance',
path: '06-quality-assurance'
},
{
name: 'Scientific Computing with Python',
path: '07-scientific-computing-with-python'
},
{
name: 'Data Analysis with Python',
path: '08-data-analysis-with-python'
},
{
name: 'Information Security',
path: '09-information-security'
},
{
name: 'Coding Interview Prep',
path: '10-coding-interview-prep'
},
{
name: 'Machine Learning with Python',
path: '11-machine-learning-with-python'
},
{
name: 'Relational Databases',
path: '13-relational-databases'
},
{
name: 'Responsive Web Design (Beta)',
path: '14-responsive-web-design-22'
}
];
@@ -0,0 +1,5 @@
export interface ChallengeData {
name: string;
id: string;
path: string;
}
@@ -0,0 +1,5 @@
export interface PartialMeta {
name: string;
dashedName: string;
challengeOrder: [string, string][];
}
@@ -0,0 +1,16 @@
type ToolsFunction = (
directory: string
) => Promise<{ stdout: string; stderr: string }>;
type ToolsFunctionWithArg = (
directory: string,
start: number
) => Promise<{ stdout: string; stderr: string }>;
export interface ToolsSwitch {
'create-next-step': ToolsFunction;
'create-empty-steps': ToolsFunctionWithArg;
'insert-step': ToolsFunctionWithArg;
'delete-step': ToolsFunctionWithArg;
'update-step-titles': ToolsFunction;
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@freecodecamp/challenge-editor",
"version": "1.0.0",
"private": true,
"description": "Editor to help with new challenge structure",
"scripts": {
"start": "ts-node server.ts"
},
"author": "freeCodeCamp",
"license": "BSD-3-Clause",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.17.3",
"gray-matter": "^4.0.3",
"ts-node": "^10.5.0"
},
"devDependencies": {
"@types/express": "^4.17.13",
"typescript": "^4.5.5"
}
}
@@ -0,0 +1,10 @@
import { Request, Response } from 'express';
import { getSteps } from '../utils/getSteps';
export const blockRoute = async (req: Request, res: Response) => {
const { superblock, block } = req.params;
const steps = await getSteps(superblock, block);
res.json(steps);
};
@@ -0,0 +1,6 @@
import { Request, Response } from 'express';
import { superBlockList } from '../configs/superBlockList';
export const indexRoute = (req: Request, res: Response) => {
res.json(superBlockList);
};
@@ -0,0 +1,15 @@
import { Request, Response } from 'express';
import { saveStep } from '../utils/saveStep';
export const saveRoute = async (req: Request, res: Response) => {
const { superblock, block, step } = req.params;
const content = (req.body as { content: string }).content;
const success = await saveStep(superblock, block, step, content);
const message = success
? 'Your changes have been saved and are ready to commit!'
: 'There was an error when saving your changes. Please try again.';
res.json({ message });
};
@@ -0,0 +1,9 @@
import { Request, Response } from 'express';
import { getStepContent } from '../utils/getStepContent';
export const stepRoute = async (req: Request, res: Response) => {
const { superblock, block, step } = req.params;
const stepContents = await getStepContent(superblock, block, step);
res.json(stepContents);
};
@@ -0,0 +1,10 @@
import { Request, Response } from 'express';
import { getBlocks } from '../utils/getBlocks';
export const superblockRoute = async (req: Request, res: Response) => {
const sup = req.params.superblock;
const blocks = await getBlocks(sup);
res.json(blocks);
};
@@ -0,0 +1,53 @@
import { exec } from 'child_process';
import { join } from 'path';
import { promisify } from 'util';
import { Request, Response } from 'express';
import { ToolsSwitch } from '../interfaces/Tools';
const asyncExec = promisify(exec);
const toolsSwitch: ToolsSwitch = {
'create-next-step': directory => {
return asyncExec(`cd ${directory} && npm run create-next-step`);
},
'create-empty-steps': (directory, num) => {
return asyncExec(`cd ${directory} && npm run create-empty-steps ${num}`);
},
'insert-step': (directory, num) => {
return asyncExec(`cd ${directory} && npm run insert-step ${num}`);
},
'delete-step': (directory, num) => {
return asyncExec(`cd ${directory} && npm run delete-step ${num}`);
},
'update-step-titles': directory => {
return asyncExec(`cd ${directory} && npm run update-step-titles`);
}
};
export const toolsRoute = async (req: Request, res: Response) => {
const { superblock, block, command } = req.params;
const { num } = req.body as Record<string, number>;
const directory = join(
__dirname,
'..',
'..',
'..',
'..',
'curriculum',
'challenges',
'english',
superblock,
block
);
if (!(command in toolsSwitch)) {
res.json({ stdout: '', stderr: 'Command not found' });
return;
}
const parsed = command as keyof ToolsSwitch;
const { stdout, stderr } = await toolsSwitch[parsed](directory, num);
res.json({ stdout, stderr });
};
+44
View File
@@ -0,0 +1,44 @@
import cors from 'cors';
import express from 'express';
import { blockRoute } from './routes/blockRoute';
import { indexRoute } from './routes/indexRoute';
import { saveRoute } from './routes/saveRoute';
import { stepRoute } from './routes/stepRoute';
import { superblockRoute } from './routes/superblockRoute';
import { toolsRoute } from './routes/toolsRoute';
const app = express();
app.use(
cors({
origin: 'http://localhost:3300'
})
);
app.use(express.static('public'));
app.use(express.json());
app.use(express.urlencoded());
app.post('/:superblock/:block/_tools/:command', (req, res, next) => {
toolsRoute(req, res).catch(next);
});
app.post('/:superblock/:block/:step', (req, res, next) => {
saveRoute(req, res).catch(next);
});
app.get('/:superblock/:block/:step', (req, res, next) => {
stepRoute(req, res).catch(next);
});
app.get('/:superblock/:block', (req, res, next) => {
blockRoute(req, res).catch(next);
});
app.get('/:superblock', (req, res, next) => {
superblockRoute(req, res).catch(next);
});
app.get('/', indexRoute);
app.listen(3200, () => console.log('App is live on 3200!'));
@@ -0,0 +1,27 @@
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { CHALLENGE_DIR, META_DIR } from '../configs/paths';
import { PartialMeta } from '../interfaces/PartialMeta';
export const getBlocks = async (sup: string) => {
const filePath = join(CHALLENGE_DIR, sup);
const files = await readdir(filePath);
const blocks = await Promise.all(
files.map(async file => {
const metaPath = join(META_DIR, file, 'meta.json');
const metaData = JSON.parse(
await readFile(metaPath, 'utf8')
) as PartialMeta;
return {
name: metaData.name,
path: file
};
})
);
return blocks;
};
@@ -0,0 +1,17 @@
import { readFile } from 'fs/promises';
import { join } from 'path';
import matter from 'gray-matter';
import { CHALLENGE_DIR } from '../configs/paths';
export const getStepContent = async (
sup: string,
block: string,
step: string
) => {
const filePath = join(CHALLENGE_DIR, sup, block, step);
const fileData = await readFile(filePath, 'utf8');
const name = matter(fileData).data.title as string;
return { name, fileData };
};
@@ -0,0 +1,38 @@
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import matter from 'gray-matter';
import { PartialMeta } from '../interfaces/PartialMeta';
import { CHALLENGE_DIR, META_DIR } from '../configs/paths';
const getFileOrder = (id: string, meta: PartialMeta) => {
return meta.challengeOrder.findIndex(([f]) => f === id);
};
export const getSteps = async (sup: string, block: string) => {
const filePath = join(CHALLENGE_DIR, sup, block);
const metaPath = join(META_DIR, block, 'meta.json');
const metaData = JSON.parse(await readFile(metaPath, 'utf8')) as PartialMeta;
const stepFilenames = await readdir(filePath);
const stepData = await Promise.all(
stepFilenames.map(async filename => {
const stepPath = join(filePath, filename);
const step = await readFile(stepPath, 'utf8');
const frontMatter = matter(step);
return {
name: frontMatter.data.title as string,
id: frontMatter.data.id as string,
path: filename
};
})
);
return stepData.sort(
(a, b) => getFileOrder(a.id, metaData) - getFileOrder(b.id, metaData)
);
};
@@ -0,0 +1,21 @@
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { CHALLENGE_DIR } from '../configs/paths';
export const saveStep = async (
sup: string,
block: string,
step: string,
content: string
) => {
try {
const filePath = join(CHALLENGE_DIR, sup, block, step);
await writeFile(filePath, content);
return true;
} catch (err) {
console.log(err);
return false;
}
};
+23
View File
@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
@@ -0,0 +1,4 @@
export interface Block {
name: string;
path: string;
}
@@ -0,0 +1,4 @@
export interface ChallengeContent {
name: string;
fileData: string;
}
@@ -0,0 +1,5 @@
export interface ChallengeData {
name: string;
id: string;
path: string;
}
@@ -0,0 +1,17 @@
export interface ChallengeContentRequiredProps {
superblock: string;
block: string;
challenge: string;
content: string;
}
export interface ChallengeRequiredProps {
superblock: string;
block: string;
challenge: string;
}
export interface BlockRequiredProps {
superblock: string;
block: string;
}
@@ -0,0 +1,4 @@
export interface SuperBlock {
name: string;
path: string;
}
@@ -0,0 +1,39 @@
{
"name": "client",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^13.5.0",
"react": "16.14.0",
"react-codemirror2": "^7.2.1",
"react-dom": "16.14.0",
"react-router-dom": "^6.2.1",
"react-scripts": "5.0.0",
"typescript": "^4.5.5"
},
"scripts": {
"start": "PORT=3300 react-scripts start",
"lint": "eslint src",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/codemirror": "^5.60.5",
"eslint-plugin-react-hooks": "^4.3.0"
}
}
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<title>freeCodeCamp Challenge Editor</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react';
import './App.css';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Header from './components/header/Header';
import Landing from './components/landing/Landing';
import SuperBlock from './components/superblock/SuperBlock';
import Block from './components/block/Block';
import Editor from './components/editor/Editor';
import Tools from './components/tools/Tools';
const App = () => {
return (
<div className='app'>
<Header />
<Router>
<Routes>
<Route index element={<Landing />} />
<Route path=':superblock' element={<SuperBlock />} />
<Route path=':superblock/:block' element={<Block />} />
<Route path=':superblock/:block/_tools' element={<Tools />} />
<Route path=':superblock/:block/:challenge' element={<Editor />} />
</Routes>
</Router>
</div>
);
};
export default App;
@@ -0,0 +1,3 @@
.step-grid {
column-count: 3;
}
@@ -0,0 +1,69 @@
import React, { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ChallengeData } from '../../../interfaces/ChallengeData';
import './Block.css';
const Block = () => {
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
const [items, setItems] = useState([] as ChallengeData[]);
const params = useParams() as { superblock: string; block: string };
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchData = () => {
setLoading(true);
fetch(`http://localhost:3200/${params.superblock}/${params.block}`)
.then(res => res.json() as Promise<ChallengeData[]>)
.then(
superblocks => {
setLoading(false);
setItems(superblocks);
},
(error: Error) => {
setLoading(false);
setError(error);
}
);
};
if (error) {
return <div>Error: {error.message}</div>;
}
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<h1>{params.block}</h1>
<span className='breadcrumb'>{params.superblock}</span>
<ul className='step-grid'>
{items.map(challenge => (
<li key={challenge.name}>
<Link
to={`/${params.superblock}/${params.block}/${challenge.path}`}
>
{challenge.name}
</Link>
</li>
))}
</ul>
<p>
<Link to={`/${params.superblock}`}>Return to Blocks</Link>
</p>
<hr />
<h2>Project Controls</h2>
<p>
Looking to add, remove, or edit steps?{' '}
<Link to={`/${params.superblock}/${params.block}/_tools`}>
Use the step tools.
</Link>
</p>
</div>
);
};
export default Block;
@@ -0,0 +1,36 @@
import React, { useState } from 'react';
import { BlockRequiredProps } from '../../../interfaces/PropTypes';
import { handleRequest } from '../../utils/handleRequest';
const CreateEmptySteps = ({ superblock, block }: BlockRequiredProps) => {
const [num, setNum] = useState(0);
const click = handleRequest(() =>
fetch(
`http://localhost:3200/${superblock}/${block}/_tools/create-empty-steps`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ num })
}
)
);
const changeNum = (e: React.ChangeEvent<HTMLInputElement>) => {
setNum(parseInt(e.target.value, 10));
};
return (
<div>
<label htmlFor='num'>
Number of steps to create:
<input id='num' type='number' onChange={changeNum} />
</label>
<button onClick={click}>Create Empty Steps</button>
</div>
);
};
export default CreateEmptySteps;
@@ -0,0 +1,18 @@
import React from 'react';
import { BlockRequiredProps } from '../../../interfaces/PropTypes';
import { handleRequest } from '../../utils/handleRequest';
const CreateNextStep = ({ superblock, block }: BlockRequiredProps) => {
const click = handleRequest(() =>
fetch(
`http://localhost:3200/${superblock}/${block}/_tools/create-next-step`,
{
method: 'POST'
}
)
);
return <button onClick={click}>Create Next Step</button>;
};
export default CreateNextStep;
@@ -0,0 +1,33 @@
import React, { useState } from 'react';
import { BlockRequiredProps } from '../../../interfaces/PropTypes';
import { handleRequest } from '../../utils/handleRequest';
const DeleteStep = ({ superblock, block }: BlockRequiredProps) => {
const [num, setNum] = useState(0);
const click = handleRequest(() =>
fetch(`http://localhost:3200/${superblock}/${block}/_tools/delete-step`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ num })
})
);
const changeNum = (e: React.ChangeEvent<HTMLInputElement>) => {
setNum(parseInt(e.target.value, 10));
};
return (
<div>
<label htmlFor='num'>
Step to delete:
<input id='num' type='number' onChange={changeNum} />
</label>
<button onClick={click}>Delete Step</button>
</div>
);
};
export default DeleteStep;
@@ -0,0 +1,33 @@
import React, { useState } from 'react';
import { BlockRequiredProps } from '../../../interfaces/PropTypes';
import { handleRequest } from '../../utils/handleRequest';
const InsertStep = ({ superblock, block }: BlockRequiredProps) => {
const [num, setNum] = useState(0);
const click = handleRequest(() =>
fetch(`http://localhost:3200/${superblock}/${block}/_tools/insert-step`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ num })
})
);
const changeNum = (e: React.ChangeEvent<HTMLInputElement>) => {
setNum(parseInt(e.target.value, 10));
};
return (
<div>
<label htmlFor='num'>
Step to insert AFTER:
<input id='num' type='number' onChange={changeNum} />
</label>
<button onClick={click}>Insert Step</button>
</div>
);
};
export default InsertStep;
@@ -0,0 +1,24 @@
import React from 'react';
import { ChallengeContentRequiredProps } from '../../../interfaces/PropTypes';
import { handleRequest } from '../../utils/handleRequest';
const SaveChallenge = ({
superblock,
block,
challenge,
content
}: ChallengeContentRequiredProps) => {
const click = handleRequest(() =>
fetch(`http://localhost:3200/${superblock}/${block}/${challenge}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ content })
})
);
return <button onClick={click}>Save Changes</button>;
};
export default SaveChallenge;
@@ -0,0 +1,18 @@
import React from 'react';
import { BlockRequiredProps } from '../../../interfaces/PropTypes';
import { handleRequest } from '../../utils/handleRequest';
const UpdateStepTitles = ({ superblock, block }: BlockRequiredProps) => {
const click = handleRequest(() =>
fetch(
`http://localhost:3200/${superblock}/${block}/_tools/update-step-titles`,
{
method: 'POST'
}
)
);
return <button onClick={click}>Reorder Steps</button>;
};
export default UpdateStepTitles;
@@ -0,0 +1,17 @@
textarea {
display: block;
margin: auto;
width: 500px;
max-width: 100vw;
height: 500px;
}
.react-codemirror2 {
max-width: 80vw;
margin: auto;
text-align: left;
}
.CodeMirror {
height: 70vh;
}
@@ -0,0 +1,102 @@
import React, { useEffect, useState } from 'react';
import { Controlled as CodeMirror } from 'react-codemirror2';
import * as codemirror from 'codemirror';
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/material.css';
import 'codemirror/mode/markdown/markdown';
// we need to import this mode to get the fenced codeblock highlighting
import 'codemirror/mode/css/css';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/jsx/jsx';
import { Link, useParams } from 'react-router-dom';
import { ChallengeContent } from '../../../interfaces/ChallengeContent';
import SaveChallenge from '../buttons/SaveChallenge';
import './Editor.css';
const Editor = () => {
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
const [items, setItems] = useState({
name: '',
fileData: ''
});
const [input, setInput] = useState('');
const params = useParams() as {
superblock: string;
block: string;
challenge: string;
};
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchData = () => {
setLoading(true);
fetch(
`http://localhost:3200/${params.superblock}/${params.block}/${params.challenge}`
)
.then(res => res.json() as Promise<ChallengeContent>)
.then(
content => {
setLoading(false);
setItems(content);
setInput(content.fileData);
},
(error: Error) => {
setLoading(false);
setError(error);
}
);
};
const handleChange = (
editor: codemirror.Editor,
data: codemirror.EditorChange,
value: string
) => {
setInput(value);
};
if (error) {
return <div>Error: {error.message}</div>;
}
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<h1>{items.name}</h1>
<span className='breadcrumb'>
{params.superblock} / {params.block}
</span>
<CodeMirror
value={input}
onBeforeChange={handleChange}
options={{
mode: {
name: 'markdown',
highlightFormatting: true
},
theme: 'material',
lineNumbers: true,
lineWrapping: true
}}
/>
<SaveChallenge
superblock={params.superblock}
block={params.block}
challenge={params.challenge}
content={input}
/>
<p>
<Link to={`/${params.superblock}/${params.block}`}>
Return to Block
</Link>
</p>
</div>
);
};
export default Editor;
@@ -0,0 +1,16 @@
.header {
width: 100vw;
margin-top: 0;
text-align: center;
background: var(--background);
color: var(--content);
}
.header a {
color: var(--content);
}
.header p {
margin: 0;
font-size: 2rem;
}
@@ -0,0 +1,14 @@
import React from 'react';
import './Header.css';
const Header = () => {
return (
<div className='header'>
<p>
<a href='/'>freeCodeCamp Challenge Editor</a>
</p>
</div>
);
};
export default Header;
@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { SuperBlock } from '../../../interfaces/SuperBlock';
const Landing = () => {
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
const [items, setItems] = useState([] as SuperBlock[]);
useEffect(() => {
fetchData();
}, []);
const fetchData = () => {
setLoading(true);
fetch('http://localhost:3200/')
.then(res => res.json() as Promise<SuperBlock[]>)
.then(
superblocks => {
setLoading(false);
setItems(superblocks);
},
(error: Error) => {
setLoading(false);
setError(error);
}
);
};
if (error) {
return <div>Error: {error.message}</div>;
}
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Superblocks</h1>
<ul>
{items.map(superblock => (
<li key={superblock.name}>
<Link to={`/${superblock.path}`}>{superblock.name}</Link>
</li>
))}
</ul>
</div>
);
};
export default Landing;
@@ -0,0 +1,62 @@
import React, { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Block } from '../../../interfaces/Block';
const SuperBlock = () => {
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
const [items, setItems] = useState([] as Block[]);
const params = useParams() as { superblock: string; block: string };
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchData = () => {
setLoading(true);
fetch(`http://localhost:3200/${params.superblock}`)
.then(res => res.json() as Promise<Block[]>)
.then(
blocks => {
setLoading(false);
setItems(blocks);
},
(error: Error) => {
setLoading(false);
setError(error);
}
);
};
if (error) {
return <div>Error: {error.message}</div>;
}
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<h1>{params.superblock}</h1>
<ul>
{items.map(block => (
<li key={block.name}>
<Link to={`/${params.superblock}/${block.path}`}>{block.name}</Link>
</li>
))}
</ul>
<p>
<Link to={'/'}>Return to Superblocks</Link>
</p>
<hr />
<h2>Create New Project</h2>
<p>
Want to create a new project? Open your terminal, point to the{' '}
<code>tools/challenge-helper-scripts</code> directory, and run{' '}
<code>npm run create-project</code>
</p>
</div>
);
};
export default SuperBlock;
@@ -0,0 +1,12 @@
label {
display: block;
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
input {
background-color: var(--grey);
border: 2px solid var(--content);
padding: 6px 12px;
margin-left: 10px;
}
@@ -0,0 +1,51 @@
import React, { Link, useParams } from 'react-router-dom';
import CreateEmptySteps from '../buttons/CreateEmptySteps';
import CreateNextStep from '../buttons/CreateNextStep';
import DeleteStep from '../buttons/DeleteStep';
import InsertStep from '../buttons/InsertStep';
import UpdateStepTitles from '../buttons/UpdateStepTitles';
import './Tools.css';
const Tools = () => {
const { block, superblock } = useParams() as {
block: string;
superblock: string;
};
return (
<div>
<h1>Editing Steps for {block}</h1>
<p>These tools will allow you to create, delete, and reorder steps.</p>
<h2>Create Next Step</h2>
<p>This tool creates a new step at the end of the project.</p>
<CreateNextStep {...{ superblock, block }} />
<h2>Create Empty Steps</h2>
<p>
This tool creates <code>n</code> number of empty steps at the end of the
project.
</p>
<CreateEmptySteps {...{ superblock, block }} />
<h2>Insert Step</h2>
<p>
This tool inserts a new step after the <code>nth</code> step.
</p>
<InsertStep {...{ superblock, block }} />
<h2>Delete Step</h2>
<p>
This tool deletes step <code>n</code>.
</p>
<DeleteStep {...{ superblock, block }} />
<h2>Update Step Titles</h2>
<p>
This reorders the existing steps, updating the meta for the block. You
should not need to use this one unless you&apos;ve manually changed the
file order.
</p>
<UpdateStepTitles {...{ superblock, block }} />
<hr />
<Link to={`/${superblock}/${block}`}>Return to Block</Link>
</div>
);
};
export default Tools;
@@ -0,0 +1,60 @@
:root {
--nav-background: #0a0a23;
--background: #1b1b32;
--content: #f5f6f7;
--grey: #3b3b4f;
}
@font-face {
font-family: 'Lato';
src: url('../public/Lato-Regular.woff');
}
body {
margin: 0;
padding: 0;
font-family: 'Lato';
text-align: center;
background: var(--background);
color: var(--content);
}
ul {
list-style-type: none;
padding: 0;
}
p,
li {
font-size: 1.2rem;
}
a {
color: var(--content);
}
button {
text-align: center;
vertical-align: middle;
border: 3px solid var(--content);
font-size: 16pt;
padding: 6px 12px;
margin: 10px auto;
background: var(--grey);
color: var(--content);
cursor: pointer;
}
button:active {
color: var(--background);
background: var(--content);
}
code {
background: var(--grey);
}
.breadcrumb {
font-size: 1.2rem;
font-style: italic;
}
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
@@ -0,0 +1,6 @@
export const handleRequest = (makeRequest: () => Promise<Response>) => () => {
makeRequest()
.then(res => res.json() as Promise<{ stdout: string; stderr: string }>)
.then(data => alert(JSON.stringify(data)))
.catch(err => console.error(err));
};
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+1
View File
@@ -5,6 +5,7 @@
"client/src/**/*", "client/src/**/*",
"client/utils/**/*", "client/utils/**/*",
"curriculum/*.test.ts", "curriculum/*.test.ts",
"tools/challenge-editor/**/*",
"tools/challenge-helper-scripts/**/*.ts", "tools/challenge-helper-scripts/**/*.ts",
"tools/scripts/**/*.ts" "tools/scripts/**/*.ts"
], ],