Porównaj commity

...

4 Commity

Autor SHA1 Wiadomość Data
Gabi Purcaru f93b146c31
Add search functionality (#28)
* Add search functionality
2023-01-01 19:36:47 +00:00
Gabi Purcaru 11924c8030 fix lint 2023-01-01 18:48:25 +00:00
Gabi Purcaru 5c9079f72e fix typing bug 2023-01-01 18:40:16 +00:00
Gabi Purcaru 189d2c33fe
added privacy policy (#27) 2023-01-01 18:35:08 +00:00
9 zmienionych plików z 460 dodań i 116 usunięć

Wyświetl plik

@ -1,4 +1,5 @@
import React, { useState } from 'react'
import { Spinner } from './Spinner'
import React, { useState, memo, useRef } from 'react'
import sanitizeHtml from 'sanitize-html'
import debounce from 'debounce'
@ -11,7 +12,11 @@ type AccountDetails = {
id: string
acct: string
followed_by: Set<string> // list of handles
followers_count: number
discoverable: boolean
display_name: string
note: string
avatar_static: string
}
async function usernameToId(
@ -166,6 +171,23 @@ function getNextPage(linkHeader: string | null): string | null {
return null
}
function matchesSearch(account: AccountDetails, search: string): boolean {
if (/^\s*$/.test(search)) {
return true
}
const sanitizedSearch = search.replace(/^\s+|\s+$/, '').toLocaleLowerCase()
if (account.acct.toLocaleLowerCase().includes(sanitizedSearch)) {
return true
}
if (account.display_name.toLocaleLowerCase().includes(sanitizedSearch)) {
return true
}
if (account.note.toLocaleLowerCase().includes(sanitizedSearch)) {
return true
}
return false
}
export function Content({}) {
const [handle, setHandle] = useState('')
const [follows, setFollows] = useState<Array<AccountDetails>>([])
@ -264,16 +286,10 @@ export function Content({}) {
ease-in-out"
>
Search
{isLoading ? (
<svg
className="w-4 h-4 ml-2 fill-white animate-spin inline"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
>
{/*! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. */}
<path d="M304 48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zm0 416c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM48 304c26.5 0 48-21.5 48-48s-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48zm464-48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM142.9 437c18.7-18.7 18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zm0-294.2c18.7-18.7 18.7-49.1 0-67.9S93.7 56.2 75 75s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zM369.1 437c18.7 18.7 49.1 18.7 67.9 0s18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9z" />
</svg>
) : null}
<Spinner
visible={isLoading}
className="w-4 h-4 ml-2 fill-white"
/>
</button>
{isLoading ? (
@ -313,24 +329,7 @@ export function Content({}) {
</form>
{isDone || follows.length > 0 ? (
<div className="flex-col lg:flex items-center justify-center">
<div className="max-w-4xl content-center px-2 sm:px-8 py-4 bg-white border rounded-lg shadow-md dark:bg-gray-800 dark:border-gray-700">
<div className="flow-root">
<ul
role="list"
className="divide-y divide-gray-200 dark:divide-gray-700"
>
{follows.slice(0, 500).map((account) => (
<AccountDetails
key={account.acct}
account={account}
mainDomain={domain}
/>
))}
</ul>
</div>
</div>
</div>
<Results follows={follows} domain={domain} />
) : null}
<ErrorLog errors={errors} />
@ -339,87 +338,96 @@ export function Content({}) {
)
}
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 AccountDetails = memo(
({
account,
mainDomain,
}: {
account: AccountDetails
mainDomain: string
}) => {
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 (
<li className="px-4 py-3 pb-7 sm:px-0 sm:py-4">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex-shrink-0 m-auto">
<img
className="w-16 h-16 sm:w-8 sm:h-8 rounded-full"
src={avatar_static}
alt={display_name}
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate dark:text-white">
{display_name}
</p>
<div className="flex flex-col sm:flex-row text-sm text-gray-500 dark:text-gray-400">
<span className="truncate">{acct}</span>
<span className="sm:inline hidden whitespace-pre"> | </span>
<span>{numFollowers} followers</span>
return (
<li className="px-4 py-3 pb-7 sm:px-0 sm:py-4">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex-shrink-0 m-auto">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="w-16 h-16 sm:w-8 sm:h-8 rounded-full"
src={avatar_static}
alt={display_name}
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate dark:text-white">
{display_name}
</p>
<div className="flex flex-col sm:flex-row text-sm text-gray-500 dark:text-gray-400">
<span className="truncate">{acct}</span>
<span className="sm:inline hidden whitespace-pre"> | </span>
<span>{numFollowers} followers</span>
</div>
<br />
<small
className="text-sm dark:text-gray-200"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(note) }}
></small>
<br />
<small className="text-xs text-gray-800 dark:text-gray-400">
Followed by{' '}
{followed_by.size < 9 || expandedFollowers ? (
Array.from<string>(followed_by.values()).map((handle, idx) => (
<React.Fragment key={handle}>
<span className="font-semibold">
{handle.replace(/@.+/, '')}
</span>
{idx === followed_by.size - 1 ? '.' : ', '}
</React.Fragment>
))
) : (
<>
<button
onClick={() => setExpandedFollowers(true)}
className="font-semibold"
>
{followed_by.size} of your contacts
</button>
.
</>
)}
</small>
</div>
<div className="inline-flex m-auto text-base font-semibold text-gray-900 dark:text-white">
<a
href={`https://${mainDomain}/@${acct.replace(
'@' + mainDomain,
''
)}`}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
target="_blank"
rel="noreferrer"
>
Follow
</a>
</div>
<br />
<small
className="text-sm dark:text-gray-200"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(note) }}
></small>
<br />
<small className="text-xs text-gray-800 dark:text-gray-400">
Followed by{' '}
{followed_by.size < 9 || expandedFollowers ? (
Array.from<string>(followed_by.values()).map((handle, idx) => (
<React.Fragment key={handle}>
<span className="font-semibold">
{handle.replace(/@.+/, '')}
</span>
{idx === followed_by.size - 1 ? '.' : ', '}
</React.Fragment>
))
) : (
<>
<button
onClick={() => setExpandedFollowers(true)}
className="font-semibold"
>
{followed_by.size} of your contacts
</button>
.
</>
)}
</small>
</div>
<div className="inline-flex m-auto text-base font-semibold text-gray-900 dark:text-white">
<a
href={`https://${mainDomain}/@${acct.replace(
'@' + mainDomain,
''
)}`}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
target="_blank"
rel="noreferrer"
>
Follow
</a>
</div>
</div>
</li>
)
}
</li>
)
}
)
AccountDetails.displayName = 'AccountDetails'
function ErrorLog({ errors }: { errors: Array<string> }) {
const [expanded, setExpanded] = useState(false)
@ -444,3 +452,99 @@ function ErrorLog({ errors }: { errors: Array<string> }) {
</>
)
}
function Results({
domain,
follows,
}: {
domain: string
follows: Array<AccountDetails>
}) {
let [search, setSearch] = useState<string>('')
const [isLoading, setLoading] = useState(false)
const updateSearch = useRef(
debounce((s: string) => {
setLoading(false)
setSearch(s)
}, 1500)
).current
follows = follows.filter((acc) => matchesSearch(acc, search)).slice(0, 500)
return (
<div className="flex-col lg:flex items-center justify-center">
<div className="max-w-4xl">
<div className="w-full mb-4 dark:text-gray-200">
<label>
<div className="mb-2">
<Spinner
visible={isLoading}
className="w-4 h-4 mr-1 fill-gray-400"
/>
Search:
</div>
<SearchInput
onChange={(s) => {
setLoading(true)
updateSearch(s)
}}
/>
</label>
</div>
<div className="content-center px-2 sm:px-8 py-4 bg-white border rounded-lg shadow-md dark:bg-gray-800 dark:border-gray-700">
<div className="flow-root">
{follows.length === 0 ? (
<p className="text-gray-700 dark:text-gray-200">
No results found.
</p>
) : null}
<ul
role="list"
className="divide-y divide-gray-200 dark:divide-gray-700"
>
{follows.map((account) => (
<AccountDetails
key={account.acct}
account={account}
mainDomain={domain}
/>
))}
</ul>
</div>
</div>
</div>
</div>
)
}
function SearchInput({ onChange }: { onChange: (s: string) => void }) {
let [search, setSearchInputValue] = useState<string>('')
return (
<input
type="text"
placeholder="London"
value={search}
onChange={(e) => {
setSearchInputValue(e.target.value)
onChange(e.target.value)
}}
className="
form-control
block
w-80
px-3
py-1.5
text-base
font-normal
text-gray-700
bg-white bg-clip-padding
border border-solid border-gray-300
rounded
transition
ease-in-out
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"
/>
)
}

Wyświetl plik

@ -71,8 +71,9 @@ export function FAQ({}) {
</FAQItem>
<FAQItem title="Can I download the list of accounts as CSV?">
While it would be a useful feature, Followgraph does <em>not</em> plan to offer this functionality
as it would facilitate inorganic and potentially malicious behaviour.
While it would be a useful feature, Followgraph does <em>not</em>{' '}
plan to offer this functionality as it would facilitate inorganic
and potentially malicious behaviour.
</FAQItem>
</div>
</div>

Wyświetl plik

@ -17,12 +17,15 @@ export default function Footer({}) {
@gabipurcaru@mastodon.online
</Link>
.<br />
<Link
href="/donate"
className="font-bold text-gray-900 dark:text-gray-400"
>
Donate
</Link>
<span className="text-sm font-bold text-gray-900 dark:text-gray-400 ">
<Link href="/privacy" className="underline">
Privacy
</Link>{' '}
|{' '}
<Link href="/donate" className="underline">
Donate
</Link>
</span>
</div>
<span className="block text-sm text-center text-gray-500 dark:text-gray-400">
Built with{' '}

Wyświetl plik

@ -1,7 +1,11 @@
import Link from 'next/link'
import React from 'react'
export default function Header({ selected }: { selected: 'home' | 'donate' }) {
export default function Header({
selected,
}: {
selected: 'home' | 'donate' | 'privacy'
}) {
return (
<header className="fixed w-full">
<nav className="bg-white border-gray-200 py-2.5 dark:bg-gray-900">

Wyświetl plik

@ -0,0 +1,22 @@
import React from 'react'
export function Spinner({
visible,
className,
}: {
visible: boolean
className: string
}) {
if (!visible) {
return null
}
return (
<svg
className={className + ' animate-spin inline'}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
>
{/*! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. */}
<path d="M304 48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zm0 416c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM48 304c26.5 0 48-21.5 48-48s-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48zm464-48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM142.9 437c18.7-18.7 18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zm0-294.2c18.7-18.7 18.7-49.1 0-67.9S93.7 56.2 75 75s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zM369.1 437c18.7 18.7 49.1 18.7 67.9 0s18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9z" />
</svg>
)
}

77
package-lock.json wygenerowano
Wyświetl plik

@ -27,6 +27,7 @@
"typescript": "4.9.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.8",
"@types/sanitize-html": "^2.8.0",
"autoprefixer": "^10.4.13",
"eslint-config-prettier": "^8.5.0",
@ -387,6 +388,34 @@
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.8.tgz",
"integrity": "sha512-xGQEp8KXN8Sd8m6R4xYmwxghmswrd0cPnNI2Lc6fmrC3OojysTBJJGSIVwPV56q4t6THFUK3HJ0EaWwpglSxWw==",
"dev": true,
"dependencies": {
"lodash.castarray": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2",
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@ -2773,6 +2802,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -4518,6 +4559,30 @@
"tslib": "^2.4.0"
}
},
"@tailwindcss/typography": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.8.tgz",
"integrity": "sha512-xGQEp8KXN8Sd8m6R4xYmwxghmswrd0cPnNI2Lc6fmrC3OojysTBJJGSIVwPV56q4t6THFUK3HJ0EaWwpglSxWw==",
"dev": true,
"requires": {
"lodash.castarray": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2",
"postcss-selector-parser": "6.0.10"
},
"dependencies": {
"postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"requires": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
}
}
}
},
"@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@ -6206,6 +6271,18 @@
"p-locate": "^5.0.0"
}
},
"lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true
},
"lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true
},
"lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",

Wyświetl plik

@ -30,6 +30,7 @@
"typescript": "4.9.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.8",
"@types/sanitize-html": "^2.8.0",
"autoprefixer": "^10.4.13",
"eslint-config-prettier": "^8.5.0",

132
pages/privacy.tsx 100644
Wyświetl plik

@ -0,0 +1,132 @@
import Footer from './../components/Footer'
import Donate from './../components/Donate'
import Header from './../components/Header'
import Head from 'next/head'
export default function Privacy() {
return (
<>
<Head>
<title>Followgraph for Mastodon</title>
<meta name="description" content="Privacy policy" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script type="application/ld+json">
{`{
"@context": "https://schema.org",
"@type": "WebSite",
"url": "https://followgraph.vercel.app/",
"image": {
"@type": "ImageObject",
"@id": "https://followgraph.vercel.app/#/schema/ImageObject/FollowGraphThumbnail",
"url": "/ldjson-logo.jpg",
"contentUrl": "/ldjson-logo.jpg",
"caption": "Followgraph for Mastodon",
"width": 345,
"height": 345
}
}`}
</script>
<link rel="icon" href="/favicon.ico" />
</Head>
<div>
<Header selected="privacy" />
<section className="pt-32 dark:bg-gray-800">
<div className="prose dark:prose-invert max-w-5xl px-10">
<h1>tl;dr</h1>
<p>
Please refer to the full Privacy Policy below. The short summary
is that Followgraph is a static website which{' '}
<strong>
sets no cookies and requires no authentication. The only data
gathered is through{' '}
<a href="https://vercel.com/analytics">Vercel Analytics</a>
</strong>
, which gathers aggregated visitor and demographic statistics
about site visitors in a privacy-sensitive way.
</p>
<h1>Privacy Policy for Followgraph for Mastodon</h1>
<p>
At Followgraph for Mastodon, accessible from
https://followgraph.vercel.app/, one of our main priorities is the
privacy of our visitors. This Privacy Policy document contains
types of information that is collected and recorded by Followgraph
for Mastodon and how we use it.
</p>
<p>
If you have additional questions or require more information about
our Privacy Policy, do not hesitate to contact us.
</p>
<h2>Log Files</h2>
<p>
Followgraph for Mastodon follows a standard procedure of using log
files. These files log visitors when they visit websites. All
hosting companies do this and a part of hosting services&apos;
analytics. The information collected by log files include internet
protocol (IP) addresses, browser type, Internet Service Provider
(ISP), date and time stamp, referring/exit pages, and possibly the
number of clicks. These are not linked to any information that is
personally identifiable. The purpose of the information is for
analyzing trends, administering the site, tracking users&apos;
movement on the website, and gathering demographic information.
Our Privacy Policy was created with the help of the{' '}
<a href="https://www.privacypolicyonline.com/privacy-policy-generator/">
Privacy Policy Generator
</a>
.
</p>
<h2>Third Party Privacy Policies</h2>
<p>
Followgraph for Mastodon&apos;s Privacy Policy does not apply to
other websites. In particular, Followgraph uses Vercel Analytics
to gather basic information about site usage. You can find Vercel
Analytics&apos; Privacy Policy here:
https://vercel.com/legal/privacy-policy.
</p>
<h2>Children&apos;s Information</h2>
<p>
Another part of our priority is adding protection for children
while using the internet. We encourage parents and guardians to
observe, participate in, and/or monitor and guide their online
activity.
</p>
<p>
Followgraph for Mastodon does not knowingly collect any Personal
Identifiable Information from children under the age of 13. If you
think that your child provided this kind of information on our
website, we strongly encourage you to contact us immediately and
we will do our best efforts to promptly remove such information
from our records.
</p>
<h2>Online Privacy Policy Only</h2>
<p>
This Privacy Policy applies only to our online activities and is
valid for visitors to our website with regards to the information
that they shared and/or collect in Followgraph for Mastodon. This
policy is not applicable to any information collected offline or
via channels other than this website.
</p>
<h2>Consent</h2>
<p>
By using our website, you hereby consent to our Privacy Policy and
agree to its Terms and Conditions.
</p>
</div>
</section>
<Footer />
</div>
</>
)
}

Wyświetl plik

@ -7,5 +7,5 @@ module.exports = {
theme: {
extend: {},
},
plugins: [],
plugins: [require('@tailwindcss/typography')],
}