diff --git a/.eslintrc b/.eslintrc index 4cb52e9..5243825 100644 --- a/.eslintrc +++ b/.eslintrc @@ -34,6 +34,7 @@ "react/prop-types": "off", "react/jsx-props-no-spreading": "off", "import/prefer-default-export": "off", - "no-param-reassign": "off" + "no-param-reassign": "off", + "no-nested-ternary": "off" } } diff --git a/README.md b/README.md index 2bd3510..768bdc8 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ A full-fledged app made with [**Next.js**](https://github.com/zeit/next.js/) and
- [x] Other user profile -- [ ] Posting +- [x] Posting - [ ] PM?
diff --git a/components/post/editor.jsx b/components/post/editor.jsx new file mode 100644 index 0000000..f9bbeba --- /dev/null +++ b/components/post/editor.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useCurrentUser } from '../../lib/hooks'; +import { usePostPages } from './posts'; + +export default function PostEditor() { + const [user] = useCurrentUser(); + const { revalidate } = usePostPages(); + + if (!user) { + return ( +
+ Please sign in to post +
+ ); + } + + async function hanldeSubmit(e) { + e.preventDefault(); + const body = { + content: e.currentTarget.content.value, + }; + if (!e.currentTarget.content.value) return; + e.currentTarget.content.value = ''; + await fetch('/api/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + // revalidate the `post-pages` key in usePostPages + revalidate(); + // Perhaps show a dialog box informing the post has been posted + } + + return ( + <> +
+ + +
+ + ); +} diff --git a/components/post/posts.jsx b/components/post/posts.jsx new file mode 100644 index 0000000..c664186 --- /dev/null +++ b/components/post/posts.jsx @@ -0,0 +1,95 @@ +import React from 'react'; +import useSWR, { useSWRPages } from 'swr'; +import Link from 'next/link'; +import { useUser } from '../../lib/hooks'; +import fetcher from '../../lib/fetch'; + +function Post({ post }) { + const user = useUser(post.creatorId); + return ( + <> + +
+ {user && ( + + + {user.name} + {user.name} + + + )} +

+ {post.content} +

+ {new Date(post.createdAt).toLocaleString()} +
+ + ); +} + +export const usePostPages = ({ creatorId } = {}) => { + const pageKey = `post-pages-${creatorId || 'all'}`; + const limit = 10; + + const hookProps = useSWRPages( + pageKey, + ({ offset, withSWR }) => { + const { data: { posts } = {} } = withSWR(useSWR(`/api/posts?from=${offset || ''}&limit=${limit}&by=${creatorId || ''}`, fetcher)); + if (!posts) return

loading

; + return posts.map((post) => ); + }, + ({ data }) => (data.posts && data.posts.length >= 10 + ? data.posts[data.posts.length - 1].createdAt // offset by date + : null), + [], + ); + + function revalidate() { + // We do not have any way to revalidate all pages right now + // Tracking at https://github.com/zeit/swr/issues/189 + + // TODO: How do we do this? + } + + return { ...hookProps, revalidate }; +}; + +export default function Posts({ creatorId }) { + const { + pages, isLoadingMore, isReachingEnd, loadMore, + } = usePostPages({ creatorId }); + + return ( +
+ {pages} + {!isReachingEnd && ( + + )} +
+ ); +} diff --git a/lib/db.js b/lib/db.js index 70238d0..a7cc28f 100644 --- a/lib/db.js +++ b/lib/db.js @@ -1,16 +1,14 @@ -import { ObjectId } from 'mongodb'; - export async function getUser(req, userId) { const user = await req.db.collection('users').findOne({ - _id: ObjectId(userId), + _id: userId, }); if (!user) return null; const { _id, name, email, bio, profilePicture, emailVerified, } = user; - const isAuth = _id.toString() === req.user?._id.toString(); + const isAuth = _id === req.user?._id; return { - _id: _id.toString(), + _id, name, email: isAuth ? email : null, bio, diff --git a/lib/fetch.js b/lib/fetch.js new file mode 100644 index 0000000..4beb89f --- /dev/null +++ b/lib/fetch.js @@ -0,0 +1 @@ +export default function fetcher(url) { return fetch(url).then((r) => r.json()); } diff --git a/lib/hooks.jsx b/lib/hooks.jsx index 913fc54..4095e95 100644 --- a/lib/hooks.jsx +++ b/lib/hooks.jsx @@ -1,9 +1,13 @@ import useSWR from 'swr'; - -const fetcher = (url) => fetch(url).then((r) => r.json()); +import fetcher from './fetch'; export function useCurrentUser() { const { data, mutate } = useSWR('/api/user', fetcher); - const user = data && data.user; + const user = data?.user; return [user, { mutate }]; } + +export function useUser(id) { + const { data } = useSWR(`/api/users/${id}`, fetcher); + return data?.user; +} diff --git a/lib/passport.js b/lib/passport.js index 9bce765..5f39baa 100644 --- a/lib/passport.js +++ b/lib/passport.js @@ -1,17 +1,16 @@ import passport from 'passport'; import bcrypt from 'bcryptjs'; import { Strategy as LocalStrategy } from 'passport-local'; -import { ObjectId } from 'mongodb'; passport.serializeUser((user, done) => { - done(null, user._id.toString()); + done(null, user._id); }); // passport#160 passport.deserializeUser((req, id, done) => { req.db .collection('users') - .findOne(ObjectId(id)) + .findOne({ _id: id }) .then((user) => done(null, user)); }); diff --git a/middlewares/database.js b/middlewares/database.js index 383b36b..cd03fa3 100644 --- a/middlewares/database.js +++ b/middlewares/database.js @@ -6,9 +6,11 @@ const client = new MongoClient(process.env.MONGODB_URI, { }); export async function setUpDb(db) { - await db + db .collection('tokens') - .createIndex('expireAt', { expireAfterSeconds: 0 }); + .createIndex({ expireAt: -1 }, { expireAfterSeconds: 0 }); + db.collection('posts').createIndex({ createdAt: -1 }); + db.collection('users').createIndex({ email: 1 }, { unique: true }); } export default async function database(req, res, next) { diff --git a/package.json b/package.json index d9cf7e1..be25bc4 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dotenv": "^8.2.0", "mongodb": "^3.5.5", "multer": "^1.4.2", + "nanoid": "^3.1.5", "next": "^9.3.1", "next-connect": "^0.6.1", "next-session": "^3.0.1", diff --git a/pages/api/posts/index.js b/pages/api/posts/index.js new file mode 100644 index 0000000..ed34172 --- /dev/null +++ b/pages/api/posts/index.js @@ -0,0 +1,47 @@ +import nextConnect from 'next-connect'; +import { nanoid } from 'nanoid'; +import middleware from '../../../middlewares/middleware'; + +const handler = nextConnect(); + +handler.use(middleware); + +handler.get(async (req, res) => { + // Pagination: Fetch posts from before the input date or fetch from newest + const from = req.query.from ? new Date(req.query.from) : new Date(); + const creatorId = req.query.by; + const posts = await req.db + .collection('posts') + .find({ + createdAt: { + $lte: from, + }, + ...(creatorId && { creatorId }), + }) + .sort({ createdAt: -1 }) + .limit(parseInt(req.query.limit, 10) || 10) + .toArray(); + res.send({ posts }); +}); + +handler.post(async (req, res) => { + if (!req.user) { + return res.status(401).send('unauthenticated'); + } + + const { content } = req.body; + + if (!content) return res.status(400).send('You must write something'); + + const post = { + _id: nanoid(), + content, + createdAt: new Date(), + creatorId: req.user._id, + }; + + await req.db.collection('posts').insertOne(post); + return res.send(post); +}); + +export default handler; diff --git a/pages/api/users.js b/pages/api/users.js index 5792848..0e1c8b3 100644 --- a/pages/api/users.js +++ b/pages/api/users.js @@ -2,6 +2,7 @@ import nextConnect from 'next-connect'; import isEmail from 'validator/lib/isEmail'; import normalizeEmail from 'validator/lib/normalizeEmail'; import bcrypt from 'bcryptjs'; +import { nanoid } from 'nanoid'; import middleware from '../../middlewares/middleware'; import { extractUser } from '../../lib/api-helpers'; @@ -28,7 +29,13 @@ handler.post(async (req, res) => { const user = await req.db .collection('users') .insertOne({ - email, password: hashedPassword, name, emailVerified: false, bio: '', profilePicture: null, + _id: nanoid(12), + email, + password: hashedPassword, + name, + emailVerified: false, + bio: '', + profilePicture: null, }) .then(({ ops }) => ops[0]); req.logIn(user, (err) => { diff --git a/pages/api/users/[userId]/index.js b/pages/api/users/[userId]/index.js new file mode 100644 index 0000000..06027d1 --- /dev/null +++ b/pages/api/users/[userId]/index.js @@ -0,0 +1,15 @@ + +import nextConnect from 'next-connect'; +import middleware from '../../../../middlewares/middleware'; +import { getUser } from '../../../../lib/db'; + +const handler = nextConnect(); + +handler.use(middleware); + +handler.get(async (req, res) => { + const user = await getUser(req, req.query.userId); + res.send({ user }); +}); + +export default handler; diff --git a/pages/index.jsx b/pages/index.jsx index 6b08484..eb5a346 100644 --- a/pages/index.jsx +++ b/pages/index.jsx @@ -1,5 +1,7 @@ import React from 'react'; import { useCurrentUser } from '../lib/hooks'; +import PostEditor from '../components/post/editor'; +import Posts from '../components/post/posts'; const IndexPage = () => { const [user] = useCurrentUser(); @@ -12,9 +14,12 @@ const IndexPage = () => { text-align: center; color: #888; } + h3 { + color: #555; + } `} -
+

Hello, {' '} @@ -23,6 +28,15 @@ const IndexPage = () => {

Have a wonderful day.

+
+

+ All posts from the Web + {' '} + 🌎 +

+ + +
); }; diff --git a/pages/user/[userId]/index.jsx b/pages/user/[userId]/index.jsx index d321926..38f3f75 100644 --- a/pages/user/[userId]/index.jsx +++ b/pages/user/[userId]/index.jsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import Error from 'next/error'; import middleware from '../../../middlewares/middleware'; import { useCurrentUser } from '../../../lib/hooks'; +import Posts from '../../../components/post/posts'; import { getUser } from '../../../lib/db'; export default function UserPage({ user }) { @@ -30,11 +31,10 @@ export default function UserPage({ user }) { border-radius: 50%; box-shadow: rgba(0, 0, 0, 0.05) 0 10px 20px 1px; margin-right: 1.5rem; + background-color: #f3f3f3; } div { color: #777; - display: flex; - align-items: center; } p { font-family: monospace; @@ -49,7 +49,7 @@ export default function UserPage({ user }) { {name} -
+
{name}
@@ -68,6 +68,10 @@ export default function UserPage({ user }) {

+
+

My posts

+ +
); }