├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── jsconfig.json ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon-old.ico ├── favicon.ico ├── icon.svg ├── linksnatch-cover.png ├── next.svg ├── thirteen.svg └── vercel.svg ├── src ├── components │ ├── AppHeader.jsx │ ├── LinkContainer.jsx │ ├── Links.jsx │ └── Placeholder.jsx ├── pages │ ├── _app.js │ ├── _document.js │ ├── about.js │ └── index.js ├── styles │ └── globals.css └── utils │ └── common.js └── tailwind.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: amitmerchant1990 4 | custom: paypal.me/AmitMerchant 5 | patreon: amitmerchant 6 | -------------------------------------------------------------------------------- /.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 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | certificates -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2023 Amit Merchant 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | the Software, and to permit persons to whom the Software is furnished to do so, 9 | subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | LinkSnatch 5 |
6 |

LinkSnatch

7 |
8 | 9 |

10 | An effortlessly simple bookmarks app that lets you save the links on your device on the go. 11 |

12 | 13 | ![](/public/linksnatch-cover.png) 14 | 15 | ## Introduction 16 | 17 | This is a dead simple bookmarks app that lets you save the links on your device on the go. 18 | 19 | I built [LinkSnatch](https://linksnatch.pages.dev) because I wanted something really simple to save links that I wanted to read later. I didn't want to have to sign up for an account, I didn't want to have to install a browser extension, and I didn't want to have to use a bookmarking service that was going to track me. I wanted something pretty simple that just works. And here I am! 20 | 21 | The app is fairly opinionated and comes with a set of bare minimum features I would need: 22 | 23 | - A beautiful interface with minimal distractions. 24 | - Extracts URL metadata using [jsonlink.io](https://jsonlink.io) and saves it to the browser's local storage. 25 | - Save and search links all from a single place. 26 | - Dark mode. 27 | - It doesn't track you. 28 | - No signup needed. 29 | - It doesn't require you to install a browser extension. 30 | - It's open source. 31 | 32 | I started building it to solve my own itch but later realized that someone might be in need of something like this. And so, I decided to set it free out in the wild! 33 | 34 | > Read the story: [Building LinkSnatch](https://www.amitmerchant.com/building-a-bookmarks-app-with-nextjs-and-tailwind-css/) 35 | 36 | ## Tech Stack 37 | 38 | - [Next.js](https://nextjs.org/) 39 | - [Tailwind CSS](https://tailwindcss.com/) 40 | - [Preline](https://preline.co/index.html) 41 | - [jsonlink.io](https://jsonlink.io) 42 | - [React Hot Toast](https://react-hot-toast.com/) 43 | - [AutoAnimate](https://auto-animate.formkit.com/) 44 | 45 | ## Development 46 | 47 | First, clone the repository. 48 | 49 | ```bash 50 | git clone git@github.com:amitmerchant1990/linksnatch.git 51 | ``` 52 | 53 | Then install the dependencies. 54 | 55 | ```bash 56 | npm install 57 | ``` 58 | 59 | Go to [jsonlink.io](https://jsonlink.io) and get your API key. Then, create a `.env.local` file in the root of the project and add the following. 60 | 61 | ```env 62 | NEXT_PUBLIC_JSONLINK_API_KEY=add_your_api_key_here 63 | ``` 64 | 65 | Finally, run the development server. 66 | 67 | ```bash 68 | npm run dev 69 | # or 70 | yarn dev 71 | # or 72 | pnpm dev 73 | ``` 74 | 75 | Open [http://localhost:3000](http://localhost:3000) with your browser to see LinkSnatch in action. 76 | 77 | ## Community 78 | 79 | - [docker-linksnatch](https://github.com/varunsridharan/docker-linksnatch) - A dockerized version of LinkSnatch directly installable from [Docker Hub](https://hub.docker.com/) and [GitHub Container Registry](https://github.com/features/packages) by [@varunsridharan](https://github.com/varunsridharan). 80 | - [linksnatch-docker](https://github.com/obiequack/linksnatch-docker/) - A docker setup repository for LinkSnatch by [@obiequack](https://github.com/obiequack). 81 | 82 | ## Support 83 | 84 | Buy Me A Coffee 85 | 86 |

Or

87 | 88 | [Become a GitHub Sponsor](https://github.com/sponsors/amitmerchant1990) 89 | 90 | ## License 91 | 92 | MIT 93 | 94 | --- 95 | 96 | > [amitmerchant.com](https://www.amitmerchant.com)  ·  97 | > GitHub [@amitmerchant1990](https://github.com/amitmerchant1990)  ·  98 | > Twitter [@amit_merchant](https://twitter.com/amit_merchant) 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | runtime: 'edge', 6 | }, 7 | publicRuntimeConfig: { 8 | app_name: 'LinkSnatch', 9 | app_short_description: 'Dead simple bookmarks', 10 | description: 'An effortlessly simple bookmarks app that lets you save the links on your device on the go.', 11 | app_url: 'https://linksnatch.pages.dev', 12 | app_creator: '@amit_merchant', 13 | app_locale: 'en_US', 14 | app_theme_color: '#CABCFD', 15 | jsonlink_api_url: 'https://jsonlink.io/api', 16 | }, 17 | } 18 | 19 | module.exports = nextConfig 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linksnatch", 3 | "version": "0.1.0", 4 | "private": false, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@formkit/auto-animate": "^0.8.1", 13 | "@next/font": "13.1.6", 14 | "eslint-config-next": "^13.5.4", 15 | "next": "^13.5.4", 16 | "next-seo": "^5.15.0", 17 | "preline": "^1.7.0", 18 | "prop-types": "^15.8.1", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-hot-toast": "^2.4.0", 22 | "sweetalert2": "^11.7.1", 23 | "sweetalert2-react-content": "^5.0.7" 24 | }, 25 | "devDependencies": { 26 | "autoprefixer": "^10.4.13", 27 | "postcss": "^8.4.21", 28 | "tailwindcss": "^3.2.6" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon-old.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amitmerchant1990/linksnatch/857f96ab53c868d5cea88b7f344f1594daa1ff4b/public/favicon-old.ico -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amitmerchant1990/linksnatch/857f96ab53c868d5cea88b7f344f1594daa1ff4b/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/linksnatch-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amitmerchant1990/linksnatch/857f96ab53c868d5cea88b7f344f1594daa1ff4b/public/linksnatch-cover.png -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/AppHeader.jsx: -------------------------------------------------------------------------------- 1 | import { publicRuntimeConfig } from "next.config" 2 | import Image from "next/image" 3 | import Link from "next/link" 4 | import { useEffect } from "react" 5 | 6 | export function AppHeader() { 7 | useEffect(() => { 8 | if (typeof window !== 'undefined') { 9 | const HSThemeAppearance = { 10 | init() { 11 | const defaultTheme = 'default' 12 | let theme = localStorage.getItem('hs_theme') || defaultTheme 13 | 14 | if (document.querySelector('html').classList.contains('dark')) return 15 | this.setAppearance(theme) 16 | }, 17 | _resetStylesOnLoad() { 18 | const $resetStyles = document.createElement('style') 19 | $resetStyles.innerText = `*{transition: unset !important;}` 20 | $resetStyles.setAttribute('data-hs-appearance-onload-styles', '') 21 | document.head.appendChild($resetStyles) 22 | return $resetStyles 23 | }, 24 | setAppearance(theme, saveInStore = true, dispatchEvent = true) { 25 | const $resetStylesEl = this._resetStylesOnLoad() 26 | 27 | if (saveInStore) { 28 | localStorage.setItem('hs_theme', theme) 29 | } 30 | 31 | if (theme === 'auto') { 32 | theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default' 33 | } 34 | 35 | document.querySelector('html').classList.remove('dark') 36 | document.querySelector('html').classList.remove('default') 37 | document.querySelector('html').classList.remove('auto') 38 | 39 | document.querySelector('html').classList.add(theme) 40 | 41 | setTimeout(() => { 42 | $resetStylesEl.remove() 43 | }) 44 | 45 | if (dispatchEvent) { 46 | window.dispatchEvent(new CustomEvent('on-hs-appearance-change', { detail: theme })) 47 | } 48 | 49 | $resetStylesEl.remove() 50 | }, 51 | getAppearance() { 52 | let theme = this.getOriginalAppearance() 53 | if (theme === 'auto') { 54 | theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default' 55 | } 56 | return theme 57 | }, 58 | getOriginalAppearance() { 59 | const defaultTheme = 'default' 60 | return localStorage.getItem('hs_theme') || defaultTheme 61 | } 62 | } 63 | 64 | HSThemeAppearance.init() 65 | 66 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 67 | if (HSThemeAppearance.getOriginalAppearance() === 'auto') { 68 | HSThemeAppearance.setAppearance('auto', false) 69 | } 70 | }) 71 | 72 | const $clickableThemes = document.querySelectorAll('[data-hs-theme-click-value]') 73 | const $switchableThemes = document.querySelectorAll('[data-hs-theme-switch]') 74 | 75 | $clickableThemes.forEach(function ($item) { 76 | $item.addEventListener('click', function () { 77 | HSThemeAppearance.setAppearance($item.getAttribute('data-hs-theme-click-value'), true, $item) 78 | }) 79 | }) 80 | 81 | $switchableThemes.forEach($item => { 82 | $item.addEventListener('change', (e) => { 83 | HSThemeAppearance.setAppearance(e.target.checked ? 'dark' : 'default') 84 | }) 85 | 86 | $item.checked = HSThemeAppearance.getAppearance() === 'dark' 87 | }) 88 | 89 | window.addEventListener('on-hs-appearance-change', e => { 90 | $switchableThemes.forEach($item => { 91 | $item.checked = e.detail === 'dark' 92 | }) 93 | }) 94 | } 95 | }, []) 96 | 97 | return ( 98 |
99 | 153 |
154 | ) 155 | } -------------------------------------------------------------------------------- /src/components/LinkContainer.jsx: -------------------------------------------------------------------------------- 1 | import { extractDomainName, copyLink } from '@/utils/common' 2 | 3 | export function LinkContainer({ 4 | link, 5 | deleteLink 6 | }) { 7 | return ( 8 |
9 |
10 | e.target.style.display = 'none'} /> 11 |

{extractDomainName(link.url ?? '')}

12 |
13 | 14 |

15 | 16 | {link.title ?? extractDomainName(link.url ?? '')} 17 | 18 | 19 |

20 | 21 | copyLink(link.url)} title="Copy link to clipboard"> 22 | 23 | 24 | 25 | 26 | 27 | deleteLink(link.id)} title="Delete link"> 28 | 29 | 30 | 31 | 32 |
33 | ) 34 | } -------------------------------------------------------------------------------- /src/components/Links.jsx: -------------------------------------------------------------------------------- 1 | import { LinkContainer } from '@/components/LinkContainer' 2 | import { useAutoAnimate } from '@formkit/auto-animate/react' 3 | 4 | export function Links({ 5 | links, 6 | deleteLink 7 | }) { 8 | const [parent, enableAnimations] = useAutoAnimate() 9 | 10 | return ( 11 |
12 |
13 | {links.slice(0).reverse().map((link, index) => ( 14 | 15 | ))} 16 |
17 |
18 | ) 19 | } -------------------------------------------------------------------------------- /src/components/Placeholder.jsx: -------------------------------------------------------------------------------- 1 | import { publicRuntimeConfig } from "next.config" 2 | import Link from "next/link" 3 | 4 | export function Placeholder() { 5 | return ( 6 |
7 |

8 | 9 | 10 | 11 | Start snatching in some links! 12 | 13 | 17 | Learn more about {publicRuntimeConfig.app_name} 18 | 19 | 20 |

21 |
22 | ) 23 | } -------------------------------------------------------------------------------- /src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | import { useEffect } from 'react' 3 | import { NextSeo, DefaultSeo } from 'next-seo' 4 | import { publicRuntimeConfig } from 'next.config' 5 | 6 | export default function App({ Component, pageProps }) { 7 | useEffect(() => { 8 | import('preline') 9 | }, []) 10 | 11 | return ( 12 | <> 13 | 17 | 18 | 35 | 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/_document.js: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/about.js: -------------------------------------------------------------------------------- 1 | import { AppHeader } from "@/components/AppHeader" 2 | import { NextSeo } from "next-seo" 3 | import { publicRuntimeConfig } from "next.config" 4 | 5 | export default function About() { 6 | return ( 7 | <> 8 | 11 | 12 | 13 | 14 |
15 |
16 |
About
17 | 18 |

19 | This is a dead simple bookmarks app that lets you save the links on your device on the go. 20 |

21 | 22 |

23 | I built LinkSnatch because I wanted something really simple to save links that I wanted to read later. I didn't want to have to sign up for an account, I didn't want to have to install a browser extension, and I (definitely) didn't want to have to use a bookmarking service that was going to track me. 24 | I wanted something pretty straight-forward that just works. And here I am! 25 |

26 | 27 |

28 | The app is fairly opinionated and comes with a set of bare minimum features I would need: 29 |

30 | 31 |
    32 |
  • No signup needed.
  • 33 |
  • A beautiful interface with minimal distractions.
  • 34 |
  • Extracts URL metadata using jsonlink.io and saves it to the browser's local storage.
  • 35 |
  • Save and search links all from a single place.
  • 36 |
  • Dark mode.
  • 37 |
  • It doesn't track you.
  • 38 |
  • It's open source.
  • 39 |
40 | 41 |

42 | I started building it to solve my own itch but later realized that someone might be in need of something like this. 43 | And so, I decided to set it free out in the wild! 44 |

45 | 46 |

47 | Crafted with ❤️ by Amit Merchant 48 |

49 | 50 |
51 | 52 |

53 | Explore more projects: 54 |

55 | 56 | 62 |
63 |
64 | 65 | ) 66 | } -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react' 2 | import { AppHeader } from '@/components/AppHeader' 3 | import { Links } from '@/components/Links' 4 | import { Placeholder } from '@/components/Placeholder' 5 | import { isValidHttpUrl, fetchUrlMetadata, formatUrl, saveTextAsFile } from '@/utils/common' 6 | import { toast, Toaster } from 'react-hot-toast' 7 | import { NextSeo } from 'next-seo' 8 | import Swal from 'sweetalert2' 9 | import withReactContent from 'sweetalert2-react-content' 10 | import { publicRuntimeConfig } from 'next.config' 11 | 12 | export default function Home() { 13 | const Dialog = withReactContent(Swal) 14 | const textInput = useRef(null) 15 | const importFile = useRef(null) 16 | const [url, setUrl] = useState('') 17 | const [links, setLinks] = useState([]) 18 | const [hasValidUrl, setHasValidUrl] = useState(false) 19 | const [showSpinner, setShowSpinner] = useState(false); 20 | 21 | let allLinks = [] 22 | 23 | useEffect(() => { 24 | textInput.current.focus() 25 | allLinks = localStorage.getItem('links') ? JSON.parse(localStorage.getItem('links')) : [] 26 | setLinks(allLinks) 27 | }, []) 28 | 29 | useEffect(() => { 30 | setHasValidUrl(false) 31 | let formattedUrl 32 | 33 | if (url !== '') { 34 | formattedUrl = formatUrl(url); 35 | } 36 | 37 | if (url !== '' && !isValidHttpUrl(formattedUrl?.href)) { 38 | searchLinks() 39 | } 40 | 41 | if (url === '' || isValidHttpUrl(formattedUrl?.href)) { 42 | allLinks = localStorage.getItem('links') ? JSON.parse(localStorage.getItem('links')) : [] 43 | setLinks(allLinks) 44 | textInput.current.focus() 45 | } 46 | 47 | if (isValidHttpUrl(formattedUrl?.href)) { 48 | setHasValidUrl(true) 49 | } 50 | }, [url]) 51 | 52 | const handleChange = (event) => { 53 | setUrl(event.target.value) 54 | }; 55 | 56 | const handleKeyDown = async (event) => { 57 | if (event.key === 'Enter') { 58 | saveLink() 59 | } 60 | }; 61 | 62 | async function saveLink() { 63 | const formattedUrl = formatUrl(url); 64 | 65 | if (!isValidHttpUrl(formattedUrl)) { 66 | toast.error('Please enter a valid URL'); 67 | return 68 | } 69 | 70 | if (checkLinkExists(formattedUrl)) { 71 | toast.error('Bam! You already have this link saved'); 72 | return 73 | } 74 | 75 | setShowSpinner(true) 76 | allLinks = await fetchUrlMetadata(formattedUrl) 77 | setShowSpinner(false) 78 | reloadLinks(allLinks) 79 | } 80 | 81 | function reloadLinks(links) { 82 | if (!links) 83 | return 84 | 85 | toast.success('Link saved!') 86 | setLinks(links) 87 | setUrl('') 88 | } 89 | 90 | function deleteLink(id) { 91 | Dialog.fire({ 92 | text: 'Are you sure you want to delete this link?', 93 | icon: 'warning', 94 | showCancelButton: true, 95 | confirmButtonColor: '#d33', 96 | cancelButtonColor: '#8e77e5', 97 | confirmButtonText: 'Yes, delete it!', 98 | showClass: { 99 | backdrop: 'swal2-noanimation', // disable backdrop animation 100 | popup: '', // disable popup animation 101 | icon: '' // disable icon animation 102 | }, 103 | hideClass: { 104 | popup: '', // disable popup fade-out animation 105 | }, 106 | }).then((result) => { 107 | if (result.isConfirmed) { 108 | const links = JSON.parse(localStorage.getItem('links')) 109 | const filteredLinks = links.filter(link => link.id !== id) 110 | localStorage.setItem('links', JSON.stringify(filteredLinks)) 111 | setLinks(filteredLinks) 112 | toast.success('Link deleted!') 113 | } 114 | }) 115 | } 116 | 117 | function searchLinks() { 118 | const links = JSON.parse(localStorage.getItem('links')) 119 | const filteredLinks = links?.filter(link => link.title?.toLowerCase().includes(url.toLowerCase())) ?? [] 120 | setLinks(filteredLinks) 121 | } 122 | 123 | function checkLinkExists(url) { 124 | const links = JSON.parse(localStorage.getItem('links')) 125 | const filteredLinks = links?.filter(link => link.url === url.href) ?? [] 126 | return filteredLinks.length > 0 127 | } 128 | 129 | function exportBookmarks() { 130 | const links = JSON.stringify(JSON.parse(localStorage.getItem('links'))) 131 | 132 | if (links === "null" || links === "[]") { 133 | toast.error('No bookmarks to be exported!') 134 | return 135 | } 136 | 137 | saveTextAsFile( 138 | links, 139 | 'linksnatch-bookmarks-' + Math.floor(Date.now() / 1000) + '.json' 140 | ) 141 | } 142 | 143 | function importBookmarks() { 144 | importFile.current.click() 145 | } 146 | 147 | function handleImportFile(event) { 148 | const fileObj = event.target.files && event.target.files[0] 149 | 150 | if (!fileObj) { 151 | return 152 | } 153 | 154 | if (fileObj.type !== 'application/json') { 155 | toast.error('Not a valid file.') 156 | event.target.value = null 157 | return 158 | } 159 | 160 | const fileReader = new FileReader(); 161 | fileReader.readAsText(event.target.files[0]) 162 | fileReader.onload = (e) => { 163 | const contents = e.target.result 164 | let importedLinks = JSON.parse(contents) 165 | 166 | if (localStorage.getItem('links')) { 167 | const existingLinks = JSON.parse(localStorage.getItem('links')) 168 | 169 | if (existingLinks.length > 0) { 170 | Dialog.fire({ 171 | text: 'Do you want to merge the imported links with your existing links?', 172 | icon: 'warning', 173 | showCancelButton: true, 174 | confirmButtonColor: '#d33', 175 | cancelButtonColor: '#8e77e5', 176 | confirmButtonText: 'Yes, merge it!', 177 | cancelButtonText: 'No', 178 | showClass: { 179 | backdrop: 'swal2-noanimation', // disable backdrop animation 180 | popup: '', // disable popup animation 181 | icon: '' // disable icon animation 182 | }, 183 | hideClass: { 184 | popup: '', // disable popup fade-out animation 185 | }, 186 | }).then((result) => { 187 | if (result.isConfirmed) { 188 | importedLinks.forEach(link => { 189 | if (!checkLinkExists(link.url)) { 190 | existingLinks.push(link) 191 | } 192 | }) 193 | 194 | importedLinks = existingLinks.reverse() 195 | } 196 | 197 | localStorage.setItem('links', JSON.stringify(importedLinks)) 198 | setLinks(importedLinks) 199 | toast.success('Bookmarks imported!') 200 | }) 201 | } 202 | } else { 203 | localStorage.setItem('links', JSON.stringify(importedLinks)) 204 | setLinks(importedLinks) 205 | toast.success('Bookmarks imported!') 206 | } 207 | }; 208 | 209 | event.target.value = null; 210 | } 211 | 212 | return ( 213 | <> 214 | 217 | 218 | 222 | 223 | 224 | 225 | 274 | 275 | {links?.length > 0 && ( 276 | 277 | )} 278 | 279 | {links.length === 0 && ( 280 | 281 | )} 282 | 283 | ) 284 | } 285 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .swal2-icon { 6 | display: none !important; 7 | } 8 | 9 | .swal2-popup { 10 | padding: 0 0 2em !important; 11 | } 12 | 13 | .swal2-html-container { 14 | margin: 2em 1.6em 0.3em !important; 15 | font-weight: bold !important; 16 | } 17 | 18 | .swal2-popup { 19 | border-radius: 20px !important; 20 | } 21 | 22 | .swal2-confirm, 23 | .swal2-cancel { 24 | border-radius: 10px !important; 25 | } -------------------------------------------------------------------------------- /src/utils/common.js: -------------------------------------------------------------------------------- 1 | import { publicRuntimeConfig } from 'next.config' 2 | import { toast } from 'react-hot-toast' 3 | 4 | export function extractDomainName(url = '') { 5 | if (!url) return; 6 | 7 | try { 8 | let domain = new URL(url) 9 | return domain.hostname 10 | } catch (error) { 11 | return 12 | } 13 | } 14 | 15 | export function isValidDomainName(string) { 16 | const regexp = /^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6}$/i 17 | 18 | return regexp.test(string) 19 | } 20 | 21 | export function isValidHttpUrl(string) { 22 | const regexp = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i 23 | 24 | return regexp.test(string) 25 | } 26 | 27 | export const fetchUrlMetadata = async (url) => { 28 | let response; 29 | 30 | let data; 31 | 32 | const jsonlink_api_key = process.env.NEXT_PUBLIC_JSONLINK_API_KEY 33 | 34 | try { 35 | response = await fetch( 36 | publicRuntimeConfig.jsonlink_api_url + `/extract?url=${url}&api_key=${jsonlink_api_key}` 37 | ) 38 | 39 | if (!response.ok) { 40 | toast.error('Oops! Bad URL.') 41 | return false 42 | } 43 | 44 | data = await response.json(); 45 | } catch (error) { 46 | console.log(error); 47 | } 48 | 49 | const linksArray = localStorage.getItem('links') ? JSON.parse(localStorage.getItem('links')) : [] 50 | 51 | const linkMetaData = { 52 | 'id': Math.random().toString(36).substr(2, 8), 53 | 'title': data?.title ?? url, 54 | 'url': data?.url ?? url, 55 | 'timestamp': Math.floor(Date.now() / 1000), 56 | } 57 | 58 | linksArray.push(linkMetaData) 59 | 60 | localStorage.setItem('links', JSON.stringify(linksArray)) 61 | 62 | const links = JSON.parse(localStorage.getItem('links')) 63 | 64 | return links 65 | }; 66 | 67 | export function copyLink(url) { 68 | navigator.clipboard.writeText(url) 69 | toast.success('Link copied to clipboard!') 70 | } 71 | 72 | export function formatUrl(string) { 73 | let url; 74 | 75 | try { 76 | url = new URL(string); 77 | 78 | if (!url.hostname) { 79 | // cases where the hostname was not identified 80 | // ex: user:password@www.example.com, example.com:8000 81 | url = new URL("https://" + string); 82 | } 83 | } catch (error) { 84 | if (isValidDomainName(string)) { 85 | url = new URL("https://" + string); 86 | } 87 | } 88 | 89 | return url; 90 | } 91 | 92 | export function saveTextAsFile(textToWrite, fileNameToSaveAs) { 93 | let textFileAsBlob = new Blob([textToWrite], { type: 'application/json' }); 94 | let downloadLink = document.createElement('a'); 95 | downloadLink.download = fileNameToSaveAs; 96 | downloadLink.innerHTML = 'Download File'; 97 | 98 | if (window.webkitURL != null) { 99 | downloadLink.href = window.webkitURL.createObjectURL(textFileAsBlob); 100 | } else { 101 | downloadLink.href = window.URL.createObjectURL(textFileAsBlob); 102 | downloadLink.style.display = 'none'; 103 | document.body.appendChild(downloadLink); 104 | } 105 | 106 | downloadLink.click(); 107 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: [ 5 | "./app/**/*.{js,ts,jsx,tsx}", 6 | "./pages/**/*.{js,ts,jsx,tsx}", 7 | "./components/**/*.{js,ts,jsx,tsx}", 8 | 9 | // Or if using `src` directory: 10 | "./src/**/*.{js,ts,jsx,tsx}", 11 | 'node_modules/preline/dist/*.js', 12 | ], 13 | theme: { 14 | extend: {}, 15 | }, 16 | plugins: [ 17 | require('preline/plugin'), 18 | ], 19 | } 20 | --------------------------------------------------------------------------------