├── .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 |
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 | 
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 |
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 |
100 |
101 |
102 |
103 |
104 |
110 |
{publicRuntimeConfig.app_name}
111 |
112 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 | About
150 |
151 |
152 |
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 |
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 |
226 |
227 |
239 |
240 |
260 |
261 |
saveLink()}>
262 |
263 |
264 |
265 |
266 |
267 | {showSpinner && (
268 |
269 | Loading...
270 |
271 | )}
272 |
273 |
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 |
--------------------------------------------------------------------------------