├── .gitignore ├── LICENSE ├── README.md ├── components ├── EnterPopular.js ├── Header.js ├── ImagePreview.js ├── MainTweet.js ├── Preview.js ├── SubTweet.js └── icons │ ├── Like.js │ ├── Link.js │ ├── Reply.js │ ├── Retweet.js │ ├── Share.js │ └── Spinner.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── api │ ├── auth │ │ └── [...auth0].js │ ├── constants.js │ ├── fetchOgp.js │ ├── index.js │ └── scrape.js ├── done.js ├── edit.js └── index.js ├── postcss.config.js ├── public ├── favicon.ico └── images │ ├── banner.jpg │ ├── hashnode.png │ ├── logo.jpg │ ├── screenshots │ ├── done-screen.png │ ├── edit-screen.png │ └── twitter-auth.png │ └── wishpond.png ├── styles ├── Preview.module.css └── globals.css ├── tailwind.config.js └── utils ├── checkValidUrl.js ├── countChars.js ├── extractTwitterEntities.js ├── getTodaysDate.js └── useLocalStorage.js /.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 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Soumya Ranjan Mohanty 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hashnode to Twitter 2 | 3 | This app allows you to post bite-sized tweets summarizing your [Hashnode](https://hashnode.com) blog post. 4 | 5 | **[Live Demo](https://hashnode-to-twitter.vercel.app)** 6 | 7 | To know more about its features and how I built this app on [my Hashnode blog](https://geekysrm.hashnode.dev/introducing-hashnode-to-twitter). 8 | 9 | ## Video Demo 10 | 11 | 12 | 13 | ## Screenshots 14 | 15 | ### Homepage 16 | home page 17 | 18 | 19 | ### Edit and Preview Tweets screen 20 | edit-screen 21 | 22 | 23 | ### Success Screen 24 | done-screen 25 | 26 | 27 | 28 | ## How to use 29 | 1. On the homepage of the app, login into Twitter. 30 | 2. Enter the URL of your Hashnode blog post and click on Fetch and Tweet. 31 | 3. On the Edit page, customize the tweets to your liking. 32 | 4. After editing and being satisfied with the preview, click on Tweet. 33 | 5. Your tweets will be tweeted in few seconds and you will be sent to the success screen. 34 | 6. On the success/done screen, you can see the posted tweets embedded. 35 | 36 | ## Tech used 37 | - Next.js 38 | - Tailwind CSS 39 | - Auth0 40 | - Twitter API 41 | 42 | ## How to run locally 43 | _Node.JS and npm must be installed. Download and install them from [here](https://nodejs.org/)._ 44 | 45 | Follow these steps to run this project in your local computer. 46 | 47 | ``` 48 | $ git clone https://github.com/geekysrm/hashnode-to-twitter.git 49 | $ cd hashnode-to-twitter 50 | $ npm i 51 | $ npm run dev 52 | ``` 53 | Dev server will be running on port 3000. 54 | 55 | ## Contributions 56 | All contributions are welcome. Bugs and feedback can be raised on the Issues tab. 57 | 58 | ## Support 59 | If you like this and want to support my open-source work, please [buy me a coffee](https://coffee.soumya.dev/). 60 | 61 | -------------------------------------------------------------------------------- /components/EnterPopular.js: -------------------------------------------------------------------------------- 1 | import isEmoji from "isemoji"; 2 | import countChars from "../utils/countChars"; 3 | 4 | export default function EnterPopular({ 5 | idx, 6 | text, 7 | toAdd, 8 | editorTexts, 9 | setEditorTexts, 10 | }) { 11 | return ( 12 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /components/Header.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | import { FaGithub } from "react-icons/fa"; 3 | import Link from "next/link"; 4 | 5 | export default function Header({ user }) { 6 | const node = useRef(); 7 | 8 | const [open, setOpen] = useState(false); 9 | const handleClickOutside = (e) => { 10 | if (node.current.contains(e.target)) { 11 | // inside click 12 | return; 13 | } 14 | // outside click 15 | setOpen(false); 16 | }; 17 | 18 | useEffect(() => { 19 | if (open) { 20 | document.addEventListener("mousedown", handleClickOutside); 21 | } else { 22 | document.removeEventListener("mousedown", handleClickOutside); 23 | } 24 | 25 | return () => { 26 | document.removeEventListener("mousedown", handleClickOutside); 27 | }; 28 | }, [open]); 29 | 30 | return ( 31 |
32 |
33 |
34 |
35 | 36 | 37 | Hashnode to Twitter 38 | 43 | 44 | 45 |
46 | 47 |
48 | 54 | 60 | 61 | 62 | {user ? ( 63 |
64 |
65 |
66 | 78 |
79 | 80 | {open && ( 81 |
85 |
86 |

87 | {user.name} 88 |

89 |

90 | @{user.nickname} 91 |

92 |
93 | 94 | 95 | 101 | Log out 102 | 103 | 104 |
105 | )} 106 |
107 |
108 | ) : ( 109 | 110 | 111 | Login 112 | 113 | 114 | )} 115 |
116 |
117 |
118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /components/ImagePreview.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useEffect, useState } from "react"; 3 | import Link from "./icons/Link"; 4 | 5 | export default function ImagePreview({ link }) { 6 | if (!link || !link.includes("http")) { 7 | return <>; 8 | } 9 | const [ogpData, setOgpData] = useState(null); 10 | 11 | useEffect(() => { 12 | axios 13 | .post("/api/fetchOgp", { url: link }) 14 | .then((res) => { 15 | const { data } = res; 16 | if (data) { 17 | setOgpData(data.ogp); 18 | } 19 | }) 20 | .catch((err) => setOgpData(null)); 21 | }, []); 22 | 23 | useEffect(() => { 24 | axios.post("/api/fetchOgp", { url: link }).then((res) => { 25 | const { data } = res; 26 | if (data) { 27 | setOgpData(data.ogp); 28 | } 29 | }); 30 | }, [link]); 31 | 32 | let title = "", 33 | description = "", 34 | imageUrl = ""; 35 | if (ogpData) { 36 | title = ogpData?.twitter_card?.title || ogpData.title; 37 | description = ogpData?.twitter_card?.description || ogpData.description; 38 | if (description?.length >= 100) { 39 | description = description.substring(0, 95) + "..."; 40 | } 41 | imageUrl = 42 | ogpData?.twitter_card?.images[0]?.url || 43 | ogpData?.open_graph?.images[0]?.url || 44 | ""; 45 | } 46 | return ogpData && imageUrl ? ( 47 |
48 | 54 |
55 | 56 |
57 |

{title}

58 |

{description}

59 |

60 | 61 | 62 | 63 |

{link}

64 |

65 |
66 |
67 |
68 |
69 | ) : ( 70 | <> 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /components/MainTweet.js: -------------------------------------------------------------------------------- 1 | import styles from "../styles/Preview.module.css"; 2 | import { FiChevronDown } from "react-icons/fi"; 3 | import twemoji from "twemoji"; 4 | import getTodaysDate from "../utils/getTodaysDate"; 5 | import Reply from "./icons/Reply"; 6 | import Retweet from "./icons/Retweet"; 7 | import Share from "./icons/Share"; 8 | import Like from "./icons/Like"; 9 | import ImagePreview from "./ImagePreview"; 10 | 11 | const MainTweet = ({ tweet: oldTweet, user, lastLink }) => { 12 | const tweet = twemoji.parse(oldTweet); 13 | const { date, time } = getTodaysDate(); 14 | return ( 15 |
16 |
19 |
20 | 21 |
22 |
{user.name}
23 |
@{user.nickname}
24 |
25 | 26 | 27 | 28 |
29 |
35 | 36 |
37 | 38 | {time} • {date} 39 | {" "} 40 | • hashnode-to-twitter 41 |
42 |
43 |
44 | 10.5K Retweets 45 |
46 |
47 | 21.2K Likes 48 |
49 |
50 |
51 |
52 |
53 | 54 |
55 |
56 | 57 |
58 |
59 | 60 |
61 |
62 | 63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | export default MainTweet; 70 | -------------------------------------------------------------------------------- /components/Preview.js: -------------------------------------------------------------------------------- 1 | import styles from "../styles/Preview.module.css"; 2 | import { useState } from "react"; 3 | import { FaTwitter } from "react-icons/fa"; 4 | import { useRouter } from "next/router"; 5 | import axios from "axios"; 6 | import MainTweet from "./MainTweet"; 7 | import SubTweet from "./SubTweet"; 8 | import { getTweetHtml } from "../utils/extractTwitterEntities"; 9 | import useLocalStorage from "../utils/useLocalStorage"; 10 | 11 | const Preview = ({ tweets, user }) => { 12 | const router = useRouter(); 13 | const [loading, setLoading] = useState(false); 14 | const [postedTweetsString, setpostedTweetsString] = useLocalStorage( 15 | "postedTweetsString", 16 | "" 17 | ); 18 | 19 | const originalMainTweet = tweets[0]; 20 | const { tweetHtml: mainTweet, lastLink } = getTweetHtml(originalMainTweet); 21 | let restTweets = []; 22 | for (let i = 1; i < tweets.length; i++) { 23 | const originalSubTweet = tweets[i]; 24 | const { tweetHtml: subTweet, lastLink } = getTweetHtml(originalSubTweet); 25 | restTweets.push({ subTweet, lastLink }); 26 | } 27 | 28 | return ( 29 |
30 |
31 | 32 | 33 |
34 |
35 |

36 |

Thread

37 |

(Preview)

38 |

39 |
40 | 41 | {restTweets.map((tweet) => { 42 | return ( 43 | 49 | ); 50 | })} 51 | 52 |
53 | {(loading && tweets?.length) > 6 && ( 54 |

55 | Posting first 6 tweets... 56 |

57 | )} 58 | 78 |
79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | export default Preview; 86 | -------------------------------------------------------------------------------- /components/SubTweet.js: -------------------------------------------------------------------------------- 1 | import styles from "../styles/Preview.module.css"; 2 | import { FiChevronDown } from "react-icons/fi"; 3 | import twemoji from "twemoji"; 4 | import Reply from "./icons/Reply"; 5 | import Retweet from "./icons/Retweet"; 6 | import Share from "./icons/Share"; 7 | import Like from "./icons/Like"; 8 | import ImagePreview from "./ImagePreview"; 9 | 10 | const SubTweet = ({ tweet: oldTweet, user, lastLink }) => { 11 | const tweet = twemoji.parse(oldTweet); 12 | 13 | return ( 14 |
15 |
16 |
17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 |
{user.name}
25 |
@{user.nickname}
26 |
27 | 28 | 2m 29 |
30 |
31 | 32 | 33 | 34 |
35 | 36 |
42 |
43 | 44 |
45 | 46 |
49 |
50 | 51 |
52 |
53 | 54 |
55 |
56 | 57 |
58 |
59 | 60 |
61 |
62 |
63 |
64 |
65 |
66 | ); 67 | }; 68 | export default SubTweet; 69 | -------------------------------------------------------------------------------- /components/icons/Like.js: -------------------------------------------------------------------------------- 1 | export default function Like(props) { 2 | return ( 3 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /components/icons/Link.js: -------------------------------------------------------------------------------- 1 | export default function Link(props) { 2 | return ( 3 | 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /components/icons/Reply.js: -------------------------------------------------------------------------------- 1 | export default function Reply(props) { 2 | return ( 3 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /components/icons/Retweet.js: -------------------------------------------------------------------------------- 1 | export default function Retweet(props) { 2 | return ( 3 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /components/icons/Share.js: -------------------------------------------------------------------------------- 1 | export default function Share(props) { 2 | return ( 3 | 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /components/icons/Spinner.js: -------------------------------------------------------------------------------- 1 | export default function Spinner(props) { 2 | return ( 3 | 9 | 17 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | i18n: { 3 | locales: ["en"], 4 | defaultLocale: "en", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "start": "next start" 7 | }, 8 | "dependencies": { 9 | "@auth0/nextjs-auth0": "^1.5.0", 10 | "axios": "^0.21.1", 11 | "cheerio": "^1.0.0-rc.10", 12 | "isemoji": "^1.0.0", 13 | "next": "latest", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-icons": "^4.2.0", 17 | "sbd": "^1.0.19", 18 | "twemoji": "^13.1.0", 19 | "twit": "^2.2.11", 20 | "twit-thread": "^2.1.0", 21 | "twitter-splitter": "^1.0.0", 22 | "twitter-text": "^3.1.0", 23 | "unfurl.js": "^5.6.0" 24 | }, 25 | "devDependencies": { 26 | "autoprefixer": "^10.2.6", 27 | "postcss": "^8.3.5", 28 | "tailwindcss": "^2.2.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import "tailwindcss/tailwind.css"; 2 | import "../styles/globals.css"; 3 | import { UserProvider } from "@auth0/nextjs-auth0"; 4 | 5 | function MyApp({ Component, pageProps }) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default MyApp; 14 | -------------------------------------------------------------------------------- /pages/api/auth/[...auth0].js: -------------------------------------------------------------------------------- 1 | import { handleAuth } from '@auth0/nextjs-auth0'; 2 | 3 | export default handleAuth(); -------------------------------------------------------------------------------- /pages/api/constants.js: -------------------------------------------------------------------------------- 1 | export const AUTH0_ACCESS_TOKEN = 2 | "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjNySXRRd3hfUGIwQjgydk9FM3FqaCJ9.eyJpc3MiOiJodHRwczovL2dlZWt5c3JtMi51cy5hdXRoMC5jb20vIiwic3ViIjoiT1RZeGh1ZjJMT3hMOGNTclE3NnlrTE5PZ2l5SWIwUlBAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZ2Vla3lzcm0yLnVzLmF1dGgwLmNvbS9hcGkvdjIvIiwiaWF0IjoxNjMwMjkzMjQzLCJleHAiOjE2MzI4ODUyNDMsImF6cCI6Ik9UWXhodWYyTE94TDhjU3JRNzZ5a0xOT2dpeUliMFJQIiwic2NvcGUiOiJyZWFkOmNsaWVudF9ncmFudHMgY3JlYXRlOmNsaWVudF9ncmFudHMgZGVsZXRlOmNsaWVudF9ncmFudHMgdXBkYXRlOmNsaWVudF9ncmFudHMgcmVhZDp1c2VycyB1cGRhdGU6dXNlcnMgZGVsZXRlOnVzZXJzIGNyZWF0ZTp1c2VycyByZWFkOnVzZXJzX2FwcF9tZXRhZGF0YSB1cGRhdGU6dXNlcnNfYXBwX21ldGFkYXRhIGRlbGV0ZTp1c2Vyc19hcHBfbWV0YWRhdGEgY3JlYXRlOnVzZXJzX2FwcF9tZXRhZGF0YSByZWFkOnVzZXJfY3VzdG9tX2Jsb2NrcyBjcmVhdGU6dXNlcl9jdXN0b21fYmxvY2tzIGRlbGV0ZTp1c2VyX2N1c3RvbV9ibG9ja3MgY3JlYXRlOnVzZXJfdGlja2V0cyByZWFkOmNsaWVudHMgdXBkYXRlOmNsaWVudHMgZGVsZXRlOmNsaWVudHMgY3JlYXRlOmNsaWVudHMgcmVhZDpjbGllbnRfa2V5cyB1cGRhdGU6Y2xpZW50X2tleXMgZGVsZXRlOmNsaWVudF9rZXlzIGNyZWF0ZTpjbGllbnRfa2V5cyByZWFkOmNvbm5lY3Rpb25zIHVwZGF0ZTpjb25uZWN0aW9ucyBkZWxldGU6Y29ubmVjdGlvbnMgY3JlYXRlOmNvbm5lY3Rpb25zIHJlYWQ6cmVzb3VyY2Vfc2VydmVycyB1cGRhdGU6cmVzb3VyY2Vfc2VydmVycyBkZWxldGU6cmVzb3VyY2Vfc2VydmVycyBjcmVhdGU6cmVzb3VyY2Vfc2VydmVycyByZWFkOmRldmljZV9jcmVkZW50aWFscyB1cGRhdGU6ZGV2aWNlX2NyZWRlbnRpYWxzIGRlbGV0ZTpkZXZpY2VfY3JlZGVudGlhbHMgY3JlYXRlOmRldmljZV9jcmVkZW50aWFscyByZWFkOnJ1bGVzIHVwZGF0ZTpydWxlcyBkZWxldGU6cnVsZXMgY3JlYXRlOnJ1bGVzIHJlYWQ6cnVsZXNfY29uZmlncyB1cGRhdGU6cnVsZXNfY29uZmlncyBkZWxldGU6cnVsZXNfY29uZmlncyByZWFkOmhvb2tzIHVwZGF0ZTpob29rcyBkZWxldGU6aG9va3MgY3JlYXRlOmhvb2tzIHJlYWQ6YWN0aW9ucyB1cGRhdGU6YWN0aW9ucyBkZWxldGU6YWN0aW9ucyBjcmVhdGU6YWN0aW9ucyByZWFkOmVtYWlsX3Byb3ZpZGVyIHVwZGF0ZTplbWFpbF9wcm92aWRlciBkZWxldGU6ZW1haWxfcHJvdmlkZXIgY3JlYXRlOmVtYWlsX3Byb3ZpZGVyIGJsYWNrbGlzdDp0b2tlbnMgcmVhZDpzdGF0cyByZWFkOmluc2lnaHRzIHJlYWQ6dGVuYW50X3NldHRpbmdzIHVwZGF0ZTp0ZW5hbnRfc2V0dGluZ3MgcmVhZDpsb2dzIHJlYWQ6bG9nc191c2VycyByZWFkOnNoaWVsZHMgY3JlYXRlOnNoaWVsZHMgdXBkYXRlOnNoaWVsZHMgZGVsZXRlOnNoaWVsZHMgcmVhZDphbm9tYWx5X2Jsb2NrcyBkZWxldGU6YW5vbWFseV9ibG9ja3MgdXBkYXRlOnRyaWdnZXJzIHJlYWQ6dHJpZ2dlcnMgcmVhZDpncmFudHMgZGVsZXRlOmdyYW50cyByZWFkOmd1YXJkaWFuX2ZhY3RvcnMgdXBkYXRlOmd1YXJkaWFuX2ZhY3RvcnMgcmVhZDpndWFyZGlhbl9lbnJvbGxtZW50cyBkZWxldGU6Z3VhcmRpYW5fZW5yb2xsbWVudHMgY3JlYXRlOmd1YXJkaWFuX2Vucm9sbG1lbnRfdGlja2V0cyByZWFkOnVzZXJfaWRwX3Rva2VucyBjcmVhdGU6cGFzc3dvcmRzX2NoZWNraW5nX2pvYiBkZWxldGU6cGFzc3dvcmRzX2NoZWNraW5nX2pvYiByZWFkOmN1c3RvbV9kb21haW5zIGRlbGV0ZTpjdXN0b21fZG9tYWlucyBjcmVhdGU6Y3VzdG9tX2RvbWFpbnMgdXBkYXRlOmN1c3RvbV9kb21haW5zIHJlYWQ6ZW1haWxfdGVtcGxhdGVzIGNyZWF0ZTplbWFpbF90ZW1wbGF0ZXMgdXBkYXRlOmVtYWlsX3RlbXBsYXRlcyByZWFkOm1mYV9wb2xpY2llcyB1cGRhdGU6bWZhX3BvbGljaWVzIHJlYWQ6cm9sZXMgY3JlYXRlOnJvbGVzIGRlbGV0ZTpyb2xlcyB1cGRhdGU6cm9sZXMgcmVhZDpwcm9tcHRzIHVwZGF0ZTpwcm9tcHRzIHJlYWQ6YnJhbmRpbmcgdXBkYXRlOmJyYW5kaW5nIGRlbGV0ZTpicmFuZGluZyByZWFkOmxvZ19zdHJlYW1zIGNyZWF0ZTpsb2dfc3RyZWFtcyBkZWxldGU6bG9nX3N0cmVhbXMgdXBkYXRlOmxvZ19zdHJlYW1zIGNyZWF0ZTpzaWduaW5nX2tleXMgcmVhZDpzaWduaW5nX2tleXMgdXBkYXRlOnNpZ25pbmdfa2V5cyByZWFkOmxpbWl0cyB1cGRhdGU6bGltaXRzIGNyZWF0ZTpyb2xlX21lbWJlcnMgcmVhZDpyb2xlX21lbWJlcnMgZGVsZXRlOnJvbGVfbWVtYmVycyByZWFkOmVudGl0bGVtZW50cyByZWFkOmF0dGFja19wcm90ZWN0aW9uIHVwZGF0ZTphdHRhY2tfcHJvdGVjdGlvbiByZWFkOm9yZ2FuaXphdGlvbnMgdXBkYXRlOm9yZ2FuaXphdGlvbnMgY3JlYXRlOm9yZ2FuaXphdGlvbnMgZGVsZXRlOm9yZ2FuaXphdGlvbnMgY3JlYXRlOm9yZ2FuaXphdGlvbl9tZW1iZXJzIHJlYWQ6b3JnYW5pemF0aW9uX21lbWJlcnMgZGVsZXRlOm9yZ2FuaXphdGlvbl9tZW1iZXJzIGNyZWF0ZTpvcmdhbml6YXRpb25fY29ubmVjdGlvbnMgcmVhZDpvcmdhbml6YXRpb25fY29ubmVjdGlvbnMgdXBkYXRlOm9yZ2FuaXphdGlvbl9jb25uZWN0aW9ucyBkZWxldGU6b3JnYW5pemF0aW9uX2Nvbm5lY3Rpb25zIGNyZWF0ZTpvcmdhbml6YXRpb25fbWVtYmVyX3JvbGVzIHJlYWQ6b3JnYW5pemF0aW9uX21lbWJlcl9yb2xlcyBkZWxldGU6b3JnYW5pemF0aW9uX21lbWJlcl9yb2xlcyBjcmVhdGU6b3JnYW5pemF0aW9uX2ludml0YXRpb25zIHJlYWQ6b3JnYW5pemF0aW9uX2ludml0YXRpb25zIGRlbGV0ZTpvcmdhbml6YXRpb25faW52aXRhdGlvbnMiLCJndHkiOiJjbGllbnQtY3JlZGVudGlhbHMifQ.GJ8_g9vJOJYzxWdD3J8U-xjUijWiB9EdiB9IveoGy94iS-EEQpiK8ffnFP4Fn9-DBPL5b25NZutTFyKUu9klwV32RjXNzH9a6fQux-FSuqvy9EN_6EjcTkuk-AaUGQrxbVxXri10TLu6txbffUoW3VZax5g3Z7a60DaPloes8TJc8yMW70OhWXzqscemB-ktVSRX6IqANNFYGK4Zx0Z0LDfPCRnN6C1oaUeduqYrrE-Z28c0WIL0Ji26Mxi8-Vwt5S-1crPjCIlHJjKkEcBdOVx040pUnVzlssnPLWmomGo59p6KMbbPMTlDpjjGxbVewozWusoCIwJR6BzJYVk92g"; 3 | -------------------------------------------------------------------------------- /pages/api/fetchOgp.js: -------------------------------------------------------------------------------- 1 | import { unfurl } from "unfurl.js"; 2 | 3 | export default async function handler(req, res) { 4 | const { url } = req.body; 5 | 6 | const result = await unfurl(url); 7 | 8 | res.status(200).json({ ogp: result }); 9 | } 10 | -------------------------------------------------------------------------------- /pages/api/index.js: -------------------------------------------------------------------------------- 1 | import { TwitThread } from "twit-thread"; 2 | import axios from "axios"; 3 | import { AUTH0_ACCESS_TOKEN } from "./constants"; 4 | 5 | async function init(user) { 6 | let axiosConfig = { 7 | headers: { 8 | authorization: `Bearer ${AUTH0_ACCESS_TOKEN}`, 9 | }, 10 | }; 11 | 12 | let res = await axios.get( 13 | `https://geekysrm2.us.auth0.com/api/v2/users/${user.sub}`, 14 | axiosConfig 15 | ); 16 | 17 | let data = res.data; 18 | return data; 19 | } 20 | 21 | export default async function handler(req, res) { 22 | const { tweets, user } = req.body; 23 | const data = await init(user); 24 | const access_token = data.identities[0].access_token; 25 | const access_token_secret = data.identities[0].access_token_secret; 26 | 27 | const postedTweets = await postTweetThread({ 28 | accessToken: access_token, 29 | accessTokenSecret: access_token_secret, 30 | tweets: tweets.slice(0, 6), 31 | replyID: null, 32 | }); 33 | 34 | res.status(200).json({ postedTweets }); 35 | } 36 | 37 | async function postTweetThread({ accessToken, accessTokenSecret, tweets }) { 38 | const config = { 39 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 40 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 41 | access_token: accessToken, 42 | access_token_secret: accessTokenSecret, 43 | timeout_ms: 5 * 1000, 44 | }; 45 | const t = new TwitThread(config); 46 | 47 | const textToTweetArray = tweets.map((tweet) => { 48 | return { text: tweet }; 49 | }); 50 | 51 | const data = await t.tweetThread(textToTweetArray); 52 | return data; 53 | } 54 | -------------------------------------------------------------------------------- /pages/api/scrape.js: -------------------------------------------------------------------------------- 1 | import cheerio from "cheerio"; 2 | import axios from "axios"; 3 | 4 | export default async function handler(req, res) { 5 | const url = req.query.url; 6 | 7 | const $ = await fetchHTML(url); 8 | 9 | const firstInputId = $("input").attr("id"); 10 | const isHashnodeBlogPostUrl = firstInputId === "hn-user"; 11 | 12 | if (!isHashnodeBlogPostUrl) { 13 | res.status(401).json({ error: "not a hashnode blogpost url" }); 14 | } else { 15 | const blogPostText = $( 16 | "div#__next > div > div.blog-post-area > main > article > div.blog-content-wrapper.article-main-wrapper > section.blog-content-main" 17 | ) 18 | .last("div") 19 | .find("div.prose") 20 | .text(); 21 | 22 | const cleanedBlogPostText = blogPostText.trim().replace(/\n/g, " "); 23 | if (!blogPostText) { 24 | res.status(404).json({ 25 | error: `Couldn't fetch post content. Please enter a correct Hashnode blog post URL.`, 26 | }); 27 | return; 28 | } 29 | res.status(200).json({ 30 | blogPostText: cleanedBlogPostText, 31 | }); 32 | } 33 | } 34 | 35 | async function fetchHTML(url) { 36 | const { data } = await axios.get(url); 37 | return cheerio.load(data); 38 | } 39 | -------------------------------------------------------------------------------- /pages/done.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { withPageAuthRequired } from "@auth0/nextjs-auth0"; 3 | import Head from "next/head"; 4 | import Header from "../components/Header"; 5 | 6 | export default function ProtectedDonePage({ user }) { 7 | const [postedTweets, setPostedTweets] = useState([]); 8 | 9 | useEffect(() => { 10 | if (typeof window !== "undefined") { 11 | const postedTweetsString = 12 | window.localStorage.getItem("postedTweetsString"); 13 | if (postedTweetsString) { 14 | const postedTweets = JSON.parse(postedTweetsString); 15 | setPostedTweets(postedTweets); 16 | } 17 | } 18 | }, []); 19 | 20 | if (postedTweets?.length === 0) return <>; 21 | return ( 22 |
23 | 24 | Tweets Posted - Hashnode to Twitter 25 | 30 | 31 |
32 |
33 |

34 | Congrats! 🎉{" "} 35 |

Your tweet thread was successfully posted!

36 |

37 | 38 | Click{" "} 39 | 45 | here 46 | {" "} 47 | to view it on Twitter. 48 | 49 |

Loading tweets...

50 |
51 | {postedTweets?.map((tweet) => ( 52 |
53 | 56 |
57 | ))} 58 |
59 |
60 |
61 | ); 62 | } 63 | 64 | export const getServerSideProps = withPageAuthRequired(); 65 | -------------------------------------------------------------------------------- /pages/edit.js: -------------------------------------------------------------------------------- 1 | import { withPageAuthRequired } from "@auth0/nextjs-auth0"; 2 | import { useState, useEffect } from "react"; 3 | import twitterSplitter from "twitter-splitter"; 4 | import Head from "next/head"; 5 | import { RiDeleteBinLine } from "react-icons/ri"; 6 | import Preview from "../components/Preview"; 7 | import Header from "../components/Header"; 8 | import countChars from "../utils/countChars"; 9 | import EnterPopular from "../components/EnterPopular"; 10 | 11 | const limit = 280; 12 | const joiner = "..."; 13 | const popularWords = [ 14 | "🧵", 15 | "💯", 16 | "🎉", 17 | "🎁", 18 | "🔥", 19 | "#javascript", 20 | "#js", 21 | "#nextjs", 22 | "#tech", 23 | "#100DaysOfCode", 24 | "#code", 25 | "#blog", 26 | "#serverless", 27 | "#nodejs", 28 | "#programming", 29 | "#tailwindcss", 30 | "#html", 31 | "#css", 32 | "#webdev", 33 | ]; 34 | 35 | export default function ProtectedEditPage({ user }) { 36 | const [editorTexts, setEditorTexts] = useState([]); 37 | 38 | useEffect(() => { 39 | if (typeof window !== "undefined") { 40 | const postText = window.localStorage.getItem("postText"); 41 | const postUrl = window.localStorage.getItem("postUrl"); 42 | const sentences = twitterSplitter( 43 | postText.substring(1, postText.length - 1), 44 | limit, 45 | joiner 46 | ); 47 | const blogLinkText = `To read more, please visit my blog at @hashnode : ${postUrl.substring( 48 | 1, 49 | postUrl.length - 1 50 | )}`; 51 | const sentencesWithPostLink = [...sentences, blogLinkText]; 52 | setEditorTexts(sentencesWithPostLink); 53 | } 54 | }, []); 55 | 56 | return ( 57 | <> 58 | 59 | Edit thread - Hashnode to Twitter 60 | 61 |
62 |
63 |
64 |
65 |

66 | Edit Tweet Thread 67 |

68 |

69 | It is recommended to not post more than 6 tweets in total at once 70 | for better visibility. 71 |

72 |

73 | If number of tweets is more than 6, then first 6 tweets will be 74 | posted. 75 |

76 | 77 |

78 | Go to{" "} 79 | 80 | Preview 81 | 82 |

83 | 84 |
85 | {editorTexts.map((text, idx) => { 86 | return ( 87 |
88 |

Click to add:

89 |
90 | {popularWords.map((ch) => ( 91 | 98 | ))} 99 |
100 | 101 |
102 |

103 | Tweet #{idx + 1} 104 |

105 |
106 | 116 |
117 |
118 | 119 |