├── .gitattributes ├── next-env.d.ts ├── public ├── twitter.ico └── images │ ├── nextjs.png │ ├── react.png │ ├── twitter.png │ ├── vercel.jpg │ ├── personal.jpg │ └── tailwind.png ├── css ├── tailwind.css └── main.css ├── types └── INextPage.d.ts ├── settings.json ├── components ├── layout │ ├── styles │ │ ├── header.module.css │ │ ├── index.module.css │ │ └── nav.module.css │ ├── index.tsx │ ├── header.tsx │ └── nav.tsx ├── shared │ ├── input │ │ ├── input.module.css │ │ └── index.tsx │ ├── center-content.tsx │ ├── news-widget │ │ └── index.tsx │ ├── who-to-follow-widget │ │ └── index.tsx │ └── tweet │ │ └── index.tsx ├── home-page │ ├── styles │ │ └── tweet-box.module.css │ ├── home-center-component.tsx │ └── tweet-box.tsx ├── signup-page │ ├── singup-modal.module.css │ ├── signup-modal.tsx │ └── signup-component.tsx └── login-page │ └── login-component.tsx ├── custom.d.ts ├── jsconfig.json ├── .prettierrc.json ├── pages ├── 404.tsx ├── _app.tsx ├── _document.tsx ├── signup.tsx ├── login.tsx ├── index.tsx └── home.tsx ├── next.config.js ├── svgs ├── arrow-down.svg ├── tweet.svg ├── gallery.svg ├── gif.svg ├── emojis.svg ├── home.svg ├── poll.svg ├── notification.svg ├── hashtag.svg ├── schedule.svg ├── replies.svg ├── share.svg ├── logo.svg ├── envelope.svg ├── bookmark.svg ├── profile.svg ├── retweet.svg ├── settings.svg ├── heart.svg └── list.svg ├── README.md ├── postcss.config.js ├── .gitignore ├── tsconfig.json ├── tailwind.config.js ├── .eslintrc.json ├── LICENSE ├── hooks └── useMedia.tsx ├── package.json └── fake-data └── tweets.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/twitter.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadou1/twitter-clone-nextjs/HEAD/public/twitter.ico -------------------------------------------------------------------------------- /public/images/nextjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadou1/twitter-clone-nextjs/HEAD/public/images/nextjs.png -------------------------------------------------------------------------------- /public/images/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadou1/twitter-clone-nextjs/HEAD/public/images/react.png -------------------------------------------------------------------------------- /public/images/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadou1/twitter-clone-nextjs/HEAD/public/images/twitter.png -------------------------------------------------------------------------------- /public/images/vercel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadou1/twitter-clone-nextjs/HEAD/public/images/vercel.jpg -------------------------------------------------------------------------------- /css/tailwind.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | -------------------------------------------------------------------------------- /public/images/personal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadou1/twitter-clone-nextjs/HEAD/public/images/personal.jpg -------------------------------------------------------------------------------- /public/images/tailwind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadou1/twitter-clone-nextjs/HEAD/public/images/tailwind.png -------------------------------------------------------------------------------- /types/INextPage.d.ts: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | 3 | export type INextPage = NextPage & { 4 | hideLayout?: boolean; 5 | }; 6 | -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "editor.formatOnSave": true 6 | } 7 | -------------------------------------------------------------------------------- /components/layout/styles/header.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | @apply px-4 max-h-screen sticky top-0; 3 | @screen xl { 4 | width: 275px; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: React.FunctionComponent>; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "~/*": ["./*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /components/layout/styles/index.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | @apply min-h-screen; 3 | @media (min-width: 1000px) { 4 | width: 990px; 5 | } 6 | width: 600px; 7 | max-width: calc(100vw - 97px); 8 | } 9 | -------------------------------------------------------------------------------- /components/shared/input/input.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | @apply bg-dark-lighter border-b-2 rounded-sm text-left; 3 | &:focus-within { 4 | label { 5 | @apply text-primary; 6 | } 7 | @apply border-primary; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /components/home-page/styles/tweet-box.module.css: -------------------------------------------------------------------------------- 1 | .actionbtn { 2 | @apply text-primary p-2 rounded-full; 3 | &:hover { 4 | @apply bg-primary bg-opacity-25; 5 | } 6 | &:focus { 7 | @apply outline-none bg-opacity-50; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "useTabs": true, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "bracketSpacing": true, 8 | "tabWidth": 1, 9 | "arrowParens": "avoid", 10 | "jsxBracketSameLine": true 11 | } 12 | -------------------------------------------------------------------------------- /components/layout/styles/nav.module.css: -------------------------------------------------------------------------------- 1 | .link { 2 | @apply inline-block mb-4 cursor-pointer; 3 | @screen xl { 4 | @apply mb-3; 5 | } 6 | &:hover { 7 | > div { 8 | @apply bg-primary text-primary bg-opacity-25; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | const PageNotFound: NextPage = () => { 3 | return
404 | This page could not be found.
; 4 | }; 5 | 6 | export default PageNotFound; 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack(config) { 3 | config.module.rules.push({ 4 | test: /\.svg$/, 5 | issuer: { 6 | test: /\.(js|ts)x?$/, 7 | }, 8 | use: ["@svgr/webpack"], 9 | }); 10 | 11 | return config; 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /svgs/arrow-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter Clone With NextJS, Tailwindcss and Typescript 2 | 3 | Well, so far only the landing page is cloned, but will add more pages when I get the time 4 | 5 | > If anything isn't written as good as it should be, please notify me, am still new to tailwindcss 6 | 7 | [Next.js](https://nextjs.org/) 8 | [Tailwindcss](https://tailwindcss.com/) 9 | -------------------------------------------------------------------------------- /components/shared/center-content.tsx: -------------------------------------------------------------------------------- 1 | const CenterContent: React.FC = ({ children }) => { 2 | return ( 3 |
4 | 9 | {children} 10 |
11 | ); 12 | }; 13 | 14 | export default CenterContent; 15 | -------------------------------------------------------------------------------- /components/signup-page/singup-modal.module.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | z-index: 51; 3 | @apply bg-dark max-h-screen w-screen h-screen pt-8; 4 | &:focus { 5 | @apply outline-none; 6 | } 7 | @screen sm { 8 | min-height: 600px; 9 | max-height: 600px; 10 | max-width: 600px; 11 | @apply rounded-xl pt-0; 12 | } 13 | } 14 | .overlay { 15 | @apply bg-white bg-opacity-15 fixed top-0 left-0 right-0 bottom-0 z-50; 16 | } 17 | -------------------------------------------------------------------------------- /svgs/tweet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import Layout from '~/components/layout'; 2 | import '~/css/tailwind.css'; 3 | import '~/css/main.css'; 4 | import Head from 'next/head'; 5 | 6 | export default function MyApp({ pageProps, Component }: any) { 7 | const { hideLayout } = Component; 8 | return ( 9 | 10 | 11 | 12 | Twitter 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const purgecss = [ 2 | "@fullhuman/postcss-purgecss", 3 | { 4 | content: ["./components/**/*.{js,ts,tsx}", "./pages/**/*.{js,ts,tsx}"], 5 | defaultExtractor: (content) => content.match(/[\w-/:]+(? 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | ); 16 | } 17 | } 18 | 19 | export default MyDocument; 20 | -------------------------------------------------------------------------------- /pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { INextPage } from '~/types/INextPage'; 3 | import dynamic from 'next/dynamic'; 4 | const SignupModal = dynamic(() => import('~/components/signup-page/signup-modal')); 5 | 6 | const SignupPage: INextPage = () => { 7 | return ( 8 |
9 | 10 | Signup / Twitter Clone 11 | 12 | 13 | 14 |
15 | ); 16 | }; 17 | 18 | SignupPage.hideLayout = true; 19 | export default SignupPage; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | now.json 32 | 33 | .vercel -------------------------------------------------------------------------------- /components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import Header from './header'; 2 | import styles from './styles/index.module.css'; 3 | 4 | export type ILayout = { 5 | hideLayout?: boolean; 6 | }; 7 | 8 | const Layout: React.FC = ({ children, hideLayout = false }) => { 9 | return ( 10 |
11 | {!hideLayout &&
} 12 |
13 | {!hideLayout ?
{children}
: children} 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default Layout; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext", "es2017"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./*"] 19 | } 20 | }, 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /pages/login.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { INextPage } from '~/types/INextPage'; 3 | import dynamic from 'next/dynamic'; 4 | import SignupModal from '~/components/signup-page/signup-modal'; 5 | const LoginComponent = dynamic(() => import('~/components/login-page/login-component')); 6 | 7 | const LoginPage: INextPage = () => { 8 | return ( 9 |
10 | 11 | 12 | 13 | Login / Twitter Clone 14 | 15 | 16 |
17 | ); 18 | }; 19 | 20 | LoginPage.hideLayout = true; 21 | export default LoginPage; 22 | -------------------------------------------------------------------------------- /svgs/gallery.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /svgs/gif.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /svgs/emojis.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | #__next { 2 | display: flex; 3 | flex-direction: column; 4 | min-height: 100vh; 5 | } 6 | 7 | .anim { 8 | @apply transition-all duration-100 ease-linear; 9 | } 10 | .mention, 11 | .hashtag { 12 | @apply text-primary; 13 | &:hover { 14 | @apply underline; 15 | } 16 | } 17 | .min-w-half { 18 | min-width: 50%; 19 | } 20 | 21 | .widget { 22 | @apply bg-dark-lighter rounded-xl overflow-hidden; 23 | width: 320px; 24 | @screen lg { 25 | width: 350px; 26 | } 27 | } 28 | 29 | .absolute-center { 30 | position: absolute; 31 | left: 50%; 32 | top: 50%; 33 | transform: translate(-50%, -50%); 34 | } 35 | -------------------------------------------------------------------------------- /svgs/home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { INextPage } from '../types/INextPage'; 2 | import { useEffect } from 'react'; 3 | import { useRouter } from 'next/router'; 4 | import { NextPageContext } from 'next'; 5 | 6 | // This page is gonna be twitter index page later 7 | const IndexPage: INextPage = () => { 8 | const router = useRouter(); 9 | useEffect(() => { 10 | router.replace('/login'); 11 | }, []); 12 | return null; 13 | }; 14 | 15 | export async function getServerSideProps(ctx: NextPageContext) { 16 | try { 17 | ctx.res?.writeHead(302, { Location: '/login' }); 18 | ctx.res?.end(); 19 | return { 20 | props: {}, 21 | }; 22 | } catch (error) { 23 | console.log(error); 24 | } 25 | } 26 | export default IndexPage; 27 | -------------------------------------------------------------------------------- /svgs/poll.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | import styles from './styles/header.module.css'; 2 | import Link from 'next/link'; 3 | import Logo from '~/svgs/logo.svg'; 4 | import dynamic from 'next/dynamic' 5 | const TwitterNav = dynamic(() => import('./nav'), { ssr: false }) 6 | 7 | const Header: React.FC = () => { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | 15 |
16 |
17 | 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default Header; 25 | -------------------------------------------------------------------------------- /svgs/notification.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /svgs/hashtag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | theme: { 4 | extend: { 5 | colors: { 6 | pinkish: "rgb(224, 36, 94)", 7 | primary: "rgb(29, 161, 242)", 8 | "primary-hover": "rgb(26, 145, 218)", 9 | dark: "rgb(21, 32, 43)", 10 | "dark-lighter": "rgb(25, 39, 52)", 11 | "dark-hover": "rgb(22, 45, 64)", 12 | }, 13 | opacity: { 14 | 0: 0, 15 | 15: "0.15", 16 | 25: "0.25", 17 | 50: "0.50", 18 | 75: "0.75", 19 | 100: "100", 20 | }, 21 | borderRadius: { 22 | none: "0", 23 | sm: "0.125rem", 24 | default: "0.25rem", 25 | md: "0.375rem", 26 | lg: "0.5rem", 27 | full: "9999px", 28 | xl: "1rem", 29 | }, 30 | }, 31 | }, 32 | variants: {}, 33 | plugins: ["tailwindcss", "postcss-preset-env"], 34 | }; 35 | -------------------------------------------------------------------------------- /components/home-page/home-center-component.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic'; 2 | import { tweets } from '../../fake-data/tweets'; 3 | import ReactList from 'react-list'; 4 | const TweetComponent = dynamic(() => import('../shared/tweet'), { ssr: false }); 5 | const TweetBox = dynamic(() => import('./tweet-box'), { ssr: false }); 6 | 7 | const HomeCenterComponent = () => { 8 | return ( 9 |
10 |
11 | Home 12 |
13 | 14 |
15 | } 20 | /> 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default HomeCenterComponent; 27 | -------------------------------------------------------------------------------- /components/signup-page/signup-modal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from 'react-modal'; 2 | import SignupComponent from './signup-component'; 3 | import { useRouter } from 'next/router'; 4 | import styles from './singup-modal.module.css'; 5 | Modal.setAppElement('#__next'); 6 | 7 | const SignupModal: React.FC = () => { 8 | const router = useRouter(); 9 | 10 | // if its a modal page (via query) then overlay click will close it, else it wont do anything (if visited by url) 11 | const isOpen = !!router.query.signup || router.pathname === '/signup'; 12 | const onRequestClose = !!router.query.signup ? () => router.back() : undefined; 13 | return ( 14 | 20 | 21 | 22 | ); 23 | }; 24 | export default SignupModal; 25 | -------------------------------------------------------------------------------- /svgs/schedule.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /svgs/replies.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "plugin:@typescript-eslint/recommended", 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "prettier", 12 | "prettier/@typescript-eslint", 13 | "prettier/react" 14 | ], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaFeatures": { 18 | "jsx": true 19 | }, 20 | "ecmaVersion": 11, 21 | "sourceType": "module" 22 | }, 23 | "plugins": ["react", "@typescript-eslint", "prettier"], 24 | 25 | "rules": { 26 | "react/prop-types": "off", 27 | "@typescript-eslint/explicit-module-boundary-types": "off", 28 | "no-unused-vars": "warn", 29 | "@typescript-eslint/no-explicit-any": "off", 30 | "@typescript-eslint/no-empty-function": "off", 31 | "react/react-in-jsx-scope": "off", 32 | "@typescript-eslint/no-var-requires": "off", 33 | "explicit-module-boundary-types": "off", 34 | "react/no-unescaped-entities": "off", 35 | "react/prefer-stateless-function": 1 36 | }, 37 | "globals": { 38 | "React": "writable" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 mohux 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /hooks/useMedia.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export default function useMedia(breakPoint: number) { 4 | // checking window object to support server side rendering. 5 | const [isMobileOrTablet, setIsMobileOrTablet] = useState( 6 | typeof window !== 'undefined' ? window.innerWidth <= breakPoint : false 7 | ); 8 | 9 | useEffect(() => { 10 | function screenResized() { 11 | // To make sure that the state is only being updated when it has to be 12 | // If its a mobile screen, i dont care how smaller it becomes. 13 | // If its a desktop screen i dont care how larger it becomes. 14 | if (isMobileOrTablet && window.innerWidth > breakPoint) { 15 | setIsMobileOrTablet(false); 16 | } else if (!isMobileOrTablet && window.innerWidth <= breakPoint) { 17 | setIsMobileOrTablet(true); 18 | } 19 | } 20 | window.addEventListener('resize', screenResized); 21 | 22 | // to remove the event listener when this component is unmounted. 23 | return () => window.removeEventListener('resize', screenResized); 24 | }, [isMobileOrTablet]); 25 | 26 | // the return value should be true or false 27 | return isMobileOrTablet; 28 | } 29 | -------------------------------------------------------------------------------- /pages/home.tsx: -------------------------------------------------------------------------------- 1 | import CenterContent from '~/components/shared/center-content'; 2 | import HomeCenterComponent from '~/components/home-page/home-center-component'; 3 | import Head from 'next/head'; 4 | import dynamic from 'next/dynamic'; 5 | import { INextPage } from '~/types/INextPage'; 6 | const NewsWidget = dynamic(() => import('~/components/shared/news-widget'), { ssr: false }); 7 | const WhoToFollowWidget = dynamic(() => import('~/components/shared/who-to-follow-widget'), { 8 | ssr: false, 9 | }); 10 | 11 | const HomePage: INextPage = () => { 12 | return ( 13 |
14 | 15 | Home / Twitter Clone 16 | 17 | 18 | 19 | 20 |
21 |
22 | 26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 |
36 |
37 | ); 38 | }; 39 | 40 | export default HomePage; 41 | -------------------------------------------------------------------------------- /svgs/share.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /svgs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /svgs/envelope.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /svgs/bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /svgs/profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /components/shared/input/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './input.module.css'; 2 | 3 | export type ISelectOption = { 4 | label: string | React.ReactNode | number; 5 | value: string | number; 6 | }; 7 | 8 | export type IInput = { 9 | innerRef?: any; 10 | error?: boolean; 11 | type?: 'select' | 'text' | 'number' | 'password' | 'date' | 'time' | 'email'; 12 | options?: ISelectOption[]; 13 | } & React.InputHTMLAttributes & 14 | React.InputHTMLAttributes; 15 | 16 | // If you want to use it as select, pass type select 17 | const Input: React.FC = ({ 18 | className, 19 | placeholder, 20 | type = 'text', 21 | options = [], 22 | innerRef, 23 | error, 24 | ...props 25 | }) => { 26 | const classname = 27 | className + 28 | ' bg-transparent w-full focus:outline-none text-white bg-dark-lighter text-lg pb-1 px-2'; 29 | return ( 30 |
31 | 36 | {type !== 'select' ? ( 37 | 38 | ) : ( 39 | 46 | )} 47 |
48 | ); 49 | }; 50 | 51 | export default Input; 52 | -------------------------------------------------------------------------------- /svgs/retweet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@svgr/webpack": "^5.4.0", 12 | "@types/react-list": "^0.8.5", 13 | "moment": "^2.27.0", 14 | "next": "^9.4.4", 15 | "react": "16.13.1", 16 | "react-contenteditable": "^3.3.5", 17 | "react-dom": "16.13.1", 18 | "react-hook-form": "^6.0.2", 19 | "react-list": "^0.8.15", 20 | "react-modal": "^3.11.2", 21 | "sanitize-html": "^1.27.0", 22 | "validator": "^13.1.1" 23 | }, 24 | "devDependencies": { 25 | "@types/moment": "^2.13.0", 26 | "@types/node": "^14.0.14", 27 | "@types/react": "^16.9.41", 28 | "@types/react-modal": "^3.10.6", 29 | "@types/sanitize-html": "^1.23.3", 30 | "@types/validator": "^13.1.0", 31 | "@typescript-eslint/eslint-plugin": "^3.6.0", 32 | "@typescript-eslint/parser": "^3.6.0", 33 | "autoprefixer": "^9.8.4", 34 | "eslint": "^7.4.0", 35 | "eslint-config-prettier": "^6.11.0", 36 | "eslint-plugin-prettier": "^3.1.4", 37 | "eslint-plugin-react": "^7.20.3", 38 | "postcss-import": "^12.0.1", 39 | "postcss-nested": "^4.2.1", 40 | "postcss-preset-env": "^6.7.0", 41 | "prettier": "^2.0.5", 42 | "tailwindcss": "^1.4.6", 43 | "typescript": "^3.9.5" 44 | }, 45 | "license": "MIT", 46 | "keywords": [ 47 | "twitter clone", 48 | "nextjs", 49 | "tailwind", 50 | "tailwind nextjs" 51 | ], 52 | "description": "Twitter home page clone with nextjs, tailwind and typescript" 53 | } 54 | -------------------------------------------------------------------------------- /svgs/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 11 | 14 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /svgs/heart.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/shared/news-widget/index.tsx: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export interface INewsItem { 4 | image: string; 5 | title: string; 6 | date: string | Date; 7 | } 8 | 9 | const NewsItem: React.FC = ({ image, title, date }) => { 10 | return
11 |
12 |
13 | 14 | {moment(date).fromNow()} 15 | 16 | {title} 17 |
18 |
19 | 20 |
21 |
22 |
23 | } 24 | 25 | const NewsWidget: React.FC = () => { 26 | return
27 |
28 | 29 | What's happening 30 | 31 |
32 | 33 | 34 | 35 | 36 | 37 |
38 | 39 | Show more 40 | 41 |
42 |
43 | } 44 | 45 | export default NewsWidget; -------------------------------------------------------------------------------- /components/shared/who-to-follow-widget/index.tsx: -------------------------------------------------------------------------------- 1 | export interface INewsItem { 2 | image: string; 3 | username: string; 4 | name: string; 5 | url: string; 6 | } 7 | 8 | const FollowItem: React.FC = ({ image, username, name, url }) => { 9 | return ( 10 |
11 |
12 |
13 | 14 |
15 |
16 | {name} 17 | @{username} 18 |
19 | 24 | Follow 25 | 26 |
27 |
28 | ); 29 | }; 30 | 31 | const WhoToFollowWidget: React.FC = () => { 32 | return ( 33 |
34 |
35 | Who to follow 36 |
37 | 43 | 49 | 55 | 56 |
57 | 58 | Show more 59 | 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default WhoToFollowWidget; 66 | -------------------------------------------------------------------------------- /svgs/list.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 32 | 33 | 35 | 36 | 37 | 38 | 39 | 41 | 42 | 43 | 44 | 45 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /fake-data/tweets.ts: -------------------------------------------------------------------------------- 1 | import { ITweet } from "~/components/shared/tweet/index"; 2 | 3 | export const tweets: ITweet[] = [ 4 | { 5 | id: String(Math.random() * 100000), 6 | description: 7 | " Hey, this is just another clone of twitter, no integration is made, maybe I should continue working on it with a backend as well?", 8 | username: "mhmdou1", 9 | name: "محمد العلبي", 10 | avatar: "/images/personal.jpg", 11 | date: new Date("2020-7-8"), 12 | likes: 511, 13 | replies: 78, 14 | retweets: 32, 15 | }, 16 | { 17 | id: String(Math.random() * 100000), 18 | description: ` Created this page with these two technologies @nextjs & 19 | @tailwind 20 | man I really love them! #nextjs_and_tailwind_rocks`, 21 | username: "mhmdou1", 22 | name: "محمد العلبي", 23 | avatar: "/images/personal.jpg", 24 | date: new Date("2020-7-8"), 25 | likes: 554, 26 | replies: 112, 27 | retweets: 60, 28 | assets: [ 29 | { type: "image", url: "/images/nextjs.png" }, 30 | { type: "image", url: "/images/nextjs.png" }, 31 | { type: "image", url: "/images/nextjs.png" }, 32 | ], 33 | }, 34 | { 35 | id: String(Math.random() * 100000), 36 | description: `Yep we are awesome!`, 37 | username: "tailwindcss", 38 | name: "Tailwind CSS", 39 | avatar: "/images/tailwind.png", 40 | date: new Date("2020-7-8"), 41 | likes: 210, 42 | replies: 78, 43 | retweets: 32, 44 | }, 45 | { 46 | id: String(Math.random() * 100000), 47 | description: `We second it! don't worry you wont ever be able to keep up with our new updates speed.`, 48 | username: "vercel", 49 | name: "Vercel", 50 | avatar: "/images/vercel.jpg", 51 | date: new Date("2020-7-8"), 52 | likes: 199, 53 | replies: 45, 54 | retweets: 22, 55 | }, 56 | { 57 | id: String(Math.random() * 100000), 58 | description: 59 | "Development is easy, it's just hard at the begining, then it becomes a matter of time until you learn another technology. good luck doing that!", 60 | username: "mhmdou1", 61 | name: "محمد العلبي", 62 | avatar: "/images/personal.jpg", 63 | date: new Date("2020-7-8"), 64 | likes: 511, 65 | replies: 78, 66 | retweets: 32, 67 | }, 68 | ]; 69 | -------------------------------------------------------------------------------- /components/layout/nav.tsx: -------------------------------------------------------------------------------- 1 | import Home from '~/svgs/home.svg'; 2 | import Explore from '~/svgs/hashtag.svg'; 3 | import Notification from '~/svgs/notification.svg'; 4 | import Envelope from '~/svgs/envelope.svg'; 5 | import Bookmark from '~/svgs/bookmark.svg'; 6 | import Profile from '~/svgs/profile.svg'; 7 | import Settings from '~/svgs/settings.svg'; 8 | import List from '~/svgs/list.svg'; 9 | import Tweet from '~/svgs/tweet.svg'; 10 | import Link from 'next/link'; 11 | import { useRouter } from 'next/router'; 12 | import styles from './styles/nav.module.css'; 13 | import useMedia from '~/hooks/useMedia'; 14 | const links = [ 15 | { href: '/home', icon: , title: 'Home' }, 16 | { href: '/explore', icon: , title: 'Explore' }, 17 | { href: '/notifications', icon: , title: 'Notifications' }, 18 | { href: '/messages', icon: , title: 'Messages' }, 19 | { href: '/bookmarks', icon: , title: 'Bookmarks' }, 20 | { href: '/lists', icon: , title: 'List' }, 21 | { href: '/profile', icon: , title: 'Profile' }, 22 | ]; 23 | 24 | const TwitterNav = () => { 25 | const router = useRouter(); 26 | const isMobile = useMedia(1280); 27 | const path = router.asPath; 28 | return ( 29 | 60 | ); 61 | }; 62 | 63 | export default TwitterNav; 64 | -------------------------------------------------------------------------------- /components/login-page/login-component.tsx: -------------------------------------------------------------------------------- 1 | import Logo from '~/svgs/logo.svg'; 2 | import Input from '../shared/input'; 3 | import { useForm } from 'react-hook-form'; 4 | import Link from 'next/link'; 5 | import { useRouter } from 'next/router'; 6 | const LoginComponent = () => { 7 | const router = useRouter(); 8 | const { handleSubmit, register, errors } = useForm({ 9 | mode: 'onSubmit', 10 | }); 11 | 12 | const submitForm = (values: any) => { 13 | router.push('/home'); 14 | }; 15 | return ( 16 |
17 | 18 | Log in to Twitter 19 | just type anything 20 |
21 |
22 |
23 | 34 | {errors?.username_or_email?.message && ( 35 | {errors?.username_or_email?.message} 36 | )} 37 |
38 |
39 | 50 | {errors?.password?.message && ( 51 | {errors?.password?.message} 52 | )} 53 |
54 |
55 | 60 |
61 |
62 | 63 | Forgot password? 64 | 65 |
.
66 | 67 | Sign up for Twitter 68 | 69 |
70 |
71 |
72 |
73 | ); 74 | }; 75 | 76 | export default LoginComponent; 77 | -------------------------------------------------------------------------------- /components/home-page/tweet-box.tsx: -------------------------------------------------------------------------------- 1 | import ContentEditable, { ContentEditableEvent } from 'react-contenteditable'; 2 | // import sanitizeHtml from 'sanitize-html'; 3 | import { useState } from 'react'; 4 | import Link from 'next/link'; 5 | import Gallery from '~/svgs/gallery.svg'; 6 | import Gif from '~/svgs/gif.svg'; 7 | import Poll from '~/svgs/poll.svg'; 8 | import Emoji from '~/svgs/emojis.svg'; 9 | import Schedule from '~/svgs/schedule.svg'; 10 | import styles from './styles/tweet-box.module.css'; 11 | const TweetBox: React.FC = () => { 12 | const [tweetData, setTweet] = useState(''); 13 | 14 | const updateTweetData = async (e: ContentEditableEvent) => { 15 | setTweet(e.target.value); 16 | }; 17 | return ( 18 |
19 |
20 | 21 | 22 |
23 |
24 | mhmdou1 29 |
30 |
31 | 32 |
33 |
34 |
35 | {tweetData.length === 0 && "What's happening?"} 36 |
37 | 38 | 47 |
48 |
49 |
50 | 53 | 56 | 59 | 62 | 65 |
66 |
67 | 74 |
75 |
76 |
77 |
78 |
79 |
80 | ); 81 | }; 82 | 83 | export default TweetBox; 84 | -------------------------------------------------------------------------------- /components/shared/tweet/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import moment from 'moment'; 3 | import ArrowDown from '~/svgs/arrow-down.svg'; 4 | import Replies from '~/svgs/replies.svg'; 5 | import Retweet from '~/svgs/retweet.svg'; 6 | import Heart from '~/svgs/heart.svg'; 7 | import Share from '~/svgs/share.svg'; 8 | 9 | export interface ITweet { 10 | id?: string; 11 | avatar?: string; 12 | username?: string; 13 | name?: string; 14 | description?: string; 15 | assets?: { type: string; url: string }[]; 16 | replies?: number; 17 | retweets?: number; 18 | likes?: number; 19 | date?: string | Date; 20 | } 21 | 22 | const TweetComponent: React.FC = props => { 23 | return ( 24 |
25 | 26 | 27 |
28 |
29 | mhmdou1 30 |
31 |
32 | 33 |
34 |
35 |
36 |
37 | 38 | {props.name} 39 | 40 | 41 | {props.username} . {moment(props.date).format('ll')} 42 | 43 |
44 |
45 | 48 |
49 |
50 | 53 |
54 | {props.assets?.map((file, idx) => ( 55 |
1 ? 'w-full lg:w-1/2' : 'w-full' 59 | }`}> 60 | 61 |
62 | ))} 63 |
64 |
65 |
66 |
67 | 68 | {props.replies} 69 |
70 |
71 | 72 | {props.retweets} 73 |
74 |
75 | 76 | {props.likes} 77 |
78 |
79 | 80 |
81 |
82 |
83 |
84 | ); 85 | }; 86 | 87 | export default TweetComponent; 88 | -------------------------------------------------------------------------------- /components/signup-page/signup-component.tsx: -------------------------------------------------------------------------------- 1 | import Logo from '~/svgs/logo.svg'; 2 | import Input from '../shared/input'; 3 | import { useState, useRef, ChangeEvent } from 'react'; 4 | import { useForm } from 'react-hook-form'; 5 | import validator from 'validator'; 6 | const SignupComponent: React.FC = () => { 7 | const [isPhone, setIsPhoneOrEmail] = useState(true); 8 | const { register, unregister, errors, handleSubmit, setValue } = useForm({ 9 | mode: 'onChange', 10 | }); 11 | 12 | // using ref instead of state, to not rerender this component just to update a number 13 | const nameLengthRef = useRef(null); 14 | 15 | const updateNameLength = (e: any) => { 16 | if (nameLengthRef.current) { 17 | nameLengthRef.current.innerHTML = e.target.value.length + '/50'; 18 | } 19 | }; 20 | const togglePhoneOrEmail = () => { 21 | setValue('email', null); 22 | setValue('phone', null); 23 | // * we are just unregistering the phone if email is selected, and unregistering email if phone is selected 24 | if (isPhone) unregister('phone'); 25 | else unregister('email'); 26 | setIsPhoneOrEmail(prev => !prev); 27 | }; 28 | 29 | const submitForm = (values: any) => console.log(values); 30 | 31 | return ( 32 |
33 |
34 |
35 |
36 |
37 | 38 |
39 |
40 | 45 |
46 |
47 |
48 | Create your account 49 |
50 | 58 |
59 |
60 | {errors?.name?.message && ( 61 | {errors.name.message} 62 | )} 63 |
64 | 0/50 65 |
66 |
67 | {isPhone ? ( 68 |
69 | {/* Fake phone validation obivously xD */} 70 | 79 | {errors?.phone?.message && ( 80 | {errors.phone.message} 81 | )} 82 |
83 | ) : ( 84 |
85 | validator.isEmail(value) || 'Please enter a valid email address', 92 | })} 93 | error={!!errors?.email?.message} 94 | /> 95 | {errors?.email?.message && ( 96 | {errors.email.message} 97 | )} 98 |
99 | )} 100 |
101 | 104 | Use {isPhone ? 'email' : 'phone'} instead 105 | 106 |
107 | 108 | Date of birth 109 |

110 | This will not be shown publicly. Confirm your age to receive the appropriate experience. 111 |

112 | 113 |
114 |
115 | 121 |
122 |
123 | 124 |
125 |
126 | 127 |
128 |
129 |
130 |
131 |
132 | ); 133 | }; 134 | export default SignupComponent; 135 | --------------------------------------------------------------------------------