DEV Community

Cover image for Build a twitter clone with Flask and React | PART 3
arnu515
arnu515

Posted on

Build a twitter clone with Flask and React | PART 3

IF YOU HAVEN'T READ PART ONE YET, CLICK HERE

IF YOU HAVEN'T READ PART TWO YET, CLICK HERE*

Deleting Tweets

Now, let's add the ability to delete tweets. Next to every tweet on the right, I want to add a delete button. Also, let's show the author of every tweet too:

// src/components/TweetItem.jsx
import React from "react";

function TweetItem(props) {
    return (
        <div
            className="w3-card w3-border w3-border-gray w3-round-large"
            style={{ marginTop: "2rem" }}>
            <header className="w3-container w3-opacity w3-light-gray" style={{padding: "1rem"}}>@{props.author}</header>
            <div className="w3-container" style={{ padding: "2rem" }}>
                <h2 className="w3-xxlarge">
                    <span className="w3-opacity">{props.title}</span>
                    <button className="w3-right w3-button w3-red w3-large w3-hover-pale-red w3-round-large">Delete</button></h2>
                <div dangerouslySetInnerHTML={{__html: props.content}}/>
            </div>
            <footer className="w3-container w3-center w3-large">
                <button className="w3-button" style={{ marginRight: "2rem" }}>
                    Like
                </button>
                <button className="w3-button" style={{ marginRight: "2rem" }}>
                    Retweet
                </button>
                <button className="w3-button">Reply</button>
            </footer>
        </div>
    );
}

export default TweetItem;
Enter fullscreen mode Exit fullscreen mode

Much better
Alt Text

One user cannot delete another's post right? So, we have to check if the current logged in user is the owner.

But first, we need to add an api route to get the current user in app.py:

@app.route("/api/getcurrentuser")
@jwt_required
def get_current_user():
    uid = get_jwt_identity()
    return jsonify(getUser(uid))
Enter fullscreen mode Exit fullscreen mode

And now, let's allow only the author to delete their post. Add this to TweetItem.jsx where the delete button was:

// ...
{props.isOwner &&
                    <button className="w3-right w3-button w3-red w3-large w3-hover-pale-red w3-round-large">Delete
                    </button>}
// ...
Enter fullscreen mode Exit fullscreen mode

Then, let's update MainPage.jsx

// src/components/MainPage.jsx
import React from "react";
import TweetItem from "./TweetItem";
import Axios from "axios";
import AddTweet from "./AddTweet";

class MainPage extends React.Component {
    state = {tweets: [], currentUser: {username: ""}}

    componentDidMount() {
        Axios.get("/api/tweets").then(res => {
            this.setState({tweets: res.data.reverse()})
        });
        setTimeout(() => {
            Axios.get("/api/getcurrentuser", {
                headers: {
                    Authorization: `Bearer ${localStorage.getItem("token")}`
                }
            }).then(res => {
                this.setState({currentUser: res.data})
            })
        }, 500)
    }

    render() {
        return (
            <React.Fragment>
                <div
                    className="w3-container w3-jumbo"
                    style={{margin: "3rem", paddingLeft: "1rem"}}>
                    <h1>Tweets</h1>
                    <button className="w3-button w3-blue w3-large" onClick={() => {
                        document.getElementById("addTweet").style.display = "block"
                    }}>Add tweet
                    </button>
                </div>
                <AddTweet/>
                <div className="w3-container">
                    {this.state.tweets.length === 0 ?
                        <p className="w3-xlarge w3-opacity" style={{marginLeft: "2rem"}}>No tweets! Create
                            one</p> : this.state.tweets.map((item, index) => {
                            return (
                                <TweetItem
                                    id={item.id}
                                    title={item.title}
                                    content={item.content}
                                    author={item.user.username}
                                    isOwner={this.state.currentUser.username === item.user.username}
                                    key={index}
                                />
                            );
                        })}
                </div>
            </React.Fragment>
        );
    }
}

export default MainPage;
Enter fullscreen mode Exit fullscreen mode

Now, if I sign in with another user:
Alt Text
I cannot see the delete button! Nice!

Now, let's add the functionality:

// src/components/TweetItem.jsx
import React from "react";
import Axios from "axios";

function deleteTweet(tid) {
    Axios.delete("/api/deletetweet/" + tid, {headers: { Authorization: "Bearer " +localStorage.getItem("token") }}).then(res => {
        console.log(res.data)
        window.location.reload();
    })
}

function TweetItem(props) {

    return (
        <div
            className="w3-card w3-border w3-border-gray w3-round-large"
            style={{marginTop: "2rem"}}>
            <header className="w3-container w3-opacity w3-light-gray" style={{padding: "1rem"}}>@{props.author}</header>
            <div className="w3-container" style={{padding: "2rem"}}>
                <h2 className="w3-xxlarge">
                    <span className="w3-opacity">{props.title}</span>
                    {props.isOwner &&
                    <button className="w3-right w3-button w3-red w3-large w3-hover-pale-red w3-round-large" onClick={() => deleteTweet(props.id)}>Delete
                    </button>}
                </h2>
                <div dangerouslySetInnerHTML={{__html: props.content}}/>
            </div>
            <footer className="w3-container w3-center w3-large">
                <button className="w3-button" style={{marginRight: "2rem"}}>
                    Like
                </button>
                <button className="w3-button" style={{marginRight: "2rem"}}>
                    Retweet
                </button>
                <button className="w3-button">Reply</button>
            </footer>
        </div>
    );
}

export default TweetItem;
Enter fullscreen mode Exit fullscreen mode

Also, I made this small change in app.py:

def delTweet(tid):
    try:
        tweet = Tweet.query.get(tid)
        db.session.delete(tweet)
        db.session.commit()
        return True
    except Exception as e:
        print(e)
        return False
Enter fullscreen mode Exit fullscreen mode

Some more form validation

If you noticed in that image, I'll put it here for you:
Alt Text

There is a post with no title and no content! Let's add some client side form validation:

// src/components/AddTweet.jsx
import React from "react";
import {Editor} from "@tinymce/tinymce-react/lib/cjs/main/ts";
import Axios from "axios";
import Alert from "./Alert";

class AddTweet extends React.Component {
    state = {content: "<p>I have to edit this!</p>", titleErr: "", contentErr: "", formErr: ""}

    handleEditorChange = (content, editor) => {
        this.setState({content})
    }

    submitForm = (e) => {
        e.preventDefault()
        if (this.state.content.length === 0) {
            this.setState(
                {contentErr: "Add some data to the content!"}
            )
            return;
        }
        if (document.getElementById("title").value.length === 0) {
            this.setState(
                {titleErr: "Add a title!"}
            )
            return;
        }
        Axios.post("/api/addtweet", {
            title: "document.getElementById(\"title\").value,"
            content: this.state.content
        }, {
            headers: {
                Authorization: "Bearer " + localStorage.getItem("token")
            }
        }).then(res => {
            if (res.data.success) {
                window.location.reload()
            } else {
                this.setState(
                        {formErr: res.data.error }
                    )
            }
        })
    }

    render() {
        return (<div className="w3-modal w3-animate-opacity" id="addTweet">
            <div className="w3-modal-content w3-card">
                <header className="w3-container w3-blue">
                <span className="w3-button w3-display-topright w3-hover-none w3-hover-text-white" onClick={() => {
                    document.getElementById("addTweet").style.display = "none"
                }}>X</span>
                    <h2>Add tweet</h2>
                </header>
                <form className="w3-container" onSubmit={this.submitForm}>
                    {this.state.formErr.length > 0 && <Alert message={this.state.formErr}/>}
                    <div className="w3-section">
                        <p>
                            <label htmlFor="title">Title</label>
                            <input type="text" id="title" className="w3-input w3-border w3-margin-bottom"/>
                            <small className="w3-text-gray">{this.state.titleErr}</small>
                        </p>
                        <p>
                            <Editor
                                initialValue="<p>This is the initial content of the editor</p>"
                                init={{
                                    height: 300,
                                    menubar: false,
                                    statusbar: false,
                                    toolbar_mode: "sliding",
                                    plugins: [
                                        'advlist autolink lists link image imagetools media emoticons preview anchor',
                                        'searchreplace visualblocks code fullscreen',
                                        'insertdatetime media table paste code help wordcount'
                                    ],
                                    toolbar:
                                        'undo redo | formatselect | bold italic underline strikethrough | image anchor media | \
                                        alignleft aligncenter alignright alignjustify | \
                                        outdent indent | bulllist numlist | fullscreen preview | emoticons help',
                                    contextmenu: "bold italic underline indent outdent help"
                                }}
                                onEditorChange={this.handleEditorChange}
                            />
                            <small className="w3-text-gray">{this.state.contentErr}</small>
                        </p>

                        <p>
                            <button type="submit" className="w3-button w3-blue">Post</button>
                        </p>
                    </div>
                </form>
            </div>
        </div>)
    }
}

export default AddTweet
Enter fullscreen mode Exit fullscreen mode

And now, for the server:

# app.py
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
import re
from flask_jwt_extended import JWTManager, create_access_token, jwt_required, get_jwt_identity, \
    jwt_refresh_token_required, create_refresh_token, get_raw_jwt

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///twitter.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db = SQLAlchemy(app)
app.config["JWT_SECRET_KEY"] = "myawesomesecretisnevergonnagiveyouup"
app.config["JWT_BLACKLIST_ENABLED"] = True
app.config["JWT_BLACKLIST_TOKEN_CHECKS"] = ["access", "refresh"]
jwt = JWTManager(app)
CORS(app)


# DB
class User(db.Model):
    id = db.Column(db.Integer,
                   primary_key=True)
    username = db.Column(db.String(24))
    email = db.Column(db.String(64))
    pwd = db.Column(db.String(64))

    # Constructor
    def __init__(self, username, email, pwd):
        self.username = username
        self.email = email
        self.pwd = pwd


def getUsers():
    users = User.query.all()
    return [{"id": i.id, "username": i.username, "email": i.email, "password": i.pwd} for i in users]


def getUser(uid):
    users = User.query.all()
    user = list(filter(lambda x: x.id == uid, users))[0]
    return {"id": user.id, "username": user.username, "email": user.email, "password": user.pwd}


def addUser(username, email, pwd):
    try:
        user = User(username, email, pwd)
        db.session.add(user)
        db.session.commit()
        return True
    except Exception as e:
        print(e)
        return False


def removeUser(uid):
    try:
        user = User.query.get(uid)
        db.session.delete(user)
        db.session.commit()
        return True
    except Exception as e:
        print(e)
        return False


class Tweet(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    uid = db.Column(db.Integer, db.ForeignKey("user.id"))
    user = db.relationship('User', foreign_keys=uid)
    title = db.Column(db.String(256))
    content = db.Column(db.String(2048))


def getTweets():
    tweets = Tweet.query.all()
    return [{"id": i.id, "title": i.title, "content": i.content, "user": getUser(i.uid)} for i in tweets]


def getUserTweets(uid):
    tweets = Tweet.query.all()
    return [{"id": item.id, "userid": item.user_id, "title": item.title, "content": item.content} for item in
            filter(lambda i: i.user_id == uid, tweets)]


def addTweet(title, content, uid):
    try:
        user = list(filter(lambda i: i.id == uid, User.query.all()))[0]
        twt = Tweet(title=title, content=content, user=user)
        db.session.add(twt)
        db.session.commit()
        return True
    except Exception as e:
        print(e)
        return False


def delTweet(tid):
    try:
        tweet = Tweet.query.get(tid)
        db.session.delete(tweet)
        db.session.commit()
        return True
    except Exception as e:
        print(e)
        return False


class InvalidToken(db.Model):
    __tablename__ = "invalid_tokens"
    id = db.Column(db.Integer, primary_key=True)
    jti = db.Column(db.String)

    def save(self):
        db.session.add(self)
        db.session.commit()

    @classmethod
    def is_invalid(cls, jti):
        q = cls.query.filter_by(jti=jti).first()
        return bool(q)


@jwt.token_in_blacklist_loader
def check_if_blacklisted_token(decrypted):
    jti = decrypted["jti"]
    return InvalidToken.is_invalid(jti)


# ROUTES
@app.route("/api/login", methods=["POST"])
def login():
    try:
        email = request.json["email"]
        password = request.json["pwd"]
        if email and password:
            user = list(filter(lambda x: x["email"] == email and x["password"] == password, getUsers()))
            # Check if user exists
            if len(user) == 1:
                token = create_access_token(identity=user[0]["id"])
                refresh_token = create_refresh_token(identity=user[0]["id"])
                return jsonify({"token": token, "refreshToken": refresh_token})
            else:
                return jsonify({"error": "Invalid credentials"})
        else:
            return jsonify({"error": "Invalid form"})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/register", methods=["POST"])
def register():
    try:
        email = request.json["email"]
        email = email.lower()
        password = request.json["pwd"]
        username = request.json["username"]
        if not (email and password and username):
            return jsonify({"error": "Invalid form"})
        # Check to see if user already exists
        users = getUsers()
        if len(list(filter(lambda x: x["email"] == email, users))) == 1:
            return jsonify({"error": "Invalid form"})
        # Email validation check
        if not re.match(r"[\w\._]{5,}@\w{3,}.\w{2,4}", email):
            return jsonify({"error": "Invalid email"})
        addUser(username, email, password)
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/checkiftokenexpire", methods=["POST"])
@jwt_required
def check_if_token_expire():
    return jsonify({"success": True})


@app.route("/api/refreshtoken", methods=["POST"])
@jwt_refresh_token_required
def refresh():
    identity = get_jwt_identity()
    token = create_access_token(identity=identity)
    return jsonify({"token": token})


@app.route("/api/logout/access", methods=["POST"])
@jwt_required
def access_logout():
    jti = get_raw_jwt()["jti"]
    try:
        invalid_token = InvalidToken(jti=jti)
        invalid_token.save()
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return {"error": e}


@app.route("/api/logout/refresh", methods=["POST"])
@jwt_required
def refresh_logout():
    jti = get_raw_jwt()["jti"]
    try:
        invalid_token = InvalidToken(jti=jti)
        invalid_token.save()
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return {"error": e}


@app.route("/api/tweets")
def get_tweets():
    return jsonify(getTweets())


@app.route("/api/addtweet", methods=["POST"])
@jwt_required
def add_tweet():
    try:
        title = request.json["title"]
        content = request.json["content"]
        if not (title and content):
            return jsonify({"error": "Invalid form"})
        uid = get_jwt_identity()
        addTweet(title, content, uid)
        return jsonify({"success": "true"})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/deletetweet/<tid>", methods=["DELETE"])
@jwt_required
def delete_tweet(tid):
    try:
        delTweet(tid)
        return jsonify({"success": "true"})
    except:
        return jsonify({"error": "Invalid form"})


@app.route("/api/getcurrentuser")
@jwt_required
def get_current_user():
    uid = get_jwt_identity()
    return jsonify(getUser(uid))


if __name__ == "__main__":
    app.run(debug=True)
Enter fullscreen mode Exit fullscreen mode

Adding User settings

Now, let's allow the user to change some settings. Let's create a new component called UserSettings.jsx

// src/components/UserSettings.jsx
import React from 'react';
import Alert from "./Alert";
import Axios from "axios";

class UserSettings extends React.Component {

    state = {currentSetting: "main", err: ""} //values: main, cpwd, del

    componentDidMount() {
        if (!localStorage.getItem("token")) {
            window.location = "/login"
        }
    }

    changePassword = (e) => {
        e.preventDefault();
        Axios.post("/api/changepassword", {
            password: document.getElementById("password").value,
            npassword: document.getElementById("npassword").value
        }, {
            headers: {
                Authorization: "Bearer " + localStorage.getItem("token")
            }
        })
            .then(res => {
                if (res.data.error) {
                    this.setState(
                        {err: res.data.error}
                    )
                } else {
                    alert("Password changed! Logging you out...")
                    window.location = "/logout"
                }
            })
    }

    deleteAccount = (e) => {
        e.preventDefault();
        let x = window.confirm("Are you sure you want to delete your account? THIS CANNOT BE UNDONE. ALL OF YOUR POSTS WILL BE DELETED")
        if (x) {
            Axios.delete("/api/deleteaccount", {headers: {Authorization: "Bearer " + localStorage.getItem("token")}})
                .then(res => {
                    if (res.data.error) {
                        alert("An error occurred: " + res.data.error)
                    } else {
                        alert("Your account has been deleted. We're sad to see you go :(. Now, anyone can sign up with your username. Logging you out...")
                        window.location = "/logout"
                    }
                })
        }
    }

    render() {
        return (<div className="w3-container" style={{margin: "3rem"}}>
            <div className="w3-card w3-border w3-round-large">
                <header className="w3-container w3-xlarge w3-blue"
                        style={{padding: "0.5rem", paddingLeft: "3rem"}}>Settings
                </header>
                <div className="w3-container">
                    {this.state.err.length > 0 && <Alert message={this.state.err}/>}
                    {this.state.currentSetting === "main" && <div style={{margin: "1rem"}}>
                        <h1 className="w3-xxlarge">Settings</h1>
                        <hr className="w3-border-top w3-border-black"/>
                        <p>Choose a setting from below:</p>
                        <ul className="w3-ul w3-border w3-hoverable">
                            <li onClick={() => this.setState({currentSetting: "cpwd"})} style={{cursor: "pointer"}}
                                className="w3-hover-light-gray">Change password
                            </li>
                            <li onClick={() => this.setState({currentSetting: "del"})} style={{cursor: "pointer"}}
                                className="w3-text-red w3-hover-pale-red w3-hover-text-red">Delete account
                            </li>
                        </ul>
                    </div>}
                    {this.state.currentSetting === "cpwd" && <div style={{margin: "1rem"}}>
                        <h1 className="w3-xxlarge">Change password</h1>
                        <hr className="w3-border-top w3-border-black"/>
                        <button className="w3-button w3-blue"
                                onClick={() => this.setState({currentSetting: "main"})}>&laquo; Back
                        </button>
                        <form onSubmit={this.changePassword}>
                            <p>
                                <label htmlFor="password">Old password</label>
                                <input type="password" id="password" className="w3-input w3-border"/>
                            </p>
                            <p>
                                <label htmlFor="npassword">New password</label>
                                <input type="password" id="npassword" className="w3-input w3-border"/>
                            </p>
                            <p>
                                <button type="submit" className="w3-button w3-blue">Submit</button>
                            </p>
                        </form>
                    </div>}
                    {this.state.currentSetting == "del" && <div style={{margin: "1rem"}}>
                        <h1 className="w3-xxlarge w3-text-red">Delete account</h1>
                        <hr className="w3-border-top w3-border-black"/>
                        <button className="w3-button w3-blue"
                                onClick={() => this.setState({currentSetting: "main"})}>&laquo; Back
                        </button>
                        <p>

                        <button className="w3-button w3-red w3-large" onClick={this.deleteAccount}>DELETE ACCOUNT</button>
                        </p>
                    </div>}
                </div>
            </div>
        </div>)
    }
}

export default UserSettings;
Enter fullscreen mode Exit fullscreen mode

Now, let's add the routes:

# app.py
# ...
@app.route("/api/changepassword", methods=["POST"])
@jwt_required
def change_password():
    try:
        user = User.query.get(get_jwt_identity())
        if not (request.json["password"] and request.json["npassword"]):
            return jsonify({"error": "Invalid form"})
        if not user.pwd == request.json["password"]:
            return jsonify({"error": "Wrong password"})
        user.pwd = request.json["npassword"]
        db.session.add(user)
        db.session.commit()
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/deleteaccount", methods=["DELETE"])
@jwt_required
def delete_account():
    try:
        user = User.query.get(get_jwt_identity())
        tweets = Tweet.query.all()
        for tweet in tweets:
            if tweet.user.username == user.username:
                delTweet(tweet.id)
        removeUser(user.id)
        return jsonify({"success": True})
    except Exception as e:
        return jsonify({"error": str(e)})
#...
Enter fullscreen mode Exit fullscreen mode

Add this route to App.jsx

<Route path="/settings" exact component={UserSettings} />
// Don't forget to import it
Enter fullscreen mode Exit fullscreen mode

And finally, a user can change their passwords or delete their account. And if they delete their account, all tweets they've made will be deleted.


Better navbar

Notice how the navbar says Login and Register even when you're logged in? Let's fix that:

// src/components/Navbar.jsx
import React from "react";

function Navbar() {
    let x = localStorage.getItem("token")
    let a = {name: x ? "Settings" : "Login", link: x ? "/settings" : "/login"}
    let b = {name: x ? "Logout" : "Register", link: x ? "/logout" : "/register"}

    return (
        <div className="w3-bar w3-black">
            <a className="w3-bar-item w3-button" href="/">
                Quickr
            </a>
            <div style={{ float: "right" }}>
                <a className="w3-bar-item w3-button" href={a.link}>
                    {a.name}
                </a>
                <a className="w3-bar-item w3-button" href={b.link}>
                    {b.name}
                </a>
            </div>
        </div>
    );
}

export default Navbar;
Enter fullscreen mode Exit fullscreen mode

Much better!


404 Route

If a user enters a wrong route like http://localhost:3000/like-this-post, then, he'll only see a navbar and will be puzzled.
So, let's fix this

// src/components/NotFound.jsx
import React from "react";

function NotFount() {
    return (<div className="w3-container w3-center" style={{margin: "3rem"}}>
        <h1 className="w3-jumbo">404</h1>
        <p className="w3-xxlarge">The page you were searching for was not found. Double check your URL and try again</p>
        <button type="button" className="w3-button w3-blue" onClick={() => window.location = "/"}>&laquo; Back</button>
    </div>)
}
Enter fullscreen mode Exit fullscreen mode

And then, we'll add a general route to App.jsx

import React from "react";
import Home from "./Home";
import Navbar from "./Navbar";
import Login from "./Login";
import Register from "./Register";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import MainPage from "./MainPage";
import {check} from "../login";
import Logout from "./Logout";
import UserSettings from "./UserSettings";
import NotFound from "./NotFound"

function App() {
    let [login, setLogin] = React.useState(false);

    check().then(r => setLogin(r))

    return (
        <React.Fragment>
            <Navbar />
            <Router>
                <Switch>
                    <Route path="/" exact>
                        {login ? <MainPage/> : <Home/>}
                    </Route>
                    <Route path="/login" exact component={Login}/>
                    <Route path="/register" exact component={Register}/>
                    <Route path="/logout" exact component={Logout}/>
                    <Route path="/settings" exact component={UserSettings}/>
                    <Route component={NotFound} />
                </Switch>
            </Router>
        </React.Fragment>
    );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Security

Let's say this website grows big. A hacker notices this website and gets access to your database. Now, THE PASSWORDS OF ALL USERS are compromised. And, knowing humans, they've definitely used that password on another, important website, like their bank account. And suddenly, BOOM, bank accounts were compromised, Google accounts were compromised, everyone's data is everywhere, all because your site did not encrypt the passwords.

Nobody wants to be the cause of a CyberWar, right? So, let's fix that. First, we need to install something to hash the passwords. bcrypt is recommended and used by many because of its advanced features. So, let's install it in our backend. (It might already be installed, when you installed flask):

pip install bcrypt
Enter fullscreen mode Exit fullscreen mode

If you're on Debian or any other system that uses apt-get, you need to install:

sudo apt-get install build-essential libffi-dev python-dev
Enter fullscreen mode Exit fullscreen mode

For RHEL or Fedora:

sudo yum install gcc libffi-devel python-devel
Enter fullscreen mode Exit fullscreen mode

What is hashing, or encrypting, you ask? Well, let's say you have a chest. Inside this chest, you have treasure, your password. What we are doing, is we're storing all of these valuable chests in a bunker. So, if someone breaks into it, and trust me, they happen. Even big companies like Adobe were hacked. Fortunately, the passwords were encrypted.

So, what is encrypting? Encrypting is where you put a lock on the chest, and only you have the key. So, nobody can unlock it, except you. There are ways over this though, your key could be compromised, and also, you may be using a weak lock, like Adobe, and people just broke through your lock.

Now, about hashing. The major difference between encrypting and hasing is that hashing is irreversibe. Meaning, once something is hashed, it cannot be de-hashed. This is good, and also trustworthy, since no one except for the user will know his/her password. We'll be using bcrypt, a hashing algorithm, but bcrypt does more than that.

Salting. Salting is where you make the hash more complicated. bcrypt can do that, and that's why it is preferred by many. You can read more about it here

Enough nerdy stuff. Let's get back to programming which is also nerdy, am I right? :P

I'm gonna create a security.py file, which will handle encryption and decryption for us:

# security.py
import bcrypt


def encpwd(pwd):
    return bcrypt.hashpw(pwd.encode(), bcrypt.gensalt()).decode()


def checkpwd(x, y):
    return bcrypt.checkpw(x.encode(), y.encode())
Enter fullscreen mode Exit fullscreen mode

This is a very basic file, that I will expand in the future (no spoilers)! Also, in bcrypt.gensalt(), you can add a parameter in the function for the number of rounds. More rounds = more security and also, more time. On my MacBook Air, it took me 0.5sec to generate a hash with 10 rounds(default) and 85 sec with 20! So, I chose 12, which is default. But, if you have a stronger GPU, then you can go higher.

But, there is one problem with bcrypt, it cannot handle passwords above 72 characters. So, what do we do about the people who sleep on their keyboard when typing a password? We, have to HASH IT EVEN MORE!

# security.py
import bcrypt
import base64
import hashlib


def encpwd(pwd):
    return bcrypt.hashpw(base64.b64encode(hashlib.sha256(pwd.encode()).digest()), bcrypt.gensalt()).decode()


def checkpwd(x, y):
    return bcrypt.checkpw(base64.b64encode(hashlib.sha256(x.encode()).digest()), y.encode())
Enter fullscreen mode Exit fullscreen mode

Now, we can test it using python interactive shell:

$ python
>>> import security
>>> pwd = "password" # a bad password
>>> security.encpwd(pwd)
'$2b$12$68F4aKicE.xpXhajKKtZJOk3fQEeU3izEkOlF0S9OI8Q1XZCbwMxm'
>>> # Woah nobody can guess that is "password"!
>>> pwd = "asdsandkasjndjkasndjksadjaksdkas" * 500
>>> len(pwd) # Very long > 72
16000
>>> security.encpwd(pwd)
'$2b$12$vjKs5EXYaALIUVCw396k0ufh2I21zlsEiRkskRD0YHWP8bC3Vj9ZK'
>>> # It works fine!
Enter fullscreen mode Exit fullscreen mode

And now, you can face the world, not worrying about any hackers - wait a second, who's gonna implement this? Oh, yes, that:

# app.py
# ...
@app.route("/api/login", methods=["POST"])
def login():
    try:
        email = request.json["email"]
        password = request.json["pwd"]
        if email and password:
            user = list(filter(lambda x: x["email"] == email and security.checkpwd(password, x["password"]), getUsers()))
            # Check if user exists
            if len(user) == 1:
                token = create_access_token(identity=user[0]["id"])
                refresh_token = create_refresh_token(identity=user[0]["id"])
                return jsonify({"token": token, "refreshToken": refresh_token})
            else:
                return jsonify({"error": "Invalid credentials"})
        else:
            return jsonify({"error": "Invalid form"})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/register", methods=["POST"])
def register():
    try:
        email = request.json["email"]
        email = email.lower()
        password = security.encpwd(request.json["pwd"])
        username = request.json["username"]
        if not (email and password and username):
            return jsonify({"error": "Invalid form"})
        # Check to see if user already exists
        users = getUsers()
        if len(list(filter(lambda x: x["email"] == email, users))) == 1:
            return jsonify({"error": "Invalid form"})
        # Email validation check
        if not re.match(r"[\w._]{5,}@\w{3,}\.\w{2,4}", email):
            return jsonify({"error": "Invalid email"})
        addUser(username, email, password)
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})
#...
Enter fullscreen mode Exit fullscreen mode

And now, to test, we have to delete and recreate our database

$ rm twitter.db
$ python
>>> import app
>>> app.db.create_all()
Enter fullscreen mode Exit fullscreen mode

And it works! Your database is now secure, but not fully secure. Yes, we can't just encrypt the password, can we? WE GOTTA ENCRYPT EM ALL! Or, just the email and username. But, we need reversible encryption for these, so hashing won't do. But, we have a module named cryptography which has an encryption tool called Fernet. As I explained before, we need a key, so let's generate one.

$ python
>>> from cryptography.fernet import Fernet
>>> Fernet.generate_key().decode()
'key'
Enter fullscreen mode Exit fullscreen mode

Of course, you'd get an actual key. Just pick one that looks secure and add it to our file.. - WOAH WAIT, we can't just add a key to our file, what if we wanted to post it on github, or what if someone gets access to our file, we're goners! Hence, we need to use something called environment variables. These are special variables that are tied to your terminal, and you can set them like this:

export VARNAME=value
Enter fullscreen mode Exit fullscreen mode

and yes, they are generally written in CAPITAL LETTERS.
Note: Windows users have to use set instead of export.
And now, we can set an environment variable, KEY:

export KEY=mykey
Enter fullscreen mode Exit fullscreen mode

It doesn't need quotes. Just type it in

Now, whenever we restart the terminal, we need to set this variable. Annoying, right? We can put it in a file called .env like this:

KEY=mykey
Enter fullscreen mode Exit fullscreen mode

and then, using a package called python-dotenv, we can automatically set those variables when we run our app.

pip install python-dotenv
Enter fullscreen mode Exit fullscreen mode

Add this to the top of app.py:

import dotenv
dotenv.load_dotenv()
Enter fullscreen mode Exit fullscreen mode

And thats it!

But what about GitHub, won't the .env file go to GitHub?

We can add it to the .gitignore file:

backend/.env
Enter fullscreen mode Exit fullscreen mode

and it won't be committed!
Finally, we can add the email encryption:

# security.py
import bcrypt
import base64
import hashlib
import os
from cryptography.fernet import Fernet

e = Fernet(os.getenv("KEY"))


def encpwd(pwd):
    return bcrypt.hashpw(base64.b64encode(hashlib.sha256(pwd.encode()).digest()), bcrypt.gensalt()).decode()


def checkpwd(x, y):
    return bcrypt.checkpw(base64.b64encode(hashlib.sha256(x.encode()).digest()), y.encode())


def enc(txt: str) -> str:
    return e.encrypt(txt.encode()).decode()


def dec(txt: str) -> str:
    return e.decrypt(txt.encode()).decode()
Enter fullscreen mode Exit fullscreen mode

And then, implement that in our app.py

# app.py
# ...
@app.route("/api/login", methods=["POST"])
def login():
    try:
        email = request.json["email"]
        password = request.json["pwd"]
        if email and password:
            user = list(filter(lambda x: security.dec(x["email"]) == email and security.checkpwd(password, x["password"]), getUsers()))
            # Check if user exists
            if len(user) == 1:
                token = create_access_token(identity=user[0]["id"])
                refresh_token = create_refresh_token(identity=user[0]["id"])
                return jsonify({"token": token, "refreshToken": refresh_token})
            else:
                return jsonify({"error": "Invalid credentials"})
        else:
            return jsonify({"error": "Invalid form"})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/register", methods=["POST"])
def register():
    try:
        email = request.json["email"]
        email = email.lower()
        password = security.encpwd(request.json["pwd"])
        username = request.json["username"]
        print(email, password, request.json["pwd"], username)
        if not (email and password and username):
            return jsonify({"error": "Invalid form"})
        # Check to see if user already exists
        users = getUsers()
        if len(list(filter(lambda x: security.dec(x["email"] == email), users))) == 1:
            return jsonify({"error": "Invalid form"})
        # Email validation check
        if not re.match(r"[\w._]{5,}@\w{3,}\.\w{2,4}", email):
            return jsonify({"error": "Invalid email"})
        addUser(username, security.enc(email), password)
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})
# ...
Enter fullscreen mode Exit fullscreen mode

Now, we can delete and recreate our database again, and then let's register.

$ rm twitter.db
$ python
>>> import app
>>> app.db.create_all()
Enter fullscreen mode Exit fullscreen mode

It works! But, how can we check if it truly has encrypted our email or not? We can look at our database using an app called (DB browser for SQLite)[https://sqlitebrowser.org/dl/], which is available for windows, mac and linux. Download and open the app, click on open database at the top,
Alt Text
and then, choose your database file. Then, it will open the database. We can see our tables we created, namely invalid_tokens, user and tweet. Click on the browse data menu and choose the user table. And here, you can see, the email and password are a bunch of gibberish, meaning it worked! And since we're done with security, IT'S TIME TO DEPLOY!


Prepaing for Deployment

We can now deploy our application. First, we have to prepare for deployment.

*Make sure you've committed everything to your git repository, just in case something goes wrong *
Now, we need to build our react application. Run this command:

npm run build
Enter fullscreen mode Exit fullscreen mode

and this should create a brand new build folder in the frontend. Now, move that folder to the backend:

mv build ../backend/
Enter fullscreen mode Exit fullscreen mode

And now, we need to serve the build in our flask application. Add these routes to app.py:

@app.route("/<a>")
def react_routes(a):
    return app.send_static_file("index.html")


@app.route("/")
def react_index():
    return app.send_static_file("index.html")
Enter fullscreen mode Exit fullscreen mode

And now, we need to change where we declared app to:

app = Flask(__name__, static_folder="build", static_url_path="/")
Enter fullscreen mode Exit fullscreen mode

And your app.py should look like:

import dotenv
dotenv.load_dotenv()
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
import re
from flask_jwt_extended import JWTManager, create_access_token, jwt_required, get_jwt_identity, \
    jwt_refresh_token_required, create_refresh_token, get_raw_jwt
import security

app = Flask(__name__, static_folder="build", static_url_path="/")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///twitter.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db = SQLAlchemy(app)
app.config["JWT_SECRET_KEY"] = "myawesomesecretisnevergonnagiveyouup"
app.config["JWT_BLACKLIST_ENABLED"] = True
app.config["JWT_BLACKLIST_TOKEN_CHECKS"] = ["access", "refresh"]
jwt = JWTManager(app)
CORS(app)


# DB
class User(db.Model):
    id = db.Column(db.Integer,
                   primary_key=True)
    username = db.Column(db.String(24))
    email = db.Column(db.String(64))
    pwd = db.Column(db.String(64))

    # Constructor
    def __init__(self, username, email, pwd):
        self.username = username
        self.email = email
        self.pwd = pwd


def getUsers():
    users = User.query.all()
    return [{"id": i.id, "username": i.username, "email": i.email, "password": i.pwd} for i in users]


def getUser(uid):
    users = User.query.all()
    user = list(filter(lambda x: x.id == uid, users))[0]
    return {"id": user.id, "username": user.username, "email": user.email, "password": user.pwd}


def addUser(username, email, pwd):
    try:
        user = User(username, email, pwd)
        db.session.add(user)
        db.session.commit()
        return True
    except Exception as e:
        print(e)
        return False


def removeUser(uid):
    try:
        user = User.query.get(uid)
        db.session.delete(user)
        db.session.commit()
        return True
    except Exception as e:
        print(e)
        return False


class Tweet(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    uid = db.Column(db.Integer, db.ForeignKey("user.id"))
    user = db.relationship('User', foreign_keys=uid)
    title = db.Column(db.String(256))
    content = db.Column(db.String(2048))


def getTweets():
    tweets = Tweet.query.all()
    return [{"id": i.id, "title": i.title, "content": i.content, "user": getUser(i.uid)} for i in tweets]


def getUserTweets(uid):
    tweets = Tweet.query.all()
    return [{"id": item.id, "userid": item.user_id, "title": item.title, "content": item.content} for item in
            filter(lambda i: i.user_id == uid, tweets)]


def addTweet(title, content, uid):
    try:
        user = list(filter(lambda i: i.id == uid, User.query.all()))[0]
        twt = Tweet(title=title, content=content, user=user)
        db.session.add(twt)
        db.session.commit()
        return True
    except Exception as e:
        print(e)
        return False


def delTweet(tid):
    try:
        tweet = Tweet.query.get(tid)
        db.session.delete(tweet)
        db.session.commit()
        return True
    except Exception as e:
        print(e)
        return False


class InvalidToken(db.Model):
    __tablename__ = "invalid_tokens"
    id = db.Column(db.Integer, primary_key=True)
    jti = db.Column(db.String)

    def save(self):
        db.session.add(self)
        db.session.commit()

    @classmethod
    def is_invalid(cls, jti):
        q = cls.query.filter_by(jti=jti).first()
        return bool(q)


@jwt.token_in_blacklist_loader
def check_if_blacklisted_token(decrypted):
    jti = decrypted["jti"]
    return InvalidToken.is_invalid(jti)


# ROUTES
@app.route("/<a>")
def react_routes(a):
    return app.send_static_file("index.html")


@app.route("/")
def react_index():
    return app.send_static_file("index.html")


@app.route("/api/login", methods=["POST"])
def login():
    try:
        email = request.json["email"]
        password = request.json["pwd"]
        if email and password:
            user = list(filter(lambda x: security.dec(x["email"]) == email and security.checkpwd(password, x["password"]), getUsers()))
            # Check if user exists
            if len(user) == 1:
                token = create_access_token(identity=user[0]["id"])
                refresh_token = create_refresh_token(identity=user[0]["id"])
                return jsonify({"token": token, "refreshToken": refresh_token})
            else:
                return jsonify({"error": "Invalid credentials"})
        else:
            return jsonify({"error": "Invalid form"})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/register", methods=["POST"])
def register():
    try:
        email = request.json["email"]
        email = email.lower()
        password = security.encpwd(request.json["pwd"])
        username = request.json["username"]
        print(email, password, request.json["pwd"], username)
        if not (email and password and username):
            return jsonify({"error": "Invalid form"})
        # Check to see if user already exists
        users = getUsers()
        if len(list(filter(lambda x: security.dec(x["email"] == email), users))) == 1:
            return jsonify({"error": "Invalid form"})
        # Email validation check
        if not re.match(r"[\w._]{5,}@\w{3,}\.\w{2,4}", email):
            return jsonify({"error": "Invalid email"})
        addUser(username, security.enc(email), password)
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/checkiftokenexpire", methods=["POST"])
@jwt_required
def check_if_token_expire():
    return jsonify({"success": True})


@app.route("/api/refreshtoken", methods=["POST"])
@jwt_refresh_token_required
def refresh():
    identity = get_jwt_identity()
    token = create_access_token(identity=identity)
    return jsonify({"token": token})


@app.route("/api/logout/access", methods=["POST"])
@jwt_required
def access_logout():
    jti = get_raw_jwt()["jti"]
    try:
        invalid_token = InvalidToken(jti=jti)
        invalid_token.save()
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return {"error": e.message}


@app.route("/api/logout/refresh", methods=["POST"])
@jwt_required
def refresh_logout():
    jti = get_raw_jwt()["jti"]
    try:
        invalid_token = InvalidToken(jti=jti)
        invalid_token.save()
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return {"error": e.message}


@app.route("/api/tweets")
def get_tweets():
    return jsonify(getTweets())


@app.route("/api/addtweet", methods=["POST"])
@jwt_required
def add_tweet():
    try:
        title = request.json["title"]
        content = request.json["content"]
        if not (title and content):
            return jsonify({"error": "Invalid form"})
        uid = get_jwt_identity()
        addTweet(title, content, uid)
        return jsonify({"success": "true"})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/deletetweet/<tid>", methods=["DELETE"])
@jwt_required
def delete_tweet(tid):
    try:
        delTweet(tid)
        return jsonify({"success": "true"})
    except:
        return jsonify({"error": "Invalid form"})


@app.route("/api/getcurrentuser")
@jwt_required
def get_current_user():
    uid = get_jwt_identity()
    return jsonify(getUser(uid))


@app.route("/api/changepassword", methods=["POST"])
@jwt_required
def change_password():
    try:
        user = User.query.get(get_jwt_identity())
        if not (request.json["password"] and request.json["npassword"]):
            return jsonify({"error": "Invalid form"})
        if not user.pwd == request.json["password"]:
            return jsonify({"error": "Wrong password"})
        user.pwd = request.json["npassword"]
        db.session.add(user)
        db.session.commit()
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})


@app.route("/api/deleteaccount", methods=["DELETE"])
@jwt_required
def delete_account():
    try:
        user = User.query.get(get_jwt_identity())
        tweets = Tweet.query.all()
        for tweet in tweets:
            if tweet.user.username == user.username:
                delTweet(tweet.id)
        removeUser(user.id)
        return jsonify({"success": True})
    except Exception as e:
        return jsonify({"error": str(e)})


if __name__ == "__main__":
    app.run(debug=True)
Enter fullscreen mode Exit fullscreen mode

And now, you can stop your frontend server and now visit the flask server at http://localhost:5000 and you should see your React website. Now flask and react are connected together.
You can also remove proxy from the package.json, but you will have to rebuild the application:

cd frontend
rm -r build # if you have it
npm run build
rm -r ../backend/build
mv build ../backend
Enter fullscreen mode Exit fullscreen mode

And now, our app should be working under one server, so now, we can deploy it.


Deploying

I'll show you how you can deploy your application to - Heroku. Don't want to deploy to Heroku? Here's how you can deploy it to a Linux server.

Deploying to Heroku

First, you need to sign up to a free account on Heroku and install the Heroku CLI. Once you've installed the heroku cli, type

heroku login
Enter fullscreen mode Exit fullscreen mode

to login to heroku. Then, let's copy our backend folder to quickr, so here's my folder structure:

application
 | - backend
 | - frontend
 | - quickr
Enter fullscreen mode Exit fullscreen mode

Now, we need to create a git repository in the quickr folder

cd quickr
git init
Enter fullscreen mode Exit fullscreen mode

And now, go to the .gitignore file in the main folder and add quickr/ to the .gitignore. Now, we need to create a Procfile file in the quickr directory. This tells Heroku how to run the app. Type this in the Procfile:

web: gunicorn app:app
Enter fullscreen mode Exit fullscreen mode

What is gunicorn? It allows us to run applications. We need to install it though:

pip install gunicorn
Enter fullscreen mode Exit fullscreen mode

Now, let's refresh our requirements by deleting requirements.txt and then typing

pip freeze > requirements.txt
Enter fullscreen mode Exit fullscreen mode

Remember, we're doing this in the quickr folder, not backend
This is what it should look like:

aniso8601==8.0.0
astroid==2.4.1
bcrypt==3.1.7
certifi==2020.4.5.1
cffi==1.14.0
chardet==3.0.4
click==7.1.2
cryptography==2.9.2
Flask==1.1.2
Flask-Cors==3.0.8
Flask-JWT==0.3.2
Flask-JWT-Extended==3.24.1
Flask-RESTful==0.3.8
Flask-SQLAlchemy==2.4.3
get==2019.4.13
gunicorn==20.0.4
idna==2.9
isort==4.3.21
itsdangerous==1.1.0
Jinja2==2.11.2
lazy-object-proxy==1.4.3
MarkupSafe==1.1.1
mccabe==0.6.1
MouseInfo==0.1.3
Pillow==7.1.2
post==2019.4.13
public==2019.4.13
PyAutoGUI==0.9.50
pycparser==2.20
PyGetWindow==0.0.8
PyJWT==1.4.2
pylint==2.5.2
pymongo==3.10.1
PyMsgBox==1.0.7
pyperclip==1.8.0
PyRect==0.1.4
PyScreeze==0.1.26
python-dotenv==0.13.0
PyTweening==1.0.3
pytz==2020.1
query-string==2019.4.13
requests==2.23.0
rubicon-objc==0.3.1
selenium==3.141.0
six==1.14.0
SQLAlchemy==1.3.17
toml==0.10.0
urllib3==1.25.9
Werkzeug==1.0.1
wrapt==1.12.1
aniso8601==8.0.0
astroid==2.4.1
bcrypt==3.1.7
certifi==2020.4.5.1
cffi==1.14.0
chardet==3.0.4
click==7.1.2
cryptography==2.9.2
Flask==1.1.2
Flask-Cors==3.0.8
Flask-JWT==0.3.2
Flask-JWT-Extended==3.24.1
Flask-RESTful==0.3.8
Flask-SQLAlchemy==2.4.3
get==2019.4.13
idna==2.9
isort==4.3.21
itsdangerous==1.1.0
Jinja2==2.11.2
lazy-object-proxy==1.4.3
MarkupSafe==1.1.1
mccabe==0.6.1
MouseInfo==0.1.3
Pillow==7.1.2
post==2019.4.13
public==2019.4.13
PyAutoGUI==0.9.50
pycparser==2.20
PyGetWindow==0.0.8
PyJWT==1.4.2
pylint==2.5.2
pymongo==3.10.1
PyMsgBox==1.0.7
pyperclip==1.8.0
PyRect==0.1.4
PyScreeze==0.1.26
python-dotenv==0.13.0
PyTweening==1.0.3
pytz==2020.1
query-string==2019.4.13
requests==2.23.0
rubicon-objc==0.3.1
selenium==3.141.0
six==1.14.0
SQLAlchemy==1.3.17
toml==0.10.0
urllib3==1.25.9
Werkzeug==1.0.1
wrapt==1.12.1
Enter fullscreen mode Exit fullscreen mode

Now, we need to add a .gitignore file in the quickr folder. Type this in it:

venv/
.env/
Enter fullscreen mode Exit fullscreen mode

And finally, we're ready to deploy!
First, commit everything:

git add .
git commit -m "Init"
Enter fullscreen mode Exit fullscreen mode

And then create the heroku app

heroku create appname
Enter fullscreen mode Exit fullscreen mode

If your app is taken, choose another name!
Then, type git remote -v to check if your app was created successfully:

$ git remote -v
heroku  https://git.heroku.com/appname.git (fetch)
heroku  https://git.heroku.com/appname.git (push)
Enter fullscreen mode Exit fullscreen mode

We're ready to deploy! Type:

git push heroku master
Enter fullscreen mode Exit fullscreen mode

and your app should deploy!
Now, you can view your website at appname.herokuapp.com. If your app crashed, you can see its logs by typing

heroku logs --tail
Enter fullscreen mode Exit fullscreen mode

Your app most likely crashed or the Register didn't work. And that's because it doesn't know about the .env! The KEY attribute will return False. We have to fix this! Hop over to the Heroku Dashboard and select your app. Now, click on settings and then "Reveal config vars". We need to add a variable called KEY and give it the value in the .env.

I also noticed a bug in the register route of app.py

@app.route("/api/register", methods=["POST"])
def register():
    try:
        email = request.json["email"]
        email = email.lower()
        password = security.encpwd(request.json["pwd"])
        username = request.json["username"]
        print(email, password, request.json["pwd"], username)
        if not (email and password and username):
            return jsonify({"error": "Invalid form"})
        # Check to see if user already exists
        users = getUsers()
        if len(list(filter(lambda x: security.dec(x["email"]) == email, users))) == 1: # this line had the bug
            return jsonify({"error": "Invalid form"})
        # Email validation check
        if not re.match(r"[\w._]{5,}@\w{3,}\.\w{2,4}", email):
            return jsonify({"error": "Invalid email"})
        addUser(username, security.enc(email), password)
        return jsonify({"success": True})
    except Exception as e:
        print(e)
        return jsonify({"error": "Invalid form"})
Enter fullscreen mode Exit fullscreen mode

Alt Text
And now, when you click "Add", your app should restart and work fine.

And we're done! Whew!

But what about the like, retweet, replies, editing tweets, admin panel and for keyboard's sake, dark mode?
Well, I'll be adding some short posts to this series in the future. Until then, stay tuned!

Code on GitHub: here
If I find any bugs, I'll be updating the code on GitHub, not here. So, if you find bugs, submit an issue. Anyway, thanks for spending >1hr of estimated time for reading my post. Means a lot to me :)

Part 4 is out! This part I add a dark theme. It is very short. I am also planning to do many more parts where we add more functionality, like the Like button, Retweets, Replies, Editing, an admin panel and much more!

Top comments (3)

Collapse
 
arnu515 profile image
arnu515

PART 4 IS NOW OUT!
Here, I add a dark theme. It is a short ~2min read, so go check it out!

Collapse
 
arnu515 profile image
arnu515

For those of you wondering, the app is live at quickr.herokuapp.com

Collapse
 
bayuangora profile image
Bayu Angora

This link should be added on readme repo.