-
-
Save jimdoescode/4c974cfae29d6a117b2a to your computer and use it in GitHub Desktop.
var React = require("react"); | |
var Router = require('react-router'); | |
var GistForm = require("./GistForm.js"); | |
var AppHeader = require("./AppHeader.js"); | |
var AppFooter = require("./AppFooter.js"); | |
var Gist = require("./Gist.js"); | |
var Config = require("./Config.js"); | |
var DefaultRoute = Router.DefaultRoute; | |
var NotFoundRoute = Router.NotFoundRoute; | |
var Route = Router.Route; | |
var RouteHandler = Router.RouteHandler; | |
var App = React.createClass({ | |
render: function() { | |
return ( | |
<div className="app"> | |
<AppHeader origin={Config.origin}/> | |
<RouteHandler/> | |
<AppFooter/> | |
</div> | |
); | |
} | |
}); | |
var GistRoute = React.createClass({ | |
contextTypes: { | |
router: React.PropTypes.func | |
}, | |
render: function() { | |
return ( | |
<Gist id={this.context.router.getCurrentParams().gistId}/> | |
); | |
} | |
}); | |
var HomeRoute = React.createClass({ | |
render: function() { | |
return ( | |
<div className="container main"> | |
<section className="hero"> | |
<h1> | |
<div className="fa fa-flask fa-3x"/> | |
<p>Up your Gist magic <i className="fa fa-magic"/></p> | |
</h1> | |
<p> | |
CodeMana lets you comment in line on your Gists, simply click the line you want to talk about. | |
It works entirely in your browser, only calling GitHub to post comments and retrieve Gists. | |
</p> | |
<p> | |
If you're curious <a href="https://github.com/jimdoescode/codemana">peruse the code</a>. It's comprised mostly of React components. | |
</p> | |
<GistForm className="pure-form" showButton="true"/> | |
</section> | |
</div> | |
); | |
} | |
}); | |
var FourOhFourRoute = React.createClass({ | |
render: function() { | |
return ( | |
<div className="container main"> | |
NOT FOUND!! | |
</div> | |
); | |
} | |
}); | |
var routes = ( | |
<Route name="app" path={Config.root} handler={App}> | |
<Route name="gist" path=":gistId" handler={GistRoute}/> | |
<DefaultRoute handler={HomeRoute}/> | |
<NotFoundRoute handler={FourOhFourRoute}/> | |
</Route> | |
); | |
Router.run(routes, Router.HistoryLocation, function (Handler) { | |
React.render(<Handler/>, document.getElementById("mount-point")); | |
}); |
var React = require("react"); | |
var GistForm = require("./GistForm.js"); | |
module.exports = React.createClass({ | |
getDefaultProps: function() { | |
return {origin: window.location.origin}; | |
}, | |
updateStyle: function(event) { | |
event.preventDefault(); | |
document.getElementById('highlight-style').href = event.target.value; | |
}, | |
shouldComponentUpdate: function(newProps, newState) { | |
return false; //No need to update this thing, it's static | |
}, | |
render: function() { | |
return ( | |
<header className="app-header"> | |
<nav className="pure-menu pure-menu-horizontal pure-menu-fixed"> | |
<div className="container"> | |
<a className="pure-menu-heading pull-left logo" href={this.props.origin}> | |
<span>CODE</span><i className="fa fa-flask"/><span>MANA</span> | |
</a> | |
<GistForm className="pure-form pull-left pure-u-2-3"/> | |
<ul className="pure-menu-list pull-right"> | |
<li className="pure-menu-item"> | |
<select onChange={this.updateStyle}> | |
<option value="css/default.css">Default Highlighting</option> | |
<option value="css/funky.css">Funky Highlighting</option> | |
<option value="css/okaidia.css">Okaidia Highlighting</option> | |
<option value="css/dark.css">Dark Highlighting</option> | |
</select> | |
</li> | |
</ul> | |
</div> | |
</nav> | |
</header> | |
); | |
} | |
}); |
module.exports = { | |
root: '/', | |
gistApi: 'https://api.github.com', | |
origin: window.location.origin | |
}; |
var React = require("react"); | |
module.exports = React.createClass({ | |
getDefaultProps: function() { | |
return { | |
name: '', | |
lines: [], | |
comments: [], | |
onCommentFormOpen: function() {}, | |
onCommentFormCancel: function() {}, | |
onCommentFormSubmit: function() {} | |
}; | |
}, | |
render: function() { | |
var rows = []; | |
var lineCount = this.props.lines.length; | |
for (var i=0; i < lineCount; i++) { | |
var num = i + 1; | |
if (this.props.comments[num] && this.props.comments[num].length > 0) { | |
rows.push(<Line key={this.props.name + num} | |
onClick={this.props.onCommentFormOpen} | |
file={this.props.name} | |
number={num} | |
content={this.props.lines[i]} | |
toggle={LineComments.generateNodeId(this.props.name, num)}/>); | |
rows.push(<LineComments key={this.props.name + num + 'comments'} | |
onEditOrReply={this.props.onCommentFormOpen} | |
onCancel={this.props.onCommentFormCancel} | |
onSubmit={this.props.onCommentFormSubmit} | |
file={this.props.name} | |
number={num} | |
comments={this.props.comments[num]}/>); | |
} else { | |
rows.push(<Line key={this.props.name + num} | |
onClick={this.props.onCommentFormOpen} | |
file={this.props.name} | |
number={num} | |
content={this.props.lines[i]}/>); | |
} | |
} | |
return ( | |
<section className="code-file-container"> | |
<table id={this.props.name} className="code-file"> | |
<tbody> | |
<tr className="spacer line"> | |
<td className="line-marker"/> | |
<td className="line-num"/> | |
<td className="line-content"/> | |
</tr> | |
{rows} | |
<tr className="spacer line"> | |
<td className="line-marker"/> | |
<td className="line-num"/> | |
<td className="line-content"/> | |
</tr> | |
</tbody> | |
</table> | |
</section> | |
); | |
} | |
}); | |
var Line = React.createClass({ | |
getDefaultProps: function() { | |
return { | |
number: 0, | |
content: '', | |
file: '', | |
toggle: false, | |
onClick: function() {} | |
} | |
}, | |
//Lines only need to rerender when a new file is set. | |
shouldComponentUpdate: function(newProps, newState) { | |
return this.props.content !== newProps.content || | |
this.props.file !== newProps.file || | |
this.props.toggle !== newProps.toggle; | |
}, | |
render: function() { | |
var toggleCol = this.props.toggle ? <td className="line-marker"><CommentToggle toggle={this.props.toggle}/></td> : <td className="line-marker"/> | |
return ( | |
<tr id={this.props.file+"-L"+this.props.number} className="line"> | |
{toggleCol} | |
<td className="line-num">{this.props.number}</td> | |
<td className="line-content" onClick={this.props.onClick.bind(null, this.props.file, this.props.number, 0)}> | |
<pre> | |
<code dangerouslySetInnerHTML={{__html: this.props.content}}/> | |
</pre> | |
</td> | |
</tr> | |
); | |
} | |
}); | |
var LineComments = React.createClass({ | |
statics: { | |
generateNodeId: function(filename, number) { | |
return filename + "-C" + number; | |
} | |
}, | |
getDefaultProps: function() { | |
return { | |
number: 0, | |
file: '', | |
comments: [], | |
onEditOrReply: function() {}, | |
onCancel: function() {}, | |
onSubmit: function() {} | |
} | |
}, | |
render: function() { | |
var comments = this.props.comments.map(function(comment) { | |
return comment.showForm ? | |
<CommentForm user={comment.user} text={comment.body} key="comment-form" onCancel={this.props.onCancel} onSubmit={this.props.onSubmit}/> : | |
<Comment user={comment.user} text={comment.body} key={comment.id} onEditOrReply={this.props.onEditOrReply} id={this.props.file+"-L"+this.props.number+"-C"+comment.id}/>; | |
}, this); | |
return ( | |
<tr id={LineComments.generateNodeId(this.props.file, this.props.number)} className="line comment-row"> | |
<td className="line-marker"/> | |
<td className="line-num"/> | |
<td className="line-comments">{comments}</td> | |
</tr> | |
); | |
} | |
}); | |
var Comment = React.createClass({ | |
getDefaultProps: function() { | |
return { | |
id: '', | |
text: '', | |
user: null, | |
onEditOrReply: function() {} | |
}; | |
}, | |
//Comments only need to rerender when new text is set. | |
shouldComponentUpdate: function(newProps, newState) { | |
return this.props.text !== newProps.text; | |
}, | |
render: function() { | |
return ( | |
<div className="line-comment"> | |
<a className="avatar pull-left" href={this.props.user.html_url}><img src={this.props.user.avatar_url} alt=""/></a> | |
<div className="pull-left content"> | |
<header className="comment-header"> | |
<a href={this.props.user.html_url}>{this.props.user.login}</a> | |
</header> | |
<p className="comment-body">{this.props.text}</p> | |
</div> | |
</div> | |
); | |
} | |
}); | |
var CommentForm = React.createClass({ | |
getDefaultProps: function() { | |
return { | |
text: '', | |
user: null, | |
onSubmit: function() {}, | |
onCancel: function() {} | |
} | |
}, | |
render: function() { | |
return ( | |
<div className="line-comment"> | |
<a className="avatar pull-left" href={this.props.user.html_url}><img src={this.props.user.avatar_url} alt=""/></a> | |
<div className="pull-left content"> | |
<header className="comment-header"> | |
<a href={this.props.user.html_url}>{this.props.user.login}</a> | |
</header> | |
<form action="#" onSubmit={this.props.onSubmit} className="comment-body"> | |
<textarea name="text" placeholder="Enter your comment..." defaultValue={this.props.text}/> | |
<button type="submit" className="pure-button button-primary"> | |
<i className="fa fa-comment"/> Comment | |
</button> | |
<button type="button" className="pure-button button-error" onClick={this.props.onCancel}> | |
<i className="fa fa-times-circle"/> Cancel | |
</button> | |
</form> | |
</div> | |
</div> | |
); | |
} | |
}); | |
var CommentToggle = React.createClass({ | |
getInitialState: function() { | |
return { | |
display: 'none', | |
symbolClass: this.props.closeIcon | |
}; | |
}, | |
getDefaultProps: function() { | |
return { | |
toggle: '', | |
closeIcon: 'fa-comment-o fa-flip-horizontal', | |
openIcon: 'fa-comment fa-flip-horizontal' | |
}; | |
}, | |
handleClick: function(event) { | |
var elm = document.getElementById(this.props.toggle); | |
var display = elm.style.display; | |
event.preventDefault(); | |
elm.style.display = this.state.display; | |
this.setState({ | |
symbolClass: display === 'none' ? this.props.closeIcon : this.props.openIcon, | |
display: display | |
}); | |
}, | |
render: function() { | |
return ( | |
<a href='#' onClick={this.handleClick}> | |
<i className={"fa " + this.state.symbolClass + " fa-fw"}/> | |
</a> | |
); | |
} | |
}); |
var React = require("react"); | |
var Qwest = require("qwest"); | |
var Modal = require("react-modal"); | |
var File = require("./File.js"); | |
var Utils = require("./Utils.js"); | |
var Spinner = require("./Spinner.js"); | |
var Config = require("./Config.js"); | |
Modal.setAppElement(document.getElementById("mount-point")); | |
Modal.injectCSS(); | |
Qwest.base = Config.gistApi; | |
module.exports = React.createClass({ | |
getHeaders: function() { | |
var headers = {}; | |
if (this.state.user !== null) | |
headers["Authorization"] = 'Basic ' + btoa(this.state.user.login + ':' + this.state.user.password); | |
return headers; | |
}, | |
getInitialState: function() { | |
return { | |
files: [], | |
comments: [], | |
openComment: null, | |
showLoginModal: false, | |
user: Utils.getUserFromStorage(sessionStorage), | |
processing: true | |
}; | |
}, | |
fetchGist: function(gistId) { | |
var self = this; | |
var options = { | |
headers: this.getHeaders(), | |
responseType: 'json' | |
}; | |
Qwest.get('/gists/'+gistId, null, options).then(function(xhr, gist) { | |
var files = []; | |
for (var name in gist.files) | |
files.push(Utils.parseFile(gist.files[name])); | |
if (self.isMounted()) | |
self.setState({ | |
files: files, | |
processing: false | |
}); | |
}).catch(function(xhr, response, e) { | |
console.log(xhr, response, e); | |
alert('There was a problem fetching and or parsing this Gist.'); | |
self.setState({processing: false}); | |
}); | |
Qwest.get('/gists/'+gistId+'/comments', null, options).then(function(xhr, comments) { | |
var parsedComments = {}; | |
var commentCount = comments.length; | |
for (var i=0; i < commentCount; i++) { | |
var parsed = Utils.parseComment(comments[i]); | |
if (parsedComments[parsed.filename] === undefined) | |
parsedComments[parsed.filename] = []; | |
if (parsedComments[parsed.filename][parsed.line] === undefined) | |
parsedComments[parsed.filename][parsed.line] = []; | |
parsedComments[parsed.filename][parsed.line].push(parsed); | |
} | |
if (self.isMounted()) { | |
self.setState({ | |
comments: parsedComments | |
}); | |
} | |
}).catch(function(xhr, response, e) { | |
console.log(xhr, response, e); | |
alert('There was a problem fetching the comments for this Gist.'); | |
}); | |
}, | |
componentDidMount: function() { | |
this.fetchGist(this.props.id); | |
}, | |
componentWillReceiveProps: function(newProps) { | |
this.setState({processing: true}); | |
this.fetchGist(newProps.id); | |
}, | |
componentDidUpdate: function(prevProps, prevState) { | |
//If there is a hash specified then attempt to scroll there. | |
if (window.location.hash) { | |
var elm = document.getElementById(window.location.hash.substring(1)); | |
if (elm) | |
window.scrollTo(0, elm.offsetTop); | |
} | |
}, | |
postGistComment: function(event) { | |
var text = event.target.children.namedItem("text").value.trim(); | |
var comments = this.state.comments; | |
var open = this.state.openComment; | |
var options = { | |
headers: this.getHeaders(), | |
dataType: 'json', | |
responseType: 'json' | |
}; | |
event.preventDefault(); | |
if (text !== "" && open !== null) { | |
open.body = text; | |
open.showForm = false; | |
comments[open.filename][open.line].splice(open.replyTo, 1, open); | |
//Send the comment to GitHub. We only need to handle the case where it doesn't make it | |
Qwest.post('/gists/'+this.props.id+'/comments', {body: Utils.createCommentLink(this.props.id, open.filename, open.line) + ' ' + text}, options); | |
this.setState({ | |
comments: comments, | |
openComment: null | |
}); | |
} | |
}, | |
insertCommentForm: function(filename, line, replyTo, event) { | |
var comments = this.state.comments; | |
var open = this.state.openComment; | |
var newOpen = Utils.createComment(this.props.id, 0, filename, line, '', this.state.user, replyTo, true); | |
if (this.state.user === null) { | |
this.setState({showLoginModal: true}); | |
return; | |
} | |
event.preventDefault(); | |
if (open !== null) | |
comments[open.filename][open.line].splice(open.replyTo, 1); | |
if (!comments[filename]) | |
comments[filename] = []; | |
if (!comments[filename][line]) | |
comments[filename][line] = []; | |
if (open === null || open.filename !== newOpen.filename || open.line !== newOpen.line || open.replyTo !== newOpen.replyTo) { | |
newOpen.id = comments[filename][line].length; | |
comments[filename][line].splice(replyTo, 0, newOpen); | |
} else { | |
newOpen = null; | |
} | |
this.setState({ | |
comments: comments, | |
openComment: newOpen | |
}); | |
}, | |
removeCommentForm: function(event) { | |
var comments = this.state.comments; | |
var open = this.state.openComment; | |
event.preventDefault(); | |
if (open !== null) { | |
comments[open.filename][open.line].splice(open.replyTo, 1); | |
this.setState({ | |
comments: comments, | |
openComment: null | |
}); | |
} | |
}, | |
handleLogin: function(user) { | |
this.setState({ | |
user: user, | |
showLoginModal: false | |
}); | |
}, | |
closeModal: function(event) { | |
event.preventDefault(); | |
this.setState({showLoginModal: false}); | |
}, | |
render: function() { | |
var body = this.state.processing ? | |
<Spinner/> : this.state.files.map(function(file) { | |
return <File onCommentFormOpen={this.insertCommentForm} | |
onCommentFormCancel={this.removeCommentForm} | |
onCommentFormSubmit={this.postGistComment} | |
key={file.name} | |
name={file.name} | |
lines={file.parsedLines} | |
comments={this.state.comments[file.name]}/> | |
}, this); | |
return ( | |
<div className="container main"> | |
<LoginModal show={this.state.showLoginModal} onSuccess={this.handleLogin} onClose={this.closeModal}/> | |
{body} | |
</div> | |
); | |
} | |
}); | |
var LoginModal = React.createClass({ | |
getDefaultProps: function() { | |
return { | |
show: false, | |
onSuccess: function(user) {}, | |
onClose: function() {} | |
}; | |
}, | |
getInitialState: function() { | |
return { | |
processing: false | |
}; | |
}, | |
attemptLogin: function(event) { | |
var username = event.target.elements.namedItem("username").value.trim(); | |
var password = event.target.elements.namedItem("password").value; | |
var store = event.target.elements.namedItem("store").checked; | |
var self = this; | |
event.preventDefault(); | |
if (username && password) { | |
var options = { | |
headers: { | |
Authorization: 'Basic ' + btoa(username + ':' + password) | |
}, | |
responseType: 'json' | |
}; | |
this.setState({processing: true}); | |
Qwest.get('/user', null, options).then(function(xhr, user) { | |
user.password = password; | |
if (store) | |
Utils.saveUserToStorage(user, sessionStorage); | |
self.props.onSuccess(user); | |
}).complete(function(xhr, user) { | |
self.setState({processing: false}); | |
}); | |
} | |
}, | |
render: function() { | |
var form = ( | |
<form className="pure-form pure-form-stacked" onSubmit={this.attemptLogin}> | |
<fieldset> | |
<input name="username" className="pure-input-1" type="text" placeholder="GitHub User Name..." required="true"/> | |
<input name="password" className="pure-input-1" type="password" placeholder="GitHub Password or Token..." required="true"/> | |
<label><input name="store" type="checkbox"/> Store in Memory</label> | |
</fieldset> | |
<fieldset> | |
<button type="submit" className="pure-button button-primary"><i className="fa fa-save"/> Save</button> | |
<button className="pure-button button-error" onClick={this.props.onClose}><i className="fa fa-times-circle"/> Cancel</button> | |
</fieldset> | |
</form> | |
); | |
return ( | |
<Modal isOpen={this.props.show} onRequestClose={this.props.onClose} className="react-modal-content" overlayClassName="react-modal-overlay"> | |
<h2><i className="fa fa-github"/> GitHub Access</h2> | |
<p>To leave a comment you need to enter your GitHub user name and GitHub password. This is <strong>only</strong> used to post Gist comments to GitHub.</p> | |
<p>If you prefer not to enter your password you can use a <a target="_blank" href="https://github.com/settings/tokens/new">personal access token</a>. Make sure it has Gist access.</p> | |
<hr/> | |
{ this.state.processing ? <Spinner className="fa-github-alt"/> : form } | |
</Modal> | |
); | |
} | |
}); |
var React = require("react"); | |
var Router = require('react-router'); | |
module.exports = React.createClass({ | |
mixins: [Router.Navigation], | |
getDefaultProps: function() { | |
return { | |
className: "", | |
showButton: false | |
}; | |
}, | |
handleSubmit: function(event) { | |
event.preventDefault(); | |
var gistId = event.target.elements.namedItem("gistId").value.trim(); | |
if (gistId.length > 0) | |
this.transitionTo('gist', {gistId: gistId}); | |
}, | |
render: function() { | |
var body = ( | |
this.props.showButton ? | |
<fieldset> | |
<input name="gistId" type="text" className="pure-input-1-3" placeholder="Enter a Gist ID..." required="true"/> | |
<button type="submit" className="pure-button button-primary">Go <i className="fa fa-sign-in"/></button> | |
</fieldset> | |
: | |
<input className="pure-input-1-3" name="gistId" type="text" placeholder="Enter a Gist ID..." required="true"/> | |
); | |
return ( | |
<form className={this.props.className} onSubmit={this.handleSubmit} action="#">{body}</form> | |
); | |
} | |
}); | |
var React = require("react"); | |
module.exports = React.createClass({ | |
getDefaultProps: function() { | |
return { | |
className: 'fa-spinner' | |
}; | |
}, | |
render: function() { | |
var classes = 'fa ' + this.props.className + ' fa-spin fa-5x'; | |
return ( | |
<p className="spinner"><i className={classes}/></p> | |
); | |
} | |
}); |
var Prism = require("./Prism.js"); | |
module.exports = { | |
tokenizeNewLines: function(str) { | |
var tokens = []; | |
var strlen = str.length; | |
var lineCount = 0; | |
for (var i=0; i < strlen; i++) | |
{ | |
if (tokens[lineCount]) | |
tokens[lineCount] += str[i]; | |
else | |
tokens[lineCount] = str[i]; | |
if (str[i] === '\n') | |
lineCount++; | |
} | |
return tokens; | |
}, | |
tokenize: function(code, lang) { | |
var processed = []; | |
var tokens = Prism.tokenize(code, lang); | |
var token = tokens.shift(); | |
while (token) | |
{ | |
var isObj = typeof token === 'object'; | |
var lines = this.tokenizeNewLines(isObj ? token.content : token); | |
var count = lines.length; | |
for (var i=0; i < count; i++) | |
{ | |
if (isObj) | |
processed.push(new Prism.Token(token.type, Prism.util.encode(lines[i]), token.alias)); | |
else | |
processed.push(Prism.util.encode(lines[i])); | |
} | |
token = tokens.shift(); | |
} | |
return processed; | |
}, | |
syntaxHighlight: function(code, lang) { | |
var lineCount = 0; | |
var lines = ['']; | |
var prismLang = this.getPrismCodeLanguage(lang); | |
var tokens = this.tokenize(code, prismLang); | |
var token = tokens.shift(); | |
while (token) | |
{ | |
code = (typeof token === 'object') ? Prism.Token.stringify(token, prismLang) : token; | |
lines[lineCount] += code.replace(/\n/g, ''); | |
if (code.indexOf('\n') !== -1) | |
lines[++lineCount] = ''; | |
token = tokens.shift(); | |
} | |
return lines; | |
}, | |
getPrismCodeLanguage: function(gistLang) { | |
var lang = gistLang.toLowerCase().replace(/#/, 'sharp').replace(/\+/g, 'p'); | |
if (Prism.languages[lang]) { | |
return Prism.languages[lang]; | |
} | |
console.log("CodeMana Error - Prism doesn't support the language: "+gistLang+". Help them and us out by adding it http://prismjs.com/"); | |
throw ({msg: "CodeMana Error - Prism doesn't support the language: " + gistLang}); | |
}, | |
getUserFromStorage: function(store) { | |
return JSON.parse(store.getItem('user')); | |
}, | |
saveUserToStorage: function(user, store) { | |
store.setItem('user', JSON.stringify(user)); | |
}, | |
createComment: function(gistId, commentId, filename, lineNumber, commentBody, commentUser, replyTo, showForm) { | |
return { | |
gistId: gistId, | |
id: commentId, | |
filename: filename, | |
line: parseInt(lineNumber, 10), | |
body: commentBody, | |
user: commentUser, | |
replyTo: replyTo, | |
showForm: showForm | |
}; | |
}, | |
createFile: function(name, parsedLines) { | |
return { | |
name: name, | |
parsedLines: parsedLines | |
}; | |
}, | |
parseComment: function(comment) { | |
//Annoyingly I couldn't get a single regex to separate everything out... | |
var split = comment.body.match(/(\S+)\s(.*)/); | |
var data = split[1].match(/http:\/\/codemana\.com\/(.*)#(.+)-L(\d+)/); | |
return data !== null ? this.createComment(data[1], comment.id, data[2], parseInt(data[3], 10), split[2], comment.user, 0, false) : null; | |
}, | |
parseFile: function(file) { | |
var lines = this.syntaxHighlight(file.content, file.language); | |
return this.createFile(file.filename, lines) | |
}, | |
createCommentLink: function(id, filename, lineNumber) { | |
return 'http://codemana.com/'+id+'#'+filename+'-L'+lineNumber; | |
} | |
}; |
https://codemana.com/4c974cfae29d6a117b2a#Utils.js-L116 Is there some way to make this configurable so that you don't have to hardcode the domain?
https://codemana.com/4c974cfae29d6a117b2a#AppFooter.js-L6 You should include links to the github repo in the footer.
https://codemana.com/4c974cfae29d6a117b2a#App.js-L9 Whoops I wanted to say something different
https://codemana.com/4c974cfae29d6a117b2a#App.js-L9 Testing stuff again
http://localhost:3000/4c974cfae29d6a117b2a#App.js-L12 Testing some stuff
http://localhost:3000/4c974cfae29d6a117b2a#App.js-L12 Replying to this one.
http://localhost:3000/4c974cfae29d6a117b2a#App.js-L17 Testing the new comment form edit
testing it now
http://localhost:3000/4c974cfae29d6a117b2a#App.js-L17 Replying to a comment
http://localhost:3000/4c974cfae29d6a117b2a#Config.js-L5 Make it scroll a little
https://codemana.com/4c974cfae29d6a117b2a#App.js-L65 Need a better 404 page.