diff --git a/.gitignore b/.gitignore index daeba5f9..61248aef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *~ node_modules .DS_Store +.idea diff --git a/README.md b/README.md index 4862f5df..34f48612 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,20 @@ -[![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) +#My implementation of the React Tutorial, plus some extras: +I decided to try my hand at go, which I've never used before, for the server. +In addition to doing the base tutorial, I also added: +- A delete button for every comment +- Put newest comments on top +- Pagination description ( i.e. showing 1-3 of 5) +- Load more button at the bottom (upon page load) if there are more than 4 comments # React Tutorial This is the React comment box example from [the React tutorial](http://facebook.github.io/react/docs/tutorial.html). ## To use - -There are several simple server implementations included. They all serve static files from `public/` and handle requests to `/api/comments` to fetch or add data. Start a server with one of the following: - -### Node - -```sh -npm install -node server.js -``` - -### Python - -```sh -pip install -r requirements.txt -python server.py -``` - -### Ruby -```sh -ruby server.rb -``` - -### PHP -```sh -php server.php -``` - -### Go ```sh go run server.go ``` -### Perl - -```sh -cpan Mojolicious -perl server.pl -``` - And visit . Try opening multiple tabs! ## Changing the port diff --git a/public/css/base.css b/public/css/base.css index 08de8f1b..e203ba24 100644 --- a/public/css/base.css +++ b/public/css/base.css @@ -1,62 +1,71 @@ body { - background: #fff; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 15px; - line-height: 1.7; - margin: 0; - padding: 30px; + background: #fff; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.7; + margin: 0; + padding: 30px; } a { - color: #4183c4; - text-decoration: none; + color: #4183c4; + text-decoration: none; } a:hover { - text-decoration: underline; + text-decoration: underline; } code { - background-color: #f8f8f8; - border: 1px solid #ddd; - border-radius: 3px; - font-family: "Bitstream Vera Sans Mono", Consolas, Courier, monospace; - font-size: 12px; - margin: 0 2px; - padding: 0 5px; + background-color: #f8f8f8; + border: 1px solid #ddd; + border-radius: 3px; + font-family: "Bitstream Vera Sans Mono", Consolas, Courier, monospace; + font-size: 12px; + margin: 0 2px; + padding: 0 5px; } h1, h2, h3, h4 { - font-weight: bold; - margin: 0 0 15px; - padding: 0; + font-weight: bold; + margin: 0 0 15px; + padding: 0; } h1 { - border-bottom: 1px solid #ddd; - font-size: 2.5em; - font-weight: bold; - margin: 0 0 15px; - padding: 0; + border-bottom: 1px solid #ddd; + font-size: 2.5em; + font-weight: bold; + margin: 0 0 15px; + padding: 0; } h2 { - border-bottom: 1px solid #eee; - font-size: 2em; + border-bottom: 1px solid #eee; + font-size: 2em; } h3 { - font-size: 1.5em; + font-size: 1.5em; } h4 { - font-size: 1.2em; + font-size: 1.2em; } p, ul { - margin: 15px 0; + margin: 15px 0; } ul { - padding-left: 30px; + padding-left: 30px; +} + +.red { + background-color: crimson; +} + +.paginationDescription { + color: navy; + font-weight: bold; } diff --git a/public/index.html b/public/index.html index 21340e72..b604ac40 100644 --- a/public/index.html +++ b/public/index.html @@ -1,6 +1,6 @@ - + React Tutorial @@ -10,13 +10,13 @@ - - -
- - - + + +
+ + + diff --git a/public/scripts/my.jsx b/public/scripts/my.jsx new file mode 100644 index 00000000..ec91b032 --- /dev/null +++ b/public/scripts/my.jsx @@ -0,0 +1,201 @@ +/** + * Created by emilyfeder on 3/17/16. + */ + +var CommentBox = React.createClass({ + getUrl: function() { + return this.props.url + '?start=' + this.state.start_fetch + '&end=' + this.state.end_fetch + }, + setStateFromCommentData: function(data, cb) { + var end_fetch = typeof data.end_fetch != 'undefined' ? data.end_fetch : this.state.end_fetch; + if (end_fetch < 4) { + end_fetch = 4; + } + this.setState({ + data: data.comments || [], + end_fetch: end_fetch, + count: typeof data.count != 'undefined' ? data.count : this.state.count + }, cb); + }, + loadCommentsFromServer: function() { + $.ajax({ + url: this.getUrl(), + type: 'GET', + dataType: 'json', + cache: false, + success: function(data) { + this.setStateFromCommentData(data); + }.bind(this), + error: function(xhr, status, err) { + console.error(this.props.url, status, err.toString()); + }.bind(this) + }); + }, + loadMoreCommentsFromServer: function() { + this.setState({data: this.state.data, start_fetch: this.state.start_fetch, end_fetch: this.state.end_fetch+3}, function(){ + this.loadCommentsFromServer(); + }); + }, + handleCommentSubmit: function(comment) { + //submit to the server and refresh the comment list + var comments = this.state.data; + comment.id = Date.now(); + comment.reason = 'create'; + var newComments = [comment].concat(comments); + this.setStateFromCommentData({ comments: newComments, end_fetch: this.state.end_fetch + 1 }, function(){ + $.ajax({ + url: this.getUrl(), + dataType: 'json', + type: 'POST', + data: comment, + success: function(data) { + this.setStateFromCommentData(data); + }.bind(this), + error: function(xhr, status, err) { + this.setStateFromCommentData({comments: comments, end_fetch: this.state.end_fetch - 1}); + console.error(this.getUrl(), status, err.toString()); + }.bind(this) + }); + }.bind(this)); + + }, + onCommentDelete: function(id) { + var comments = this.state.data; + var newComments = comments.filter(function(comment) { + return comment.id != id; + }); + this.setState({data: newComments, end_fetch : this.state.end_fetch - 1}, function(){ + $.ajax({ + url: this.getUrl(), + dataType: 'json', + type: 'POST', + data: {id: id, reason: 'delete'}, + success: function(data) { + this.setStateFromCommentData(data); + }.bind(this), + error: function(xhr, status, err) { + this.setStateFromCommentData({comments: comments, end_fetch: this.state.end_fetch + 1}); + console.error(this.getUrl(), status, err.toString()); + }.bind(this) + }); + }.bind(this)); + }, + getInitialState: function() { + return {data:[], start_fetch: 0, end_fetch: 4, count: 0} + }, + componentDidMount: function() { + this.loadCommentsFromServer(); + setInterval(this.loadCommentsFromServer, this.props.pollInterval) + }, + render: function() { + var load_more_button; + if (this.state.count > this.state.data.length) { + load_more_button = ; + } + return( +
+

Comments

+ + + + {load_more_button} +
+ ); + } +}); + +var CommentList = React.createClass({ + render: function() { + var list = this; + var commentNodes = this.props.data.map(function(comment) { + return ( + + {comment.text} + + ); + }); + return ( +
+ {commentNodes} +
+ ); + } +}); + +var Comment = React.createClass({ + rawMarkup: function() { + var rawMarkup = marked(this.props.children.toString(), {sanitize: true}); + return { __html: rawMarkup }; + }, + deleteComment: function(e) { + console.log('comment delete' + this.props.comment_id) + this.props.onCommentDelete(this.props.comment_id); + }, + render: function() { + return ( +
+

+ {this.props.author} +

+ + +
+ ) + } +}); + +var CommentForm = React.createClass({ + getInitialState: function() { + return {author: '', text:''} + }, + handleAuthorChange: function(e) { + this.setState({ author: e.target.value }) + }, + handleTextChange: function(e) { + this.setState({ text: e.target.value }) + }, + handleSubmit: function(e) { + e.preventDefault(); + var author = this.state.author.trim(); + var text = this.state.text.trim(); + if (!text || !author) { + return; + } + this.props.onCommentSubmit({author: author, text: text}); + this.setState({author: '', text: ''}); + }, + render: function() { + return ( +
+ + + +
+ ) + } +}); + +var PaginationDescription = React.createClass({ + render: function() { + return ( +
+ Showing {this.props.start} - {this.props.end} of {this.props.count} +
+ ) + } +}); + +ReactDOM.render( + , + document.getElementById('content') +); \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 632a1efa..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -Flask==0.10.1 diff --git a/server.go b/server.go index 934a4cfc..00ab9a49 100644 --- a/server.go +++ b/server.go @@ -8,6 +8,8 @@ * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + * edited by: Emily Feder */ package main @@ -23,18 +25,38 @@ import ( "os" "sync" "time" + "strconv" ) -type comment struct { +type Comment struct { ID int64 `json:"id"` Author string `json:"author"` Text string `json:"text"` } +type Response struct { + Comments []Comment `json:"comments"` + Count int `json:"count"` +} + const dataFile = "./comments.json" var commentMutex = new(sync.Mutex) +func writeCommentsToFile(comments []Comment, fi os.FileInfo) (string) { + var commentData, err = json.MarshalIndent(comments, "", " ") + if err != nil { + return fmt.Sprintf("Unable to marshal comments to data file (%s): %s", dataFile, err) + } + // Write out the comments to the file, preserving permissions + err = ioutil.WriteFile(dataFile, commentData, fi.Mode()) + if err != nil { + return fmt.Sprintf("Unable to write comments to data file (%s): %s", dataFile, err) + } + + return "" +} + // Handle comments func handleComments(w http.ResponseWriter, r *http.Request) { // Since multiple requests could come in at once, ensure we have a lock @@ -56,43 +78,81 @@ func handleComments(w http.ResponseWriter, r *http.Request) { return } + // Decode the JSON data + var comments []Comment + if err := json.Unmarshal(commentData, &comments); err != nil { + http.Error(w, fmt.Sprintf("Unable to Unmarshal comments from data file (%s): %s", dataFile, err), http.StatusInternalServerError) + return + } + + var start, _ = strconv.ParseInt(r.URL.Query().Get("start"), 10, 64); + var end, _ = strconv.ParseInt(r.URL.Query().Get("end"), 10, 64); + switch r.Method { case "POST": - // Decode the JSON data - var comments []comment - if err := json.Unmarshal(commentData, &comments); err != nil { - http.Error(w, fmt.Sprintf("Unable to Unmarshal comments from data file (%s): %s", dataFile, err), http.StatusInternalServerError) - return + //delete or add a comment + if r.FormValue("reason") == "delete" { + var newcomments []Comment + for _, c := range comments { + var id, err = strconv.ParseInt(r.FormValue("id"), 10, 64); + if err == nil && c.ID != id { + newcomments = append(newcomments, c); + } + } + comments = newcomments + } else { + comments = append([]Comment{{ID: time.Now().UnixNano() / 1000000, Author: r.FormValue("author"), Text: r.FormValue("text")}}, comments...) + } + + //refresh the end + if (len(comments) == 0) { + end = 0 + } else if (end > int64(len(comments))) { + end = int64(len(comments)) } - // Add a new comment to the in memory slice of comments - comments = append(comments, comment{ID: time.Now().UnixNano() / 1000000, Author: r.FormValue("author"), Text: r.FormValue("text")}) + response := Response{Comments:comments[start:end], Count: len(comments)} - // Marshal the comments to indented json. - commentData, err = json.MarshalIndent(comments, "", " ") + // Marshal the response to indented json. + responseData, err := json.MarshalIndent(response, "", " ") if err != nil { - http.Error(w, fmt.Sprintf("Unable to marshal comments to json: %s", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("Unable to marshal response to json: %s", err), http.StatusInternalServerError) return } // Write out the comments to the file, preserving permissions - err := ioutil.WriteFile(dataFile, commentData, fi.Mode()) - if err != nil { - http.Error(w, fmt.Sprintf("Unable to write comments to data file (%s): %s", dataFile, err), http.StatusInternalServerError) + write_err := writeCommentsToFile(comments, fi) + if write_err != "" { + http.Error(w, write_err, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Access-Control-Allow-Origin", "*") - io.Copy(w, bytes.NewReader(commentData)) + io.Copy(w, bytes.NewReader(responseData)) case "GET": w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Access-Control-Allow-Origin", "*") + + if (len(comments) == 0) { + end = 0 + } else if (end > int64(len(comments))) { + end = int64(len(comments)) + } + + response := Response{Comments:comments[start:end], Count: len(comments)} + // stream the contents of the file to the response - io.Copy(w, bytes.NewReader(commentData)) + responseData, err := json.MarshalIndent(response, "", " ") + if err != nil { + http.Error(w, fmt.Sprintf("Unable to marshal response to json: %s", err), http.StatusInternalServerError) + return + } + + io.Copy(w, bytes.NewReader(responseData)) default: // Don't know the method, so error diff --git a/server.js b/server.js deleted file mode 100644 index b5a7218a..00000000 --- a/server.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * This file provided by Facebook is for non-commercial testing and evaluation - * purposes only. Facebook reserves all rights not expressly granted. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL - * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN - * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -var fs = require('fs'); -var path = require('path'); -var express = require('express'); -var bodyParser = require('body-parser'); -var app = express(); - -var COMMENTS_FILE = path.join(__dirname, 'comments.json'); - -app.set('port', (process.env.PORT || 3000)); - -app.use('/', express.static(path.join(__dirname, 'public'))); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({extended: true})); - -// Additional middleware which will set headers that we need on each request. -app.use(function(req, res, next) { - // Set permissive CORS header - this allows this server to be used only as - // an API server in conjunction with something like webpack-dev-server. - res.setHeader('Access-Control-Allow-Origin', '*'); - - // Disable caching so we'll always get the latest comments. - res.setHeader('Cache-Control', 'no-cache'); - next(); -}); - -app.get('/api/comments', function(req, res) { - fs.readFile(COMMENTS_FILE, function(err, data) { - if (err) { - console.error(err); - process.exit(1); - } - res.json(JSON.parse(data)); - }); -}); - -app.post('/api/comments', function(req, res) { - fs.readFile(COMMENTS_FILE, function(err, data) { - if (err) { - console.error(err); - process.exit(1); - } - var comments = JSON.parse(data); - // NOTE: In a real implementation, we would likely rely on a database or - // some other approach (e.g. UUIDs) to ensure a globally unique id. We'll - // treat Date.now() as unique-enough for our purposes. - var newComment = { - id: Date.now(), - author: req.body.author, - text: req.body.text, - }; - comments.push(newComment); - fs.writeFile(COMMENTS_FILE, JSON.stringify(comments, null, 4), function(err) { - if (err) { - console.error(err); - process.exit(1); - } - res.json(comments); - }); - }); -}); - - -app.listen(app.get('port'), function() { - console.log('Server started: http://localhost:' + app.get('port') + '/'); -}); diff --git a/server.php b/server.php deleted file mode 100644 index 75fae215..00000000 --- a/server.php +++ /dev/null @@ -1,53 +0,0 @@ - round(microtime(true) * 1000), - 'author' => $_POST['author'], - 'text' => $_POST['text'] - ]; - - $comments = json_encode($commentsDecoded, JSON_PRETTY_PRINT); - file_put_contents('comments.json', $comments); - } - header('Content-Type: application/json'); - header('Cache-Control: no-cache'); - header('Access-Control-Allow-Origin: *'); - echo $comments; - } else { - return false; - } -} diff --git a/server.pl b/server.pl deleted file mode 100644 index c3212b9c..00000000 --- a/server.pl +++ /dev/null @@ -1,38 +0,0 @@ -# This file provided by Facebook is for non-commercial testing and evaluation -# purposes only. Facebook reserves all rights not expressly granted. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -# FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -use Time::HiRes qw(gettimeofday); -use Mojolicious::Lite; -use Mojo::JSON qw(encode_json decode_json); - -app->static->paths->[0] = './public'; - -any '/' => sub { $_[0]->reply->static('index.html') }; - -any [qw(GET POST)] => '/api/comments' => sub { - my $self = shift; - my $comments = decode_json (do { local(@ARGV,$/) = 'comments.json';<> }); - $self->res->headers->cache_control('no-cache'); - $self->res->headers->access_control_allow_origin('*'); - - if ($self->req->method eq 'POST') - { - push @$comments, { - id => int(gettimeofday * 1000), - author => $self->param('author'), - text => $self->param('text'), - }; - open my $FILE, '>', 'comments.json'; - print $FILE encode_json($comments); - } - $self->render(json => $comments); -}; -my $port = $ENV{PORT} || 3000; -app->start('daemon', '-l', "http://*:$port"); diff --git a/server.py b/server.py deleted file mode 100644 index 5cf598df..00000000 --- a/server.py +++ /dev/null @@ -1,36 +0,0 @@ -# This file provided by Facebook is for non-commercial testing and evaluation -# purposes only. Facebook reserves all rights not expressly granted. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -# FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import json -import os -import time -from flask import Flask, Response, request - -app = Flask(__name__, static_url_path='', static_folder='public') -app.add_url_rule('/', 'root', lambda: app.send_static_file('index.html')) - -@app.route('/api/comments', methods=['GET', 'POST']) -def comments_handler(): - - with open('comments.json', 'r') as file: - comments = json.loads(file.read()) - - if request.method == 'POST': - newComment = request.form.to_dict() - newComment['id'] = int(time.time() * 1000) - comments.append(newComment) - - with open('comments.json', 'w') as file: - file.write(json.dumps(comments, indent=4, separators=(',', ': '))) - - return Response(json.dumps(comments), mimetype='application/json', headers={'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*'}) - -if __name__ == '__main__': - app.run(port=int(os.environ.get("PORT",3000))) diff --git a/server.rb b/server.rb deleted file mode 100644 index 698f4339..00000000 --- a/server.rb +++ /dev/null @@ -1,49 +0,0 @@ -# This file provided by Facebook is for non-commercial testing and evaluation -# purposes only. Facebook reserves all rights not expressly granted. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -# FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -require 'webrick' -require 'json' - -# default port to 3000 or overwrite with PORT variable by running -# $ PORT=3001 ruby server.rb -port = ENV['PORT'] ? ENV['PORT'].to_i : 3000 - -puts "Server started: http://localhost:#{port}/" - -root = File.expand_path './public' -server = WEBrick::HTTPServer.new Port: port, DocumentRoot: root - -server.mount_proc '/api/comments' do |req, res| - comments = JSON.parse(File.read('./comments.json', encoding: 'UTF-8')) - - if req.request_method == 'POST' - # Assume it's well formed - comment = { id: (Time.now.to_f * 1000).to_i } - req.query.each do |key, value| - comment[key] = value.force_encoding('UTF-8') unless key == 'id' - end - comments << comment - File.write( - './comments.json', - JSON.pretty_generate(comments, indent: ' '), - encoding: 'UTF-8' - ) - end - - # always return json - res['Content-Type'] = 'application/json' - res['Cache-Control'] = 'no-cache' - res['Access-Control-Allow-Origin'] = '*' - res.body = JSON.generate(comments) -end - -trap('INT') { server.shutdown } - -server.start