From 04c68b45d8d559e9615789c71fae29e4c0c1a8f1 Mon Sep 17 00:00:00 2001 From: Ciro Santilli Date: Sun, 30 May 2021 22:14:14 +0100 Subject: [PATCH 001/221] next --- .gitignore | 6 + .gitmodules | 3 - .nowignore | 2 + README.adoc | 316 +--------- app.js | 17 +- components/article/ArticleActions.tsx | 57 ++ components/article/ArticleList.tsx | 103 ++++ components/article/ArticleMeta.tsx | 72 +++ components/article/ArticlePreview.tsx | 157 +++++ components/comment/Comment.tsx | 111 ++++ components/comment/CommentInput.tsx | 90 +++ components/comment/DeleteButton.tsx | 34 ++ components/common/CustomImage.tsx | 21 + components/common/CustomLink.tsx | 31 + components/common/ErrorMessage.tsx | 29 + components/common/Footer.tsx | 54 ++ components/common/Layout.tsx | 14 + components/common/ListErrors.tsx | 15 + components/common/LoadingSpinner.tsx | 31 + components/common/Maybe.tsx | 5 + components/common/NavLink.tsx | 46 ++ components/common/Navbar.tsx | 136 +++++ components/common/Pagination.tsx | 155 +++++ components/editor/ArticleEditor.tsx | 155 +++++ components/editor/TagInput.tsx | 61 ++ components/home/Banner.tsx | 14 + components/home/TabList.tsx | 75 +++ components/home/Tags.tsx | 36 ++ components/profile/EditProfileButton.tsx | 22 + components/profile/FollowUserButton.tsx | 33 + components/profile/LoginForm.tsx | 84 +++ components/profile/ProfileTab.tsx | 30 + components/profile/RegisterForm.tsx | 102 ++++ components/profile/SettingsForm.tsx | 138 +++++ config/index.js | 14 +- config/shared.js | 4 + lib/api/article.ts | 86 +++ lib/api/comment.ts | 32 + lib/api/tag.ts | 8 + lib/api/user.ts | 106 ++++ lib/article.ts | 48 ++ lib/context/PageContext.tsx | 38 ++ lib/context/PageCountContext.tsx | 38 ++ lib/context/index.tsx | 12 + lib/db.ts | 9 + lib/hooks/useSessionStorage.ts | 24 + lib/hooks/useViewport.ts | 26 + lib/types/articleType.ts | 27 + lib/types/commentType.ts | 19 + lib/types/tagType.ts | 3 + lib/utils/calculatePagination.ts | 50 ++ lib/utils/checkLogin.ts | 6 + lib/utils/constant.ts | 11 + lib/utils/fetcher.ts | 23 + lib/utils/getQuery.ts | 2 + lib/utils/handleBrokenImage.ts | 8 + lib/utils/storage.ts | 6 + models/article.js | 4 +- models/comment.js | 4 +- next-env.d.ts | 2 + next-realworld-example-app/.gitignore | 27 + package.json | 38 +- pages/_app.tsx | 34 ++ pages/_document.tsx | 106 ++++ pages/article/[pid].tsx | 197 ++++++ pages/editor/[pid].tsx | 10 + pages/editor/new.tsx | 3 + pages/index.tsx | 109 ++++ pages/profile/[pid].tsx | 109 ++++ pages/user/login.tsx | 31 + pages/user/register.tsx | 32 + pages/user/settings.tsx | 54 ++ public/favicon.ico | Bin 0 -> 1150 bytes react-redux-realworld-example-app | 1 - realworld | 2 +- routes/index.js | 4 +- styles.css | 745 +++++++++++++++++++++++ tsconfig.json | 27 + 78 files changed, 4067 insertions(+), 327 deletions(-) create mode 100644 .nowignore create mode 100644 components/article/ArticleActions.tsx create mode 100644 components/article/ArticleList.tsx create mode 100644 components/article/ArticleMeta.tsx create mode 100644 components/article/ArticlePreview.tsx create mode 100644 components/comment/Comment.tsx create mode 100644 components/comment/CommentInput.tsx create mode 100644 components/comment/DeleteButton.tsx create mode 100644 components/common/CustomImage.tsx create mode 100644 components/common/CustomLink.tsx create mode 100644 components/common/ErrorMessage.tsx create mode 100644 components/common/Footer.tsx create mode 100644 components/common/Layout.tsx create mode 100644 components/common/ListErrors.tsx create mode 100644 components/common/LoadingSpinner.tsx create mode 100644 components/common/Maybe.tsx create mode 100644 components/common/NavLink.tsx create mode 100644 components/common/Navbar.tsx create mode 100644 components/common/Pagination.tsx create mode 100644 components/editor/ArticleEditor.tsx create mode 100644 components/editor/TagInput.tsx create mode 100644 components/home/Banner.tsx create mode 100644 components/home/TabList.tsx create mode 100644 components/home/Tags.tsx create mode 100644 components/profile/EditProfileButton.tsx create mode 100644 components/profile/FollowUserButton.tsx create mode 100644 components/profile/LoginForm.tsx create mode 100644 components/profile/ProfileTab.tsx create mode 100644 components/profile/RegisterForm.tsx create mode 100644 components/profile/SettingsForm.tsx create mode 100644 config/shared.js create mode 100644 lib/api/article.ts create mode 100644 lib/api/comment.ts create mode 100644 lib/api/tag.ts create mode 100644 lib/api/user.ts create mode 100644 lib/article.ts create mode 100644 lib/context/PageContext.tsx create mode 100644 lib/context/PageCountContext.tsx create mode 100644 lib/context/index.tsx create mode 100644 lib/db.ts create mode 100644 lib/hooks/useSessionStorage.ts create mode 100644 lib/hooks/useViewport.ts create mode 100644 lib/types/articleType.ts create mode 100644 lib/types/commentType.ts create mode 100644 lib/types/tagType.ts create mode 100644 lib/utils/calculatePagination.ts create mode 100644 lib/utils/checkLogin.ts create mode 100644 lib/utils/constant.ts create mode 100644 lib/utils/fetcher.ts create mode 100644 lib/utils/getQuery.ts create mode 100644 lib/utils/handleBrokenImage.ts create mode 100644 lib/utils/storage.ts create mode 100644 next-env.d.ts create mode 100644 next-realworld-example-app/.gitignore create mode 100644 pages/_app.tsx create mode 100644 pages/_document.tsx create mode 100644 pages/article/[pid].tsx create mode 100644 pages/editor/[pid].tsx create mode 100644 pages/editor/new.tsx create mode 100644 pages/index.tsx create mode 100644 pages/profile/[pid].tsx create mode 100644 pages/user/login.tsx create mode 100644 pages/user/register.tsx create mode 100644 pages/user/settings.tsx create mode 100644 public/favicon.ico delete mode 160000 react-redux-realworld-example-app create mode 100644 styles.css create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 27e3b0af..5469caf5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,9 @@ package-lock.json # Temporary files *.tmp *.bak + +# next.js +/.next/ +/out/ +/.now/ +.now diff --git a/.gitmodules b/.gitmodules index f30bd358..9d81ce2b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "react-redux-realworld-example-app"] - path = react-redux-realworld-example-app - url = https://github.com/cirosantilli/react-redux-realworld-example-app [submodule "realworld"] path = realworld url = https://github.com/cirosantilli/realworld diff --git a/.nowignore b/.nowignore new file mode 100644 index 00000000..cf79a4a9 --- /dev/null +++ b/.nowignore @@ -0,0 +1,2 @@ +README.md +.next \ No newline at end of file diff --git a/README.adoc b/README.adoc index 6598ec66..6723d47d 100644 --- a/README.adoc +++ b/README.adoc @@ -1,324 +1,46 @@ -= Node.js + Express.js + Sequelize + SQLite/PostgreSQL + React + Redux + Heroku Example Realworld App -:china-dictatorship-media-base: https://raw.githubusercontent.com/cirosantilli/china-dictatorship-media/master -:china-dictatorship-media-base-ignore: {china-dictatorship-media-base} -:idprefix: -:idseparator: - -:sectanchors: -:sectlinks: -:sectnumlevels: 6 -:sectnums: -:toc: macro -:toclevels: 6 -:toc-title: += Node.js + Express.js + Sequelize + SQLite/PostgreSQL + Next.js fullstack static/SSG/ISG Example Realworld App Got it running on <> as of April 2021 at https://cirosantilli-realworld-express.herokuapp.com/ -Includes a working single-process fullstack <> by building the https://github.com/gothinkster/react-redux-realworld-example-app frontend statically and serving it from the public folder of the backend. +This is a branch of https://github.com/cirosantilli/node-express-sequelize-realworld-example-app rebased on top of that master, we show it in a separate GitHub repository just to give it more visibility. -CSS bundling to use https://demo.productionready.io/main.css from the SCSS source is also setup, but blocked on: https://github.com/gothinkster/conduit-bootstrap-template/issues/5#issuecomment-829104220 +Any information that applies to both repositories will be kept in that README. -This started as a fork of: https://github.com/sigoden/node-express-realworld-example-app (deleted on 2021-06-21 probably because of fear of backslash from the Chinese communist party after Ciro created some issues there) which was likely a port of https://github.com/gothinkster/node-express-realworld-example-app both of which are backend implementations of the awesome https://github.com/gothinkster/realworld sigoden was a good starting point, but it notably did not implement any of the many-to-many relations properly in SQL, rather hacking it with strings. We have now implemented those properly with relations (and it was not a breeze partly because sequelize is so quirky). +Ideally we would like to have both demos on the same tree as they share all backend code, but Next.js and NPM impose too many restrictions which we don't have the time to work around. -The `react-redux-realworld-example-app` is stored at link:react-redux-realworld-example-app[] as a submodule, and some small modifications have been made to it from the upstream, they are tracked at: https://github.com/cirosantilli/react-redux-realworld-example-app +This frontend is based on https://github.com/reck1ess/next-realworld-example-app/tree/66003772a42bf71c7b2771119121d4c9cf4b07d4 which was SSR only via API, not SSG. We decided to merge it in-tree rather than keep it a separate submodule because the fullstack implementation means that the barrier between front-end and back-end becomes is quite blurred, e.g. "front-end" files also contain functions with direct backend database access without going through the API. -Website behaviour is intended to match the front and backend upstreams as closely as possible, minus except possible obvious bugs. +Both next and the backend API run on the same express server via a https://nextjs.org/docs/advanced-features/custom-server[Next.js custom server]. -Other versions of this repository include: +The design goals are similar to https://dev.to/givehug/next-js-apollo-client-and-server-on-a-single-express-app-55l6 except that we are trying to implement the Realworld App :-) GraphQL would be ideal, but its need is greatly diminished by SSG. -* Next.js instead of React + Redux: https://github.com/cirosantilli/node-express-sequelize-nextjs-realworld-example-app - -Tested on an Ubuntu 21.04 development host. - -toc::[] +Due to ISR/SSR, the entire website should look exactly the same with and without JavaScript for a logged out user. == Local development with SQLite -..... +.... npm install npm run dev -..... - -The browser automatically pops the development front-end server at http://localhost:4101[] which makes requests to the backend server that runs at http://localhost:3000[]. - -You might also want to generate some test data as mentioned at: <>. - -The SQLite database is located at `db.sqlite3`. - -== Local optimized frontend - -..... -npm install -npm run build -npm start -..... - -The website can now be seen at: http://localhost:3000 - -This setup does not start the front-end development server at http://localhost:4101[], but rather compiles the front-end files statically, and serves them on the public folder of the backend server, giving performance representative performance characteristics of the frontend. - -This setup is much closer to the final type of setup that will run in production, and it runs a single server. - -Running it locally might help debug some front-end deployment issues. - -But otherwise you will just normally use the <> setup instead for development, because this setup lacks important debug features such as: - -* hot code reloading - -This setup still runs on `NODE_ENV=development`, which implies that sqlite is used, so no further database setup is needed for PostgreSQL. This also means however that you might experience different performance characteristics or behaviour not ironed out by Sequelize between SQLite vs PotsgreSQL. - -=== Local run as identical to deployment as possible - -Here we use PostgreSQL instead of SQLite with the prebuilt static frontend. Note that optimized frontend is also used on the SQLite setup described at <>). - -For when you really need to debug some deployment stuff locally - -Setup: - .... -sudo apt install postgresql -# Become able to run psql command without sudo. -sudo -u postgres createuser -s "$(whoami)" -createdb "$(whoami)" +You can now visit http://localhost:3000[] to view the website. Both API and pages are served from that single server. -createdb node_express_sequelize_realworld -psql -c "CREATE ROLE node_express_sequelize_realworld_user with login password 'a'" -psql -c 'GRANT ALL PRIVILEGES ON DATABASE node_express_sequelize_realworld TO node_express_sequelize_realworld_user' -echo "SECRET=$(tr -dc A-Za-z0-9 > .env -.... - -Run: +== Local optimized frontend .... -npm run build -npm run start-prod +npm install +npm run build-dev +npm run start-dev .... -then visit the running website at: http://localhost:3000/ - -To <> for this instance run: - -.... -NODE_ENV=production DATABASE_URL='postgres://node_express_sequelize_realworld_user:a@localhost:5432/node_express_sequelize_realworld' ./bin/generate-demo-data.js --force-production -.... +If you make any changes to the code, at least code under `/pages` for sure, you have to rebuild before they take effect in this mode, as Next.js appears to also run server-only code such as `getStaticPaths` from one of the webpack bundles. == Heroku deployment -First time setup: - -.... -heroku git:remote -a cirosantilli-realworld-express -# Automatically sets DATABASE_URL. -heroku addons:create heroku-postgresql:hobby-dev -# Otherwise the react build picks up the .eslint from this directory, -# which specifies a plugin that is not installed because it is in the -# devDependencies of this package.json... For the love of God, this is -# a deployment, not a CI. -# https://stackoverflow.com/questions/55821078/disable-eslint-that-create-react-app-provides -heroku config:set DISABLE_ESLINT_PLUGIN=true -# Notably to skip ultra-slow sqlite native build. -heroku config:set NPM_CONFIG_PRODUCTION=true YARN_PRODUCTION=true -heroku config:set SECRET="$(tr -dc A-Za-z0-9 > inside Heroku: - -.... -heroku run bash -./bin/generate-demo-data.js --force-production -.... - -We have to run `heroku run bash` instead of `heroku ps:exec` because the second command does not set `DATABASE_URL`: - -* https://stackoverflow.com/questions/62502951/heroku-env-variables-database-url-and-port-not-showing-in-dyno-heroku-psexec/68050303#68050303 -* https://stackoverflow.com/questions/48119289/how-to-get-environment-variables-in-live-heroku-dyno/64951959#64951959 -* https://www.reddit.com/r/rails/comments/ejljxj/how_to_seed_a_postgres_production_database_on/ - -Edit a file in Heroku to debug that you are trying to run manually, e.g. by adding print commands, uses https://github.com/hakash/termit[] minimal https://en.wikipedia.org/wiki/GNU_nano[nano]-like text editor: - -.... -heroku ps:exec -termit app.js -.... - -== Debugging - -=== Step debugging - -For the backend, add `debugger;` to the point of interest, and run as: - -.... -npm run back-inspect -.... - -On the debugger, do a `c` to continue so that the server will start running (impossible to skip automatically: https://stackoverflow.com/questions/16420374/how-to-disable-in-the-node-debugger-break-on-first-line[]), and then trigger your event of interest from the browser: - -.... -npm run front -.... - -=== VERBOSE environment variable - -If you run as: - -.... -VERBOSE=1 npm run dev -.... - -this enables the following extra logs: - -* a log line for every request done - -=== Log database queries done - -.... -DEBUG='sequelize:sql:*' npm run start-prod -.... - -=== Generate demo data - -Note that this will first erase any data present in the database: - -.... -./bin/generate-demo-data.js -.... - -You can then login with users such as: - -* `user0@mail.com` -* `user1@mail.com` - -and password `asdf`. - -Test data size can be configured with CLI parameters, e.g.: - -.... -./bin/generate-demo-data.js --n-users 5 --n-articles-per-user 8 --n-follows-per-user 3 -.... - -=== Prevent the browser from opening automatically - -In case you've broken things so bad that the very first GET blows up the website and further requests don't respond https://stackoverflow.com/questions/61927814/how-to-disable-open-browser-in-cra - -.... -BROWSER=none npm run dev -.... - -This gives you time to setup e.g. Network recording in Chrome Developer Tools to be able to understand what is going on. - -=== Sequelize sometimes does not show the full stack trace - -This is a big problem during development, not sure how to solve it: https://github.com/sequelize/sequelize/issues/8199#issuecomment-863943835 - -== Testing - -=== API tests - -These tests are part of https://github.com/gothinkster/realworld which we track here as a submodule. - -Test test method uses Postman, but we feel that it is not a very good way to do the testing, as it uses JSON formats everywhere with embedded JavaScript, presumably to be edited in some dedicated editor like Jupyter does. It would be much better to just have a pure JavaScript setup instead. - -They test the JSON REST API without the frontend. - -First start the backend server in a terminal: - -.... -npm run back-test -.... - -`npm run back-test` will make our server use a clean one-off in-memory database instead of using the default in-disk development `./db.sqlite3` as done for `npm run back`. - -Then on another terminal: - -.... -npm run test-api -.... - -Run a single test called `Register` instead: - -.... -npm run test-api -- --folder Register -.... - -TODO: many tests depend on previous steps, notably register. But we weren't able to make it run just given specific tests e.g. with: - -.... -npmr test-api -- --folder 'Register' --folder 'Login and Remember Token' --folder 'Create Article' -.... - -only the last `--folder` is used. Some threads say that multiple ones can be used in newer Newman, but even after updating it to latest v5 we couldn't get it to work: - -* https://stackoverflow.com/questions/60057009/how-to-run-single-request-from-the-collection-in-newman -* https://stackoverflow.com/questions/52519415/how-to-read-two-folder-with-newman - -=== Unit tests - -Ideally, all tests should be API test, so that they will work across any backend implementation more easily, and test the system more fully. - -However, setting up full API tests can be annoying, especially the user creation part, as especially since Postman is so clunky. - -Furthermore, the API tests can have a slower setup time, since by going directly to the backend API we can call `bulkCreate` which can be much faster than creating objects one by one. - -So sometimes, especially for things like model relations, we will just revert to a some quick API test: - -.... -npm test -.... - -To run those tests on PostgreSQL intead, first setup as in <>, and then - -.... -NODE_ENV=production DATABASE_URL='postgres://node_express_sequelize_realworld_user:a@localhost:5432/node_express_sequelize_realworld' npm test -.... - -== Benchmarks - -Methodology: - -* time after click event https://stackoverflow.com/questions/67750849/how-to-filter-by-event-type-in-chrome-devtools-profile-tab-e-g-to-see-mouse-cli/67750850#67750850 up until new page renders, not considering any images on the new page, just text -* caches warmed by clicking all pages involved just before the experiment -* hardware: Lenovo ThinkPad P51 -* browser: Chromium 91 - -Results: - -* click from global feed to article -** this repo at 98e628a76b4253bb51ff4a8659305fabfda1b1f8, `npm run dev`: 0.2s -** this repo at 98e628a76b4253bb51ff4a8659305fabfda1b1f8, `npm run start`: 0.2s -** this repo at 98e628a76b4253bb51ff4a8659305fabfda1b1f8 + frontend https://github.com/cirosantilli/next-realworld-example-app/tree/d510e33745966618ee95243ad8f7d3d974adcf14 `npm run dev`: 0.2s -** this repo at 98e628a76b4253bb51ff4a8659305fabfda1b1f8 + frontend https://github.com/cirosantilli/next-realworld-example-app/tree/d510e33745966618ee95243ad8f7d3d974adcf14 `npm run`: 0.2s - -== Bugs - -Notable React Redux upstream bugs: - -* https://github.com/gothinkster/react-redux-realworld-example-app/issues/197 Your Feed pagination is just completely broken. This is not an API bug in this repo. - -== Database conventions - -The naming conventions are meant to be similar to the JavaScript naming conventions: - -* camel case on tables and columns -* tables start with a capital letter, because they are class-like -* columns start with a lowercase letter, because they are field-like -* tables use singular form +We are not sure if Next.js ISR can be deployed reliably due to the ephemeral filesystem such as those in Heroku...: https://stackoverflow.com/questions/67684780/how-to-set-where-the-prerendered-html-of-new-pages-generated-at-runtime-is-store -Achieving this requires fighting a bit with sequelize, which by default produces inconsistent names on foreign keys. +== TODO -== Related projects +=== TODO security -https://github.com/Varun-Hegde/Conduit_NodeJS/tree/99cc32f19a42d74ff9729765772b4676c537a755 some of the <> were failing, and some parts of the code didin't feel as clean as I'd like, so I ended up using https://github.com/sigoden/node-express-realworld-example-app[] as a basis. However I later learnt they did do/attempt to do the many to many relatioships properly unlike sigoden which just hacked with strings. The critical "hard" querry however https://github.com/Varun-Hegde/Conduit_NodeJS/blob/99cc32f19a42d74ff9729765772b4676c537a755/controllers/articles.js#L271[], which finds "posts by users I follow" and which best exercises the ORM's is not done nicel in a single SQL command as achieved in this repository after a lot of suffering. +Use a markdown sanitizer, the `marked` library `sanitize` option was deprecated. diff --git a/app.js b/app.js index 8097646a..852c715c 100644 --- a/app.js +++ b/app.js @@ -8,6 +8,7 @@ const express = require('express') const http = require('http') const methods = require('methods') const morgan = require('morgan') +const next = require('next') const passport = require('passport') const passport_local = require('passport-local'); const path = require('path') @@ -15,6 +16,7 @@ const session = require('express-session') const models = require('./models') const config = require('./config') +const configShared = require('./config/shared') function doStart(app) { const sequelize = models(__dirname); @@ -46,12 +48,13 @@ function doStart(app) { app.use(bodyParser.urlencoded({ extended: false })) app.use(bodyParser.json()) app.use(require('method-override')()) - const buildDir = path.join(__dirname, 'react-redux-realworld-example-app', 'build'); - app.use(express.static(buildDir)); - app.get(new RegExp('^(?!' + config.apiPath + '(/|$))'), function (req, res) { - res.sendFile(path.join(buildDir, 'index.html')); + + // Next handles anythiung outside of /api. + app.get(new RegExp('^(?!' + configShared.apiPath + '(/|$))'), function (req, res) { + return nextHandle(req, res); }); app.use(session({ secret: 'conduit', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false })) + // https://stackoverflow.com/questions/42099925/logging-all-requests-in-node-js-express/64668730#64668730 app.use(require('./routes')) // 404 handler. @@ -92,6 +95,10 @@ function start(cb) { } const app = express() -doStart(app) +const nextApp = next({ dev: !config.isProductionNext }) +const nextHandle = nextApp.getRequestHandler() +nextApp.prepare().then(() => { + doStart(app) +}) module.exports = { app, start } diff --git a/components/article/ArticleActions.tsx b/components/article/ArticleActions.tsx new file mode 100644 index 00000000..17242ae6 --- /dev/null +++ b/components/article/ArticleActions.tsx @@ -0,0 +1,57 @@ +import Router, { useRouter } from "next/router"; +import React from "react"; +import useSWR, { trigger } from "swr"; + +import CustomLink from "../common/CustomLink"; +import checkLogin from "../../lib/utils/checkLogin"; +import ArticleAPI from "../../lib/api/article"; +import { SERVER_BASE_URL } from "../../lib/utils/constant"; +import storage from "../../lib/utils/storage"; +import Maybe from "../common/Maybe"; + +const ArticleActions = ({ article }) => { + const { data: currentUser } = useSWR("user", storage); + const isLoggedIn = checkLogin(currentUser); + const router = useRouter(); + const { + query: { pid }, + } = router; + + const handleDelete = async () => { + if (!isLoggedIn) return; + + const result = window.confirm("Do you really want to delete it?"); + + if (!result) return; + + await ArticleAPI.delete(pid, currentUser?.token); + trigger(`${SERVER_BASE_URL}/articles/${pid}`); + Router.push(`/`); + }; + + const canModify = + isLoggedIn && currentUser?.username === article?.author?.username; + + return ( + + + + Edit Article + + + + + + ); +}; + +export default ArticleActions; diff --git a/components/article/ArticleList.tsx b/components/article/ArticleList.tsx new file mode 100644 index 00000000..52f96f57 --- /dev/null +++ b/components/article/ArticleList.tsx @@ -0,0 +1,103 @@ +import styled from "@emotion/styled"; +import { useRouter } from "next/router"; +import React, { useMemo, useEffect } from "react"; +import useSWR from "swr"; + +import ArticlePreview from "components/article/ArticlePreview"; +import ErrorMessage from "components/common/ErrorMessage"; +import LoadingSpinner from "components/common/LoadingSpinner"; +import Maybe from "components/common/Maybe"; +import Pagination from "components/common/Pagination"; +import { usePageState } from "lib/context/PageContext"; +import { + usePageCountState, + usePageCountDispatch, +} from "lib/context/PageCountContext"; +import { SERVER_BASE_URL, DEFAULT_LIMIT } from "lib/utils/constant"; +import fetcher from "lib/utils/fetcher"; + +const EmptyMessage = styled("div")` + border-top: 1px solid rgba(0, 0, 0, 0.1); + padding: 1.5rem 0; +`; + +const ArticleList = () => { + const page = usePageState(); + const pageCount = usePageCountState(); + const setPageCount = usePageCountDispatch(); + const lastIndex = + pageCount > 480 ? Math.ceil(pageCount / 20) : Math.ceil(pageCount / 20) - 1; + + const router = useRouter(); + const { asPath, pathname, query } = router; + const { favorite, follow, tag, pid } = query; + const isProfilePage = pathname.startsWith(`/profile`); + + const getFetchURL = () => { + switch (true) { + case !!tag: + return `${SERVER_BASE_URL}/articles${asPath}&offset=${ + page * DEFAULT_LIMIT + }`; + case isProfilePage && !!favorite: + return `${SERVER_BASE_URL}/articles?favorited=${encodeURIComponent( + String(pid) + )}&offset=${page * DEFAULT_LIMIT}`; + case isProfilePage && !favorite: + return `${SERVER_BASE_URL}/articles?author=${encodeURIComponent( + String(pid) + )}&offset=${page * DEFAULT_LIMIT}`; + case !isProfilePage && !!follow: + return `${SERVER_BASE_URL}/articles/feed?offset=${ + page * DEFAULT_LIMIT + }`; + default: + return `${SERVER_BASE_URL}/articles?offset=${page * DEFAULT_LIMIT}`; + } + }; + + let fetchURL = useMemo(() => getFetchURL(), [ + favorite, + page, + tag, + isProfilePage, + ]); + + const { data, error } = useSWR(fetchURL, fetcher); + const { articles, articlesCount } = data || { + articles: [], + articlesCount: 0, + }; + + useEffect(() => { + setPageCount(articlesCount); + }, [articlesCount]); + + if (error) return ; + if (!data) return ; + + if (articles?.length === 0) { + return No articles are here... yet.; + } + + return ( + <> + {articles?.map((article) => ( + + ))} + + 20}> + + + + ); +}; + +export default ArticleList; diff --git a/components/article/ArticleMeta.tsx b/components/article/ArticleMeta.tsx new file mode 100644 index 00000000..70042ccf --- /dev/null +++ b/components/article/ArticleMeta.tsx @@ -0,0 +1,72 @@ +import styled from "@emotion/styled"; +import React from "react"; + +import ArticleActions from "components/article/ArticleActions"; +import CustomImage from "components/common/CustomImage"; +import CustomLink from "components/common/CustomLink"; + +const ArticleMetaContainer = styled("div")` + display: block; + position: relative; + font-weight: 300; + margin: 2rem 0 0; +`; + +const ArticleAuthorImage = styled(CustomImage)` + display: inline-block; + vertical-align: middle; + height: 32px; + width: 32px; + border-radius: 30px; +`; + +const ArticleInfo = styled("div")` + display: inline-block; + vertical-align: middle; + margin: 0 1.5rem 0 0.3rem; + line-height: 1rem; +`; + +const ArticleAuthorLink = styled(CustomLink)` + display: block; + font-weight: 500 !important; + color: #fff; +`; + +const ArticleDate = styled("span")` + color: #bbb; + font-size: 0.8rem; + display: block; +`; + +const ArticleMeta = ({ article }) => { + if (!article) return; + + return ( + + + + + + + + {article.author?.username} + + {new Date(article.createdAt).toDateString()} + + + + + ); +}; + +export default ArticleMeta; diff --git a/components/article/ArticlePreview.tsx b/components/article/ArticlePreview.tsx new file mode 100644 index 00000000..26685320 --- /dev/null +++ b/components/article/ArticlePreview.tsx @@ -0,0 +1,157 @@ +import axios from "axios"; +import Link from "next/link"; +import Router from "next/router"; +import React from "react"; +import useSWR from "swr"; + +import CustomLink from "../common/CustomLink"; +import CustomImage from "../common/CustomImage"; +import { usePageDispatch } from "../../lib/context/PageContext"; +import checkLogin from "../../lib/utils/checkLogin"; +import { SERVER_BASE_URL } from "../../lib/utils/constant"; +import storage from "../../lib/utils/storage"; + +const FAVORITED_CLASS = "btn btn-sm btn-primary"; +const NOT_FAVORITED_CLASS = "btn btn-sm btn-outline-primary"; + +const ArticlePreview = ({ article }) => { + const setPage = usePageDispatch(); + + const [preview, setPreview] = React.useState(article); + const [hover, setHover] = React.useState(false); + const [currentIndex, setCurrentIndex] = React.useState(-1); + + const { data: currentUser } = useSWR("user", storage); + const isLoggedIn = checkLogin(currentUser); + + const handleClickFavorite = async (slug) => { + if (!isLoggedIn) { + Router.push(`/user/login`); + return; + } + + setPreview({ + ...preview, + favorited: !preview.favorited, + favoritesCount: preview.favorited + ? preview.favoritesCount - 1 + : preview.favoritesCount + 1, + }); + + try { + if (preview.favorited) { + await axios.delete(`${SERVER_BASE_URL}/articles/${slug}/favorite`, { + headers: { + Authorization: `Token ${currentUser?.token}`, + }, + }); + } else { + await axios.post( + `${SERVER_BASE_URL}/articles/${slug}/favorite`, + {}, + { + headers: { + Authorization: `Token ${currentUser?.token}`, + }, + } + ); + } + } catch (error) { + setPreview({ + ...preview, + favorited: !preview.favorited, + favoritesCount: preview.favorited + ? preview.favoritesCount - 1 + : preview.favoritesCount + 1, + }); + } + }; + + if (!article) return; + + return ( +
+
+ + + + +
+ + setPage(0)}>{preview.author.username} + + + {new Date(preview.createdAt).toDateString()} + +
+ +
+ +
+
+ + +

{preview.title}

+

{preview.description}

+ Read more... +
    + {preview.tagList.map((tag, index) => { + return ( + +
  • e.stopPropagation()} + onMouseOver={() => { + setHover(true); + setCurrentIndex(index); + }} + onMouseLeave={() => { + setHover(false); + setCurrentIndex(-1); + }} + style={{ + borderColor: + hover && currentIndex === index ? "#5cb85c" : "initial", + }} + > + setPage(0)} + > + {tag} + +
  • + + ); + })} +
+
+
+ ); +}; + +export default ArticlePreview; diff --git a/components/comment/Comment.tsx b/components/comment/Comment.tsx new file mode 100644 index 00000000..00c81199 --- /dev/null +++ b/components/comment/Comment.tsx @@ -0,0 +1,111 @@ +import styled from "@emotion/styled"; +import React from "react"; +import useSWR from "swr"; + +import CustomLink from "components/common/CustomLink"; +import CustomImage from "components/common/CustomImage"; +import Maybe from "components/common/Maybe"; +import DeleteButton from "components/comment/DeleteButton"; +import checkLogin from "lib/utils/checkLogin"; +import storage from "lib/utils/storage"; + +const CommentContainer = styled("div")` + position: relative; + display: block; + margin-bottom: 0.75rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.25rem; + box-shadow: none !important; +`; + +const CommentCardBlock = styled("div")` + padding: 1.25rem; + &::after { + content: ""; + display: table; + clear: both; + } +`; + +const CommentContent = styled("p")` + margin-top: 0; + margin-bottom: 0; +`; + +const CommentFooter = styled("div")` + font-size: 0.8rem; + font-weight: 300; + border-top: 1px solid #e5e5e5; + border-radius: 0 0 0.25rem 0.25rem; + padding: 0.75rem 1.25rem; + box-shadow: none !important; + background: #f5f5f5; + + &::after { + content: ""; + display: table; + clear: both; + } +`; + +const CommentAuthorLink = styled(CustomLink)` + display: inline-block; + vertical-align: middle; +`; + +const CommentAuthorImage = styled(CustomImage)` + display: inline-block; + vertical-align: middle; + height: 20px; + width: 20px; + border-radius: 30px; + border: 0; +`; + +const CommentDate = styled("span")` + display: inline-block; + vertical-align: middle; + margin-left: 5px; + color: #bbb; +`; + +const Comment = ({ comment }) => { + const { data: currentUser } = useSWR("user", storage); + const isLoggedIn = checkLogin(currentUser); + const canModify = + isLoggedIn && currentUser?.username === comment?.author?.username; + + return ( + + + {comment.body} + + + + + +   + + {comment.author.username} + + {new Date(comment.createdAt).toDateString()} + + + + + + ); +}; + +export default Comment; diff --git a/components/comment/CommentInput.tsx b/components/comment/CommentInput.tsx new file mode 100644 index 00000000..bffdf892 --- /dev/null +++ b/components/comment/CommentInput.tsx @@ -0,0 +1,90 @@ +import axios from "axios"; +import { useRouter } from "next/router"; +import React from "react"; +import useSWR, { trigger } from "swr"; + +import CustomImage from "../common/CustomImage"; +import CustomLink from "../common/CustomLink"; +import checkLogin from "../../lib/utils/checkLogin"; +import { SERVER_BASE_URL } from "../../lib/utils/constant"; +import storage from "../../lib/utils/storage"; + +const CommentInput = () => { + const { data: currentUser } = useSWR("user", storage); + const isLoggedIn = checkLogin(currentUser); + const router = useRouter(); + const { + query: { pid }, + } = router; + + const [content, setContent] = React.useState(""); + const [isLoading, setLoading] = React.useState(false); + + const handleChange = React.useCallback((e) => { + setContent(e.target.value); + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + await axios.post( + `${SERVER_BASE_URL}/articles/${encodeURIComponent(String(pid))}/comments`, + JSON.stringify({ + comment: { + body: content, + }, + }), + { + headers: { + "Content-Type": "application/json", + Authorization: `Token ${encodeURIComponent(currentUser?.token)}`, + }, + } + ); + setLoading(false); + setContent(""); + trigger(`${SERVER_BASE_URL}/articles/${pid}/comments`); + }; + + if (!isLoggedIn) { + return ( +

+ + Sign in + +  or  + + sign up + +  to add comments on this article. +

+ ); + } + + return ( +
+
+