diff --git a/.eslintrc.json b/.eslintrc.json index 22c30eb..4d765f2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,3 @@ { - "extends": [ - "next/core-web-vitals", - "prettier" - ] -} \ No newline at end of file + "extends": ["next/core-web-vitals", "prettier"] +} diff --git a/.prettierrc b/.prettierrc index 66e7e94..fa51da2 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,4 +3,4 @@ "tabWidth": 2, "semi": false, "singleQuote": true -} \ No newline at end of file +} diff --git a/components/Content.tsx b/components/Content.tsx index 1be48eb..08787e7 100644 --- a/components/Content.tsx +++ b/components/Content.tsx @@ -1,78 +1,88 @@ -import React, { useState } from "react"; -import sanitizeHtml from 'sanitize-html'; +import React, { useState } from 'react' +import sanitizeHtml from 'sanitize-html' import debounce from 'debounce' type AccountDetails = { - id: string, // IMPORTANT: this is int64 so will overflow Javascript's number type - acct: string, - followed_by: Set, // list of handles -}; + id: string // IMPORTANT: this is int64 so will overflow Javascript's number type + acct: string + followed_by: Set // list of handles +} -async function usernameToId(handle: string): Promise<{ id: number, domain: string }> { - const match = handle.match(/^(.+)@(.+)$/); +async function usernameToId( + handle: string +): Promise<{ id: number; domain: string }> { + const match = handle.match(/^(.+)@(.+)$/) if (!match || match.length < 2) { - throw new Error(`Incorrect handle: ${handle}`); + throw new Error(`Incorrect handle: ${handle}`) } - const domain = match[2]; - const username = match[1]; - let response = await fetch(`https://${domain}/api/v1/accounts/lookup?acct=${username}`); + const domain = match[2] + const username = match[1] + let response = await fetch( + `https://${domain}/api/v1/accounts/lookup?acct=${username}` + ) if (response.status !== 200) { - throw new Error('HTTP request failed'); + throw new Error('HTTP request failed') } - const { id } = await response.json(); - return { id, domain }; + const { id } = await response.json() + return { id, domain } } function getDomain(handle: string) { - const match = handle.match(/^(.+)@(.+)$/); + const match = handle.match(/^(.+)@(.+)$/) if (!match || match.length < 2) { - throw new Error(`Incorrect handle: ${handle}`); + throw new Error(`Incorrect handle: ${handle}`) } - const domain = match[2]; - return domain; + const domain = match[2] + return domain } -async function accountFollows(handle: string, limit: number, logError: (x: string) => void): Promise> { - let id, domain: string; +async function accountFollows( + handle: string, + limit: number, + logError: (x: string) => void +): Promise> { + let id, domain: string try { - ({ id, domain } = await usernameToId(handle)); + ;({ id, domain } = await usernameToId(handle)) } catch (e) { - logError(`Cannot find handle ${handle}.`); - return []; + logError(`Cannot find handle ${handle}.`) + return [] } - let nextPage: string | null = `https://${domain}/api/v1/accounts/${id}/following`; - let data: Array = []; + let nextPage: + | string + | null = `https://${domain}/api/v1/accounts/${id}/following` + let data: Array = [] while (nextPage && data.length <= limit) { - console.log(`Get page: ${nextPage}`); - let response; - let page; + console.log(`Get page: ${nextPage}`) + let response + let page try { - response = await fetch(nextPage); + response = await fetch(nextPage) if (response.status !== 200) { - throw new Error('HTTP request failed'); + throw new Error('HTTP request failed') } - page = await response.json(); - console.log(response.statusText); + page = await response.json() + console.log(response.statusText) } catch (e) { logError(`Error while retrieving followers for ${handle}.`) - console.log('eeeee', e); - break; + console.log('eeeee', e) + break } if (!page.map) { - break; + break } page = page.map((entry: AccountDetails) => { if (entry.acct && !/@/.test(entry.acct)) { // make sure the domain is always there - entry.acct = `${entry.acct}@${domain}`; - }; - return entry; + entry.acct = `${entry.acct}@${domain}` + } + return entry }) - data = [...data, ...page]; - nextPage = getNextPage(response.headers.get('Link')); + data = [...data, ...page] + nextPage = getNextPage(response.headers.get('Link')) } - return data; + return data } async function accountFofs( @@ -81,103 +91,127 @@ async function accountFofs( setFollows: (x: Array) => void, logError: (x: string) => void ): Promise { - const directFollows = await accountFollows(handle, 2000, logError); - setProgress([0, directFollows.length]); - let progress = 0; + const directFollows = await accountFollows(handle, 2000, logError) + setProgress([0, directFollows.length]) + let progress = 0 - const directFollowIds = new Set(directFollows.map(({ acct }) => acct)); - directFollowIds.add(handle.replace(/^@/, '')); + const directFollowIds = new Set(directFollows.map(({ acct }) => acct)) + directFollowIds.add(handle.replace(/^@/, '')) - const indirectFollowLists: Array> = []; + const indirectFollowLists: Array> = [] const updateList = debounce(() => { - let indirectFollows: Array = [].concat([], ...indirectFollowLists); - const indirectFollowMap = new Map(); + let indirectFollows: Array = [].concat( + [], + ...indirectFollowLists + ) + const indirectFollowMap = new Map() - indirectFollows.filter( - // exclude direct follows - ({ acct }) => !directFollowIds.has(acct) - ).map(account => { - const acct = account.acct; - if (indirectFollowMap.has(acct)) { - const otherAccount = indirectFollowMap.get(acct); - account.followed_by = new Set([...Array.from(account.followed_by.values()), ...otherAccount.followed_by]); - } - indirectFollowMap.set(acct, account); - }); + indirectFollows + .filter( + // exclude direct follows + ({ acct }) => !directFollowIds.has(acct) + ) + .map((account) => { + const acct = account.acct + if (indirectFollowMap.has(acct)) { + const otherAccount = indirectFollowMap.get(acct) + account.followed_by = new Set([ + ...Array.from(account.followed_by.values()), + ...otherAccount.followed_by, + ]) + } + indirectFollowMap.set(acct, account) + }) const list = Array.from(indirectFollowMap.values()).sort((a, b) => { if (a.followed_by.size != b.followed_by.size) { - return b.followed_by.size - a.followed_by.size; + return b.followed_by.size - a.followed_by.size } - return b.followers_count - a.followers_count; - }); + return b.followers_count - a.followers_count + }) - setFollows(list); - }, 2000); + setFollows(list) + }, 2000) await Promise.all( - directFollows.map( - async ({ acct }) => { - const follows = await accountFollows(acct, 200, logError); - progress++; - setProgress([progress, directFollows.length]); - indirectFollowLists.push(follows.map(account => ({ ...account, followed_by: new Set([acct]) }))); - updateList(); - } - ), - ); + directFollows.map(async ({ acct }) => { + const follows = await accountFollows(acct, 200, logError) + progress++ + setProgress([progress, directFollows.length]) + indirectFollowLists.push( + follows.map((account) => ({ ...account, followed_by: new Set([acct]) })) + ) + updateList() + }) + ) - updateList.flush(); + updateList.flush() } function getNextPage(linkHeader: string | null): string | null { if (!linkHeader) { - return null; + return null } // Example header: // Link: ; rel="next", ; rel="prev" - const match = linkHeader.match(/<(.+)>; rel="next"/); + const match = linkHeader.match(/<(.+)>; rel="next"/) if (match && match.length > 0) { - return match[1]; + return match[1] } - return null; + return null } -export function Content({ }) { - const [handle, setHandle] = useState(""); - const [follows, setFollows] = useState>([]); - const [isLoading, setLoading] = useState(false); - const [isDone, setDone] = useState(false); - const [domain, setDomain] = useState(""); - const [[numLoaded, totalToLoad], setProgress] = useState>([0, 0]); - const [errors, setErrors] = useState>([]); +export function Content({}) { + const [handle, setHandle] = useState('') + const [follows, setFollows] = useState>([]) + const [isLoading, setLoading] = useState(false) + const [isDone, setDone] = useState(false) + const [domain, setDomain] = useState('') + const [[numLoaded, totalToLoad], setProgress] = useState>([ + 0, 0, + ]) + const [errors, setErrors] = useState>([]) async function search(handle: string) { if (!/@/.test(handle)) { - return; + return } - setErrors([]); - setLoading(true); - setDone(false); - setFollows([]); - setProgress([0, 0]); - setDomain(getDomain(handle)); - await accountFofs(handle, setProgress, setFollows, error => setErrors(e => [...e, error])); - setLoading(false); - setDone(true); + setErrors([]) + setLoading(true) + setDone(false) + setFollows([]) + setProgress([0, 0]) + setDomain(getDomain(handle)) + await accountFofs(handle, setProgress, setFollows, (error) => + setErrors((e) => [...e, error]) + ) + setLoading(false) + setDone(true) } - return
-
-
{ - search(handle); - e.preventDefault(); - return false; - }}> -
- - setHandle(e.target.value)} className="form-control + return ( +
+
+ { + search(handle) + e.preventDefault() + return false + }} + > +
+ + setHandle(e.target.value)} + className="form-control block w-80 px-3 @@ -193,11 +227,21 @@ export function Content({ }) { m-0 focus:text-gray-900 focus:bg-white focus:border-green-600 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-gray-200 dark:focus:bg-gray-900 dark:focus:text-gray-200 - " id="mastodonHandle" - aria-describedby="mastodonHandleHelp" placeholder="johnmastodon@mas.to" /> - Be sure to include the full handle, including the domain. + " + id="mastodonHandle" + aria-describedby="mastodonHandleHelp" + placeholder="johnmastodon@mas.to" + /> + + Be sure to include the full handle, including the domain. + - + ease-in-out" + > + Search + {isLoading ? ( + + {/*! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. */} + + + ) : null} + - {isLoading ? -

Loaded {numLoaded} of {totalToLoad}...

- : null} + {isLoading ? ( +

+ Loaded {numLoaded} of {totalToLoad}... +

+ ) : null} - {isDone && follows.length === 0 ? -
- - Info -
- No results found. Please double check for typos in the handle, and ensure that you follow at least a few people - to seed the search. Otherwise, try again later as Mastodon may throttle requests. + {isDone && follows.length === 0 ? ( +
+ + Info +
+ No results found. Please + double check for typos in the handle, and ensure that you + follow at least a few people to seed the search. Otherwise, + try again later as Mastodon may throttle requests. +
+
+ ) : null} +
+ + + {isDone || follows.length > 0 ? ( +
+
+
+
    + {follows.slice(0, 500).map((account) => ( + + ))} +
- : null} -
- - - - - {isDone || follows.length > 0 ? -
-
-
-
    - {follows.slice(0, 500).map(account => )} -
-
-
- : null} + ) : null} - -
-
; + +
+
+ ) } function AccountDetails({ account, mainDomain }) { - const { avatar_static, display_name, acct, note, followers_count, followed_by } = account; - let formatter = Intl.NumberFormat('en', { notation: 'compact' }); - let numFollowers = formatter.format(followers_count); + const { + avatar_static, + display_name, + acct, + note, + followers_count, + followed_by, + } = account + let formatter = Intl.NumberFormat('en', { notation: 'compact' }) + let numFollowers = formatter.format(followers_count) - const [expandedFollowers, setExpandedFollowers] = useState(false); + const [expandedFollowers, setExpandedFollowers] = useState(false) return (
  • - {display_name} + {display_name}

    @@ -277,36 +367,73 @@ function AccountDetails({ account, mainDomain }) { {acct} | {numFollowers} followers


    - +
    Followed by{' '} - {followed_by.size < 9 || expandedFollowers ? - + {followed_by.size < 9 || expandedFollowers ? ( Array.from(followed_by.values()).map((handle, idx) => ( - <>{handle.replace(/@.+/, '')}{idx === followed_by.size - 1 ? '.' : ', '} + <> + + {handle.replace(/@.+/, '')} + + {idx === followed_by.size - 1 ? '.' : ', '} + )) - : <> - . - } + ) : ( + <> + + . + + )}
  • - ); + ) } function ErrorLog({ errors }: { errors: Array }) { - const [expanded, setExpanded] = useState(false); - return (<> - {errors.length > 0 ?
    - Found {expanded ? ':' : '.'} - {expanded ? errors.map(err =>

    {err}

    ) : null} -
    : null} - ); -} \ No newline at end of file + const [expanded, setExpanded] = useState(false) + return ( + <> + {errors.length > 0 ? ( +
    + Found{' '} + + {expanded ? ':' : '.'} + {expanded + ? errors.map((err) => ( +

    + {err} +

    + )) + : null} +
    + ) : null} + + ) +} diff --git a/components/FAQ.tsx b/components/FAQ.tsx index 75650e3..c401a4f 100644 --- a/components/FAQ.tsx +++ b/components/FAQ.tsx @@ -1,62 +1,127 @@ -import React, { useState } from "react"; -export function FAQ({ }) { - return
    -
    -

    Frequently asked questions

    -
    -
    - - The tool looks up all the people you follow, and then the people they follow. Then - it sorts them by the number of mutuals, or otherwise by how popular those accounts are. - +import React, { useState } from 'react' +export function FAQ({}) { + return ( +
    +
    +

    + Frequently asked questions +

    +
    +
    + + The tool looks up all the people you follow, and then the people{' '} + they follow. Then it sorts them by the number of mutuals, + or otherwise by how popular those accounts are. + - - Not at all! This app uses public APIs to fetch potential people you can follow on Mastodon. In fact, it only does inauthenticated network requests to various - Mastodon instances. - + + Not at all! This app uses public APIs to fetch potential people + you can follow on Mastodon. In fact, it only does inauthenticated + network requests to various Mastodon instances. + - - Don't worry. The list of suggestions will load in 30 seconds or so. Sometimes it gets stuck because one or more of the queries - made to Mastodon time out. This is not a problem, because the rest of the queries will work as expected. - + + Don't worry. The list of suggestions will load in 30 seconds + or so. Sometimes it gets stuck because one or more of the queries + made to Mastodon time out. This is not a problem, because the rest + of the queries will work as expected. + - - There could be a few reasons: -
      -
    • This tool only works if your list of follows is public. If you've opted to hide your social graph, you will not see - any results here.
    • -
    • Due to the high volume of requests, sometimes Mastodon throttles this tool. If that's the case, try again a bit later.
    • -
    • Make sure you have no typos in the Mastodon handle, and make sure you follow at least a few people to seed the search.
    • -
    -
    + + There could be a few reasons: +
      +
    • + This tool only works if your list of follows is public. If + you've opted to hide your social graph, you will not see + any results here. +
    • +
    • + Due to the high volume of requests, sometimes Mastodon + throttles this tool. If that's the case, try again a bit + later. +
    • +
    • + Make sure you have no typos in the Mastodon handle, and make + sure you follow at least a few people to seed the search. +
    • +
    +
    - - Click the "Fork me on Github" link on the top right, and open up an issue. - + + Click the "Fork me on Github" link on the top right, and + open up an issue. + - - Well, maybe it should be. In the meantime, you can use this website. - + + Well, maybe it should be. In the meantime, you can use this + website. + +
    -
    -
    ; + + ) } -function FAQItem({ defaultSelected, title, children }: { defaultSelected?: boolean, title: string, children: React.ReactNode }) { - const [selected, setSelected] = useState(defaultSelected); - return (<> -

    - -

    - {selected ? -
    -
    - {children} +function FAQItem({ + defaultSelected, + title, + children, +}: { + defaultSelected?: boolean + title: string + children: React.ReactNode +}) { + const [selected, setSelected] = useState(defaultSelected) + return ( + <> +

    + +

    + {selected ? ( +
    +
    + {children} +
    -
    : null} - ); + ) : null} + + ) } diff --git a/components/Footer.tsx b/components/Footer.tsx index da70715..23e7b29 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,16 +1,41 @@ -import React from "react"; -export default function Footer({ }) { - return + ) } diff --git a/components/Header.tsx b/components/Header.tsx index 34d208f..e5be898 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,39 +1,80 @@ -import React from "react"; +import React from 'react' export default function Header() { - return (
    - +
    + ) - function Logo({ }) { - return ( - - Followgraph for Mastodon - ); - } + function Logo({}) { + return ( + + + + + + Followgraph for Mastodon + + + ) + } } -function MenuItem({ link, children, selected }: { link: string, children: string | React.ReactElement, selected?: boolean }) { - return (
  • - {selected ? - - {children} - - : - - {children} - - } -
  • ); +function MenuItem({ + link, + children, + selected, +}: { + link: string + children: string | React.ReactElement + selected?: boolean +}) { + return ( +
  • + {selected ? ( + + {children} + + ) : ( + + {children} + + )} +
  • + ) } diff --git a/components/Hero.tsx b/components/Hero.tsx index 4c56c90..a8e7edc 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -1,27 +1,40 @@ import Image from 'next/image' -import React from "react"; +import React from 'react' -export default function Hero({ }) { - return
    -
    -
    -

    - Find awesome people
    on Mastodon. -

    -

    - This tool allows you to expand your connection graph and find new people to follow. It works by - looking up your "follows' follows".

    - Your extended network is a treasure trove! -

    - -
    - - { - /* Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) */ - } View on GitHub - +export default function Hero({}) { + return ( +
    +
    +
    +

    + Find awesome people
    on Mastodon. +

    +

    + This tool allows you to expand your connection graph and find new + people to follow. It works by looking up your "follows' + follows".

    + Your extended network is a treasure trove! +

    - + + + {/* Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) */} + + {' '} + View on GitHub + + + - - {/* Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. */} - Use now - + dark:border-gray-700 " + > + + {/* Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. */} + + + Use now + +
    +
    +
    + Picture of people at a party
    -
    - Picture of people at a party -
    -
    -
    ; + + ) } diff --git a/package-lock.json b/package-lock.json index 2cc8eb4..7b6e621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "autoprefixer": "^10.4.13", "eslint-config-prettier": "^8.5.0", "postcss": "^8.4.20", + "prettier": "2.8.1", "tailwindcss": "^3.2.4" } }, @@ -3440,6 +3441,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", + "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6608,6 +6624,12 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, + "prettier": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", + "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", + "dev": true + }, "prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index f34a1e2..a3c8f5c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "prettier": "prettier --check . --config .prettierrc", + "prettier:fix": "prettier --write . --config .prettierrc" }, "dependencies": { "@next/font": "13.0.7", @@ -32,6 +34,7 @@ "autoprefixer": "^10.4.13", "eslint-config-prettier": "^8.5.0", "postcss": "^8.4.20", + "prettier": "2.8.1", "tailwindcss": "^3.2.4" } } diff --git a/pages/_app.tsx b/pages/_app.tsx index 5b5861a..2cabbfa 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,10 +1,12 @@ import '../styles/globals.css' -import { Analytics } from '@vercel/analytics/react'; +import { Analytics } from '@vercel/analytics/react' import type { AppProps } from 'next/app' export default function App({ Component, pageProps }: AppProps) { - return (<> - - - ); + return ( + <> + + + + ) } diff --git a/pages/index.tsx b/pages/index.tsx index 63ed07d..02c6e3b 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,8 +1,8 @@ -import { Content } from './../components/Content'; -import { FAQ } from './../components/FAQ'; -import Footer from './../components/Footer'; -import Hero from './../components/Hero'; -import Header from './../components/Header'; +import { Content } from './../components/Content' +import { FAQ } from './../components/FAQ' +import Footer from './../components/Footer' +import Hero from './../components/Hero' +import Header from './../components/Header' import Head from 'next/head' export default function Home() { @@ -10,7 +10,10 @@ export default function Home() { <> Followgraph for Mastodon - + diff --git a/public/googledfe269244d136c58.html b/public/googledfe269244d136c58.html index 1c06a75..5695855 100644 --- a/public/googledfe269244d136c58.html +++ b/public/googledfe269244d136c58.html @@ -1 +1 @@ -google-site-verification: googledfe269244d136c58.html \ No newline at end of file +google-site-verification: googledfe269244d136c58.html diff --git a/public/site.webmanifest b/public/site.webmanifest index 45dc8a2..fa99de7 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1 +1,19 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/styles/globals.css b/styles/globals.css index 3ffdd2f..c85cde9 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -2,4 +2,6 @@ @tailwind components; @tailwind utilities; -html { scroll-behavior: smooth; } \ No newline at end of file +html { + scroll-behavior: smooth; +} diff --git a/tailwind.config.js b/tailwind.config.js index f8ecd2c..6aa9dd3 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,8 +1,8 @@ /** @type {import('tailwindcss').Config} */ module.exports = { content: [ - "./pages/**/*.{js,ts,jsx,tsx}", - "./components/**/*.{js,ts,jsx,tsx}", + './pages/**/*.{js,ts,jsx,tsx}', + './components/**/*.{js,ts,jsx,tsx}', ], theme: { extend: {},