kopia lustrzana https://github.com/gabipurcaru/followgraph
Add prettier package and apply config to all files (#11)
rodzic
1a368e963f
commit
8141375a2b
|
@ -1,6 +1,3 @@
|
|||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"prettier"
|
||||
]
|
||||
}
|
||||
"extends": ["next/core-web-vitals", "prettier"]
|
||||
}
|
||||
|
|
|
@ -3,4 +3,4 @@
|
|||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<string>, // list of handles
|
||||
};
|
||||
id: string // IMPORTANT: this is int64 so will overflow Javascript's number type
|
||||
acct: string
|
||||
followed_by: Set<string> // 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<Array<AccountDetails>> {
|
||||
let id, domain: string;
|
||||
async function accountFollows(
|
||||
handle: string,
|
||||
limit: number,
|
||||
logError: (x: string) => void
|
||||
): Promise<Array<AccountDetails>> {
|
||||
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<AccountDetails> = [];
|
||||
let nextPage:
|
||||
| string
|
||||
| null = `https://${domain}/api/v1/accounts/${id}/following`
|
||||
let data: Array<AccountDetails> = []
|
||||
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<AccountDetails>) => void,
|
||||
logError: (x: string) => void
|
||||
): Promise<void> {
|
||||
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<Array<AccountDetails>> = [];
|
||||
const indirectFollowLists: Array<Array<AccountDetails>> = []
|
||||
|
||||
const updateList = debounce(() => {
|
||||
let indirectFollows: Array<AccountDetails> = [].concat([], ...indirectFollowLists);
|
||||
const indirectFollowMap = new Map();
|
||||
let indirectFollows: Array<AccountDetails> = [].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: <https://mastodon.example/api/v1/accounts/1/follows?limit=2&max_id=7628164>; rel="next", <https://mastodon.example/api/v1/accounts/1/follows?limit=2&since_id=7628165>; 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<Array<AccountDetails>>([]);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [isDone, setDone] = useState(false);
|
||||
const [domain, setDomain] = useState<string>("");
|
||||
const [[numLoaded, totalToLoad], setProgress] = useState<Array<number>>([0, 0]);
|
||||
const [errors, setErrors] = useState<Array<string>>([]);
|
||||
export function Content({}) {
|
||||
const [handle, setHandle] = useState('')
|
||||
const [follows, setFollows] = useState<Array<AccountDetails>>([])
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [isDone, setDone] = useState(false)
|
||||
const [domain, setDomain] = useState<string>('')
|
||||
const [[numLoaded, totalToLoad], setProgress] = useState<Array<number>>([
|
||||
0, 0,
|
||||
])
|
||||
const [errors, setErrors] = useState<Array<string>>([])
|
||||
|
||||
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 <section className="bg-gray-50 dark:bg-gray-800" id="searchForm">
|
||||
<div className="px-4 py-8 mx-auto space-y-12 lg:space-y-20 lg:py-24 lg:px-6">
|
||||
<form onSubmit={e => {
|
||||
search(handle);
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}>
|
||||
<div className="form-group mb-6 text-4xl lg:ml-16">
|
||||
<label htmlFor="mastodonHandle" className="form-label inline-block mb-2 text-gray-700 dark:text-gray-200">Your Mastodon handle:</label>
|
||||
<input type="text" value={handle} onChange={e => setHandle(e.target.value)} className="form-control
|
||||
return (
|
||||
<section className="bg-gray-50 dark:bg-gray-800" id="searchForm">
|
||||
<div className="px-4 py-8 mx-auto space-y-12 lg:space-y-20 lg:py-24 lg:px-6">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
search(handle)
|
||||
e.preventDefault()
|
||||
return false
|
||||
}}
|
||||
>
|
||||
<div className="form-group mb-6 text-4xl lg:ml-16">
|
||||
<label
|
||||
htmlFor="mastodonHandle"
|
||||
className="form-label inline-block mb-2 text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
Your Mastodon handle:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={handle}
|
||||
onChange={(e) => 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" />
|
||||
<small id="mastodonHandleHelp" className="block mt-1 text-xs text-gray-600 dark:text-gray-300">Be sure to include the full handle, including the domain.</small>
|
||||
"
|
||||
id="mastodonHandle"
|
||||
aria-describedby="mastodonHandleHelp"
|
||||
placeholder="johnmastodon@mas.to"
|
||||
/>
|
||||
<small
|
||||
id="mastodonHandleHelp"
|
||||
className="block mt-1 text-xs text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Be sure to include the full handle, including the domain.
|
||||
</small>
|
||||
|
||||
<button type="submit" className="
|
||||
<button
|
||||
type="submit"
|
||||
className="
|
||||
px-6
|
||||
py-2.5
|
||||
bg-green-600
|
||||
|
@ -213,61 +257,107 @@ export function Content({ }) {
|
|||
active:bg-green-800 active:shadow-lg
|
||||
transition
|
||||
duration-150
|
||||
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}
|
||||
</button>
|
||||
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}
|
||||
</button>
|
||||
|
||||
{isLoading ?
|
||||
<p className="text-sm dark:text-gray-400">Loaded {numLoaded} of {totalToLoad}...</p>
|
||||
: null}
|
||||
{isLoading ? (
|
||||
<p className="text-sm dark:text-gray-400">
|
||||
Loaded {numLoaded} of {totalToLoad}...
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isDone && follows.length === 0 ?
|
||||
<div className="flex p-4 mt-4 max-w-full sm:max-w-xl text-sm text-gray-700 bg-gray-100 rounded-lg dark:bg-gray-700 dark:text-gray-300" role="alert">
|
||||
<svg aria-hidden="true" className="flex-shrink-0 inline w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>
|
||||
<span className="sr-only">Info</span>
|
||||
<div>
|
||||
<span className="font-medium">No results found.</span> 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 ? (
|
||||
<div
|
||||
className="flex p-4 mt-4 max-w-full sm:max-w-xl text-sm text-gray-700 bg-gray-100 rounded-lg dark:bg-gray-700 dark:text-gray-300"
|
||||
role="alert"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 inline w-5 h-5 mr-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="sr-only">Info</span>
|
||||
<div>
|
||||
<span className="font-medium">No results found.</span> 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.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</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>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
</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>
|
||||
: null}
|
||||
) : null}
|
||||
|
||||
<ErrorLog errors={errors} />
|
||||
</div>
|
||||
</section>;
|
||||
<ErrorLog errors={errors} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className="py-3 sm:py-4">
|
||||
<div className="flex flex-col sm:flex-row items-center space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<img className="w-16 h-16 sm:w-8 sm:h-8 rounded-full" src={avatar_static} alt={display_name} />
|
||||
<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">
|
||||
|
@ -277,36 +367,73 @@ function AccountDetails({ account, mainDomain }) {
|
|||
{acct} | {numFollowers} followers
|
||||
</p>
|
||||
<br />
|
||||
<small className="text-sm dark:text-gray-200" dangerouslySetInnerHTML={{ __html: sanitizeHtml(note) }}></small>
|
||||
<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 ?
|
||||
|
||||
{followed_by.size < 9 || expandedFollowers ? (
|
||||
Array.from<string>(followed_by.values()).map((handle, idx) => (
|
||||
<><span className="font-semibold">{handle.replace(/@.+/, '')}</span>{idx === followed_by.size - 1 ? '.' : ', '}</>
|
||||
<>
|
||||
<span className="font-semibold">
|
||||
{handle.replace(/@.+/, '')}
|
||||
</span>
|
||||
{idx === followed_by.size - 1 ? '.' : ', '}
|
||||
</>
|
||||
))
|
||||
: <>
|
||||
<button onClick={() => setExpandedFollowers(true)} className="font-semibold">{followed_by.size} of your contacts</button>.
|
||||
</>}
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setExpandedFollowers(true)}
|
||||
className="font-semibold"
|
||||
>
|
||||
{followed_by.size} of your contacts
|
||||
</button>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white my-4 sm:my-0">
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorLog({ errors }: { errors: Array<string> }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (<>
|
||||
{errors.length > 0 ? <div className="text-sm text-gray-600 dark:text-gray-200 lg:ml-12 border border-solid border-gray-200 dark:border-gray-700 rounded p-4">
|
||||
Found <button className="font-bold" onClick={() => setExpanded(!expanded)}>{errors.length} warnings</button>{expanded ? ':' : '.'}
|
||||
{expanded ? errors.map(err => <p key={err} className="text-xs">{err}</p>) : null}
|
||||
</div> : null}
|
||||
</>);
|
||||
}
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
return (
|
||||
<>
|
||||
{errors.length > 0 ? (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-200 lg:ml-12 border border-solid border-gray-200 dark:border-gray-700 rounded p-4">
|
||||
Found{' '}
|
||||
<button className="font-bold" onClick={() => setExpanded(!expanded)}>
|
||||
{errors.length} warnings
|
||||
</button>
|
||||
{expanded ? ':' : '.'}
|
||||
{expanded
|
||||
? errors.map((err) => (
|
||||
<p key={err} className="text-xs">
|
||||
{err}
|
||||
</p>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,62 +1,127 @@
|
|||
import React, { useState } from "react";
|
||||
export function FAQ({ }) {
|
||||
return <section className="bg-white dark:bg-gray-900 pt-12">
|
||||
<div className="max-w-screen-xl px-4 pb-8 mx-auto lg:pb-24 lg:px-6" id="faq">
|
||||
<h2 className="mb-6 text-3xl font-extrabold tracking-tight text-center text-gray-900 lg:mb-8 lg:text-3xl dark:text-white">Frequently asked questions</h2>
|
||||
<div className="max-w-screen-md mx-auto">
|
||||
<div id="accordion-flush" data-accordion="collapse" data-active-classes="bg-white dark:bg-gray-900 text-gray-900 dark:text-white" data-inactive-classes="text-gray-500 dark:text-gray-400">
|
||||
<FAQItem defaultSelected title="How does this work?">
|
||||
The tool looks up all the people you follow, and then the people <em>they</em> follow. Then
|
||||
it sorts them by the number of mutuals, or otherwise by how popular those accounts are.
|
||||
</FAQItem>
|
||||
import React, { useState } from 'react'
|
||||
export function FAQ({}) {
|
||||
return (
|
||||
<section className="bg-white dark:bg-gray-900 pt-12">
|
||||
<div
|
||||
className="max-w-screen-xl px-4 pb-8 mx-auto lg:pb-24 lg:px-6"
|
||||
id="faq"
|
||||
>
|
||||
<h2 className="mb-6 text-3xl font-extrabold tracking-tight text-center text-gray-900 lg:mb-8 lg:text-3xl dark:text-white">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className="max-w-screen-md mx-auto">
|
||||
<div
|
||||
id="accordion-flush"
|
||||
data-accordion="collapse"
|
||||
data-active-classes="bg-white dark:bg-gray-900 text-gray-900 dark:text-white"
|
||||
data-inactive-classes="text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<FAQItem defaultSelected title="How does this work?">
|
||||
The tool looks up all the people you follow, and then the people{' '}
|
||||
<em>they</em> follow. Then it sorts them by the number of mutuals,
|
||||
or otherwise by how popular those accounts are.
|
||||
</FAQItem>
|
||||
|
||||
<FAQItem title="Do I need to grant the app any permissions?">
|
||||
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.
|
||||
</FAQItem>
|
||||
<FAQItem title="Do I need to grant the app any permissions?">
|
||||
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.
|
||||
</FAQItem>
|
||||
|
||||
<FAQItem title="Help! The search got stuck.">
|
||||
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.
|
||||
</FAQItem>
|
||||
<FAQItem title="Help! The search got stuck.">
|
||||
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.
|
||||
</FAQItem>
|
||||
|
||||
<FAQItem title="Why don't I see any results?">
|
||||
There could be a few reasons:
|
||||
<ul className="list-disc ml-4">
|
||||
<li>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.</li>
|
||||
<li>Due to the high volume of requests, sometimes Mastodon throttles this tool. If that's the case, try again a bit later.</li>
|
||||
<li>Make sure you have no typos in the Mastodon handle, and make sure you follow at least a few people to seed the search.</li>
|
||||
</ul>
|
||||
</FAQItem>
|
||||
<FAQItem title="Why don't I see any results?">
|
||||
There could be a few reasons:
|
||||
<ul className="list-disc ml-4">
|
||||
<li>
|
||||
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.
|
||||
</li>
|
||||
<li>
|
||||
Due to the high volume of requests, sometimes Mastodon
|
||||
throttles this tool. If that's the case, try again a bit
|
||||
later.
|
||||
</li>
|
||||
<li>
|
||||
Make sure you have no typos in the Mastodon handle, and make
|
||||
sure you follow at least a few people to seed the search.
|
||||
</li>
|
||||
</ul>
|
||||
</FAQItem>
|
||||
|
||||
<FAQItem title="How can I contribute with suggestions?">
|
||||
Click the "Fork me on Github" link on the top right, and open up an issue.
|
||||
</FAQItem>
|
||||
<FAQItem title="How can I contribute with suggestions?">
|
||||
Click the "Fork me on Github" link on the top right, and
|
||||
open up an issue.
|
||||
</FAQItem>
|
||||
|
||||
<FAQItem title="Why is this not a core Mastodon feature?">
|
||||
Well, maybe it should be. In the meantime, you can use this website.
|
||||
</FAQItem>
|
||||
<FAQItem title="Why is this not a core Mastodon feature?">
|
||||
Well, maybe it should be. In the meantime, you can use this
|
||||
website.
|
||||
</FAQItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FAQItem({ defaultSelected, title, children }: { defaultSelected?: boolean, title: string, children: React.ReactNode }) {
|
||||
const [selected, setSelected] = useState(defaultSelected);
|
||||
return (<>
|
||||
<h3 id="accordion-flush-heading-1">
|
||||
<button type="button" onClick={() => setSelected(!selected)} className={`flex items-center justify-between w-full py-5 font-medium text-left text-gray-${selected ? 900 : 500} bg-white border-b border-gray-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-${selected ? 200 : 400}`} data-accordion-target="#accordion-flush-body-1" aria-expanded="true" aria-controls="accordion-flush-body-1">
|
||||
<span>{title}</span>
|
||||
<svg data-accordion-icon className="w-6 h-6 rotate-180 shrink-0" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
{selected ?
|
||||
<div id="accordion-flush-body-1" aria-labelledby="accordion-flush-heading-1">
|
||||
<div className="py-5 border-b border-gray-200 dark:border-gray-700 dark:text-gray-300">
|
||||
{children}
|
||||
function FAQItem({
|
||||
defaultSelected,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
defaultSelected?: boolean
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [selected, setSelected] = useState(defaultSelected)
|
||||
return (
|
||||
<>
|
||||
<h3 id="accordion-flush-heading-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(!selected)}
|
||||
className={`flex items-center justify-between w-full py-5 font-medium text-left text-gray-${
|
||||
selected ? 900 : 500
|
||||
} bg-white border-b border-gray-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-${
|
||||
selected ? 200 : 400
|
||||
}`}
|
||||
data-accordion-target="#accordion-flush-body-1"
|
||||
aria-expanded="true"
|
||||
aria-controls="accordion-flush-body-1"
|
||||
>
|
||||
<span>{title}</span>
|
||||
<svg
|
||||
data-accordion-icon
|
||||
className="w-6 h-6 rotate-180 shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</h3>
|
||||
{selected ? (
|
||||
<div
|
||||
id="accordion-flush-body-1"
|
||||
aria-labelledby="accordion-flush-heading-1"
|
||||
>
|
||||
<div className="py-5 border-b border-gray-200 dark:border-gray-700 dark:text-gray-300">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div> : null}
|
||||
</>);
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,16 +1,41 @@
|
|||
import React from "react";
|
||||
export default function Footer({ }) {
|
||||
return <footer className="bg-white dark:bg-gray-800">
|
||||
<div className="max-w-screen-xl p-4 py-6 mx-auto lg:py-16 md:p-8 lg:p-10">
|
||||
<hr className="my-6 border-gray-200 sm:mx-auto dark:border-gray-700 lg:my-8" />
|
||||
<div className="text-center">
|
||||
<div className="mb-5 lg:text-2xl font-semibold text-gray-700 dark:text-white text-lg">
|
||||
Followgraph for Mastodon, built by <a href="https://mastodon.online/@gabipurcaru" target="_blank" rel="noreferrer" className="font-bold text-gray-900 dark:text-gray-400">@gabipurcaru@mastodon.online</a>.
|
||||
</div>
|
||||
<span className="block text-sm text-center text-gray-500 dark:text-gray-400">Built with <a href="https://flowbite.com" className="text-purple-600 hover:underline dark:text-purple-500">Flowbite</a> and <a href="https://tailwindcss.com" className="text-purple-600 hover:underline dark:text-purple-500">Tailwind CSS</a>.
|
||||
</span>
|
||||
|
||||
</div>
|
||||
import React from 'react'
|
||||
export default function Footer({}) {
|
||||
return (
|
||||
<footer className="bg-white dark:bg-gray-800">
|
||||
<div className="max-w-screen-xl p-4 py-6 mx-auto lg:py-16 md:p-8 lg:p-10">
|
||||
<hr className="my-6 border-gray-200 sm:mx-auto dark:border-gray-700 lg:my-8" />
|
||||
<div className="text-center">
|
||||
<div className="mb-5 lg:text-2xl font-semibold text-gray-700 dark:text-white text-lg">
|
||||
Followgraph for Mastodon, built by {' '}
|
||||
<a
|
||||
href="https://mastodon.online/@gabipurcaru"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-bold text-gray-900 dark:text-gray-400"
|
||||
>
|
||||
@gabipurcaru@mastodon.online
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<span className="block text-sm text-center text-gray-500 dark:text-gray-400">
|
||||
Built with{' '}
|
||||
<a
|
||||
href="https://flowbite.com"
|
||||
className="text-purple-600 hover:underline dark:text-purple-500"
|
||||
>
|
||||
Flowbite
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href="https://tailwindcss.com"
|
||||
className="text-purple-600 hover:underline dark:text-purple-500"
|
||||
>
|
||||
Tailwind CSS
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
</footer>;
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,39 +1,80 @@
|
|||
import React from "react";
|
||||
import React from 'react'
|
||||
export default function Header() {
|
||||
return (<header className="fixed w-full">
|
||||
<nav className="bg-white border-gray-200 py-2.5 dark:bg-gray-900">
|
||||
<div className="flex flex-wrap items-center justify-between max-w-screen-xl px-4 mx-auto">
|
||||
<Logo />
|
||||
return (
|
||||
<header className="fixed w-full">
|
||||
<nav className="bg-white border-gray-200 py-2.5 dark:bg-gray-900">
|
||||
<div className="flex flex-wrap items-center justify-between max-w-screen-xl px-4 mx-auto">
|
||||
<Logo />
|
||||
|
||||
<div className="items-center justify-between hidden w-full lg:flex lg:w-auto lg:order-1" id="mobile-menu-2">
|
||||
<ul className="flex flex-col mt-4 font-medium lg:flex-row lg:space-x-8 lg:mt-0">
|
||||
<MenuItem link="#" selected>Home</MenuItem>
|
||||
<MenuItem link="#faq">FAQ</MenuItem>
|
||||
<MenuItem link="https://github.com/gabipurcaru/followgraph">Fork me on GitHub</MenuItem>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>);
|
||||
<div
|
||||
className="items-center justify-between hidden w-full lg:flex lg:w-auto lg:order-1"
|
||||
id="mobile-menu-2"
|
||||
>
|
||||
<ul className="flex flex-col mt-4 font-medium lg:flex-row lg:space-x-8 lg:mt-0">
|
||||
<MenuItem link="#" selected>
|
||||
Home
|
||||
</MenuItem>
|
||||
<MenuItem link="#faq">FAQ</MenuItem>
|
||||
<MenuItem link="https://github.com/gabipurcaru/followgraph">
|
||||
Fork me on GitHub
|
||||
</MenuItem>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
|
||||
function Logo({ }) {
|
||||
return (<a href="#" className="flex items-center">
|
||||
<svg className="w-12 h-12 mr-4 dark:fill-white" xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 342.68"><path d="M3.59 300.55a3.59 3.59 0 0 1-3.59-3.6c0-1.02.14-2.03.38-3.03 5.77-45.65 41.51-57.84 66.87-64.42 12.69-3.29 44.26-15.82 31.68-33.04-7.05-9.67-13.44-16.47-19.83-26.68-4.61-6.81-7.04-12.88-7.04-17.75 0-5.19 2.75-11.27 8.26-12.64-.73-10.45-.97-24.2-.48-35.39 1.75-19.2 15.52-33.35 33.31-39.62 7.05-2.68 3.64-13.38 11.42-13.62 18.24-.49 48.14 15.07 59.82 27.71 6.81 7.3 11.18 17.02 11.91 29.91l-.73 32.22c3.4.98 5.59 3.16 6.57 6.56.97 3.89 0 9.25-3.41 16.79 0 .24-.24.24-.24.48-7.51 12.37-15.33 20.56-23.92 32.03-3.84 5.11-3.09 10.01.1 14.41-4.48 2.62-8.85 5.62-13.06 9.16-16.76 14.07-29.68 35.08-34.13 68.61l-.5 2.9c-.24 1.9-.38 3.48-.38 4.66 0 1.48.13 2.93.37 4.35H3.59zM428 174.68c46.41 0 84 37.62 84 84 0 46.4-37.62 84-84 84-46.4 0-84-37.62-84-84 0-46.41 37.61-84 84-84zm-13.25 49.44c-.03-3.91-.39-6.71 4.46-6.64l15.7.19c5.07-.03 6.42 1.58 6.36 6.33v21.43h21.3c3.91-.04 6.7-.4 6.63 4.45l-.19 15.71c.03 5.07-1.58 6.42-6.32 6.36h-21.42v21.41c.06 4.75-1.29 6.36-6.36 6.33l-15.7.18c-4.85.08-4.49-2.72-4.46-6.63v-21.29h-21.43c-4.75.06-6.35-1.29-6.32-6.36l-.19-15.71c-.08-4.85 2.72-4.49 6.62-4.45h21.32v-21.31zm-261.98 76.47c-2.43 0-4.39-1.96-4.39-4.39 0-1.25.17-2.48.47-3.7 7.03-55.71 40.42-67.83 71.33-75.78 14.84-3.82 44.44-18.71 40.85-37.91-7.49-6.94-14.92-16.53-16.21-30.83l-.9.02c-2.07-.03-4.08-.5-5.95-1.56-4.13-2.35-6.4-6.85-7.49-11.99-2.29-15.67-2.86-23.67 5.49-27.17l.07-.02c-1.04-19.34 2.23-47.79-17.63-53.8 39.21-48.44 84.41-74.8 118.34-31.7 37.81 1.98 54.67 55.54 31.19 85.52h-.99c8.36 3.5 7.1 12.49 5.49 27.17-1.09 5.14-3.36 9.64-7.49 11.99-1.87 1.06-3.87 1.53-5.95 1.56l-.9-.02c-1.29 14.3-8.74 23.89-16.23 30.83-1.01 5.43.63 10.52 3.84 15.11-14.05 17.81-22.44 40.31-22.44 64.76 0 14.89 3.11 29.07 8.73 41.91H152.77z" /></svg>
|
||||
<span className="self-center text-xl font-semibold whitespace-nowrap dark:text-white">Followgraph for Mastodon</span>
|
||||
</a>);
|
||||
}
|
||||
function Logo({}) {
|
||||
return (
|
||||
<a href="#" className="flex items-center">
|
||||
<svg
|
||||
className="w-12 h-12 mr-4 dark:fill-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
shape-rendering="geometricPrecision"
|
||||
text-rendering="geometricPrecision"
|
||||
image-rendering="optimizeQuality"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 512 342.68"
|
||||
>
|
||||
<path d="M3.59 300.55a3.59 3.59 0 0 1-3.59-3.6c0-1.02.14-2.03.38-3.03 5.77-45.65 41.51-57.84 66.87-64.42 12.69-3.29 44.26-15.82 31.68-33.04-7.05-9.67-13.44-16.47-19.83-26.68-4.61-6.81-7.04-12.88-7.04-17.75 0-5.19 2.75-11.27 8.26-12.64-.73-10.45-.97-24.2-.48-35.39 1.75-19.2 15.52-33.35 33.31-39.62 7.05-2.68 3.64-13.38 11.42-13.62 18.24-.49 48.14 15.07 59.82 27.71 6.81 7.3 11.18 17.02 11.91 29.91l-.73 32.22c3.4.98 5.59 3.16 6.57 6.56.97 3.89 0 9.25-3.41 16.79 0 .24-.24.24-.24.48-7.51 12.37-15.33 20.56-23.92 32.03-3.84 5.11-3.09 10.01.1 14.41-4.48 2.62-8.85 5.62-13.06 9.16-16.76 14.07-29.68 35.08-34.13 68.61l-.5 2.9c-.24 1.9-.38 3.48-.38 4.66 0 1.48.13 2.93.37 4.35H3.59zM428 174.68c46.41 0 84 37.62 84 84 0 46.4-37.62 84-84 84-46.4 0-84-37.62-84-84 0-46.41 37.61-84 84-84zm-13.25 49.44c-.03-3.91-.39-6.71 4.46-6.64l15.7.19c5.07-.03 6.42 1.58 6.36 6.33v21.43h21.3c3.91-.04 6.7-.4 6.63 4.45l-.19 15.71c.03 5.07-1.58 6.42-6.32 6.36h-21.42v21.41c.06 4.75-1.29 6.36-6.36 6.33l-15.7.18c-4.85.08-4.49-2.72-4.46-6.63v-21.29h-21.43c-4.75.06-6.35-1.29-6.32-6.36l-.19-15.71c-.08-4.85 2.72-4.49 6.62-4.45h21.32v-21.31zm-261.98 76.47c-2.43 0-4.39-1.96-4.39-4.39 0-1.25.17-2.48.47-3.7 7.03-55.71 40.42-67.83 71.33-75.78 14.84-3.82 44.44-18.71 40.85-37.91-7.49-6.94-14.92-16.53-16.21-30.83l-.9.02c-2.07-.03-4.08-.5-5.95-1.56-4.13-2.35-6.4-6.85-7.49-11.99-2.29-15.67-2.86-23.67 5.49-27.17l.07-.02c-1.04-19.34 2.23-47.79-17.63-53.8 39.21-48.44 84.41-74.8 118.34-31.7 37.81 1.98 54.67 55.54 31.19 85.52h-.99c8.36 3.5 7.1 12.49 5.49 27.17-1.09 5.14-3.36 9.64-7.49 11.99-1.87 1.06-3.87 1.53-5.95 1.56l-.9-.02c-1.29 14.3-8.74 23.89-16.23 30.83-1.01 5.43.63 10.52 3.84 15.11-14.05 17.81-22.44 40.31-22.44 64.76 0 14.89 3.11 29.07 8.73 41.91H152.77z" />
|
||||
</svg>
|
||||
<span className="self-center text-xl font-semibold whitespace-nowrap dark:text-white">
|
||||
Followgraph for Mastodon
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function MenuItem({ link, children, selected }: { link: string, children: string | React.ReactElement, selected?: boolean }) {
|
||||
return (<li>
|
||||
{selected ?
|
||||
<a href={link} className="block py-2 pl-3 pr-4 text-white bg-purple-700 rounded lg:bg-transparent lg:text-purple-700 lg:p-0 dark:text-white" aria-current="page">
|
||||
{children}
|
||||
</a>
|
||||
:
|
||||
<a href={link} className="block py-2 pl-3 pr-4 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-purple-700 lg:p-0 dark:text-gray-300 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">
|
||||
{children}
|
||||
</a>
|
||||
}
|
||||
</li>);
|
||||
function MenuItem({
|
||||
link,
|
||||
children,
|
||||
selected,
|
||||
}: {
|
||||
link: string
|
||||
children: string | React.ReactElement
|
||||
selected?: boolean
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
{selected ? (
|
||||
<a
|
||||
href={link}
|
||||
className="block py-2 pl-3 pr-4 text-white bg-purple-700 rounded lg:bg-transparent lg:text-purple-700 lg:p-0 dark:text-white"
|
||||
aria-current="page"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={link}
|
||||
className="block py-2 pl-3 pr-4 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-purple-700 lg:p-0 dark:text-gray-300 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,27 +1,40 @@
|
|||
import Image from 'next/image'
|
||||
import React from "react";
|
||||
import React from 'react'
|
||||
|
||||
export default function Hero({ }) {
|
||||
return <section className="bg-white dark:bg-gray-900">
|
||||
<div className="grid max-w-screen-xl px-4 pt-20 pb-8 mx-auto lg:gap-8 xl:gap-0 lg:py-16 lg:grid-cols-12 lg:pt-28 lg:px-20">
|
||||
<div className="mr-auto place-self-center lg:col-span-7">
|
||||
<h1 className="max-w-2xl mb-4 text-4xl font-extrabold leading-none tracking-tight md:text-5xl xl:text-6xl dark:text-white">
|
||||
Find awesome people <br /> on Mastodon.
|
||||
</h1>
|
||||
<p className="max-w-2xl mb-6 font-light text-gray-500 lg:mb-8 md:text-lg lg:text-xl dark:text-gray-400">
|
||||
This tool allows you to expand your connection graph and find new people to follow. It works by
|
||||
looking up your "follows' follows". <br /> <br/>
|
||||
Your extended network is a treasure trove!
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 sm:flex sm:space-y-0 sm:space-x-4 ">
|
||||
<a href="https://github.com/gabipurcaru/followgraph" className="inline-flex items-center justify-center w-full px-5 py-3 text-sm font-medium text-center text-gray-900 border border-gray-200 rounded-lg sm:w-auto hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 dark:text-white dark:border-gray-700 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
|
||||
<svg className="w-4 h-4 mr-2 text-gray-500 dark:fill-gray-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">{
|
||||
/* Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) */
|
||||
}<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" /></svg> View on GitHub
|
||||
</a>
|
||||
export default function Hero({}) {
|
||||
return (
|
||||
<section className="bg-white dark:bg-gray-900">
|
||||
<div className="grid max-w-screen-xl px-4 pt-20 pb-8 mx-auto lg:gap-8 xl:gap-0 lg:py-16 lg:grid-cols-12 lg:pt-28 lg:px-20">
|
||||
<div className="mr-auto place-self-center lg:col-span-7">
|
||||
<h1 className="max-w-2xl mb-4 text-4xl font-extrabold leading-none tracking-tight md:text-5xl xl:text-6xl dark:text-white">
|
||||
Find awesome people <br /> on Mastodon.
|
||||
</h1>
|
||||
<p className="max-w-2xl mb-6 font-light text-gray-500 lg:mb-8 md:text-lg lg:text-xl dark:text-gray-400">
|
||||
This tool allows you to expand your connection graph and find new
|
||||
people to follow. It works by looking up your "follows'
|
||||
follows". <br /> <br />
|
||||
Your extended network is a treasure trove!
|
||||
</p>
|
||||
|
||||
<a href="#searchForm" className="
|
||||
<div className="space-y-4 sm:flex sm:space-y-0 sm:space-x-4 ">
|
||||
<a
|
||||
href="https://github.com/gabipurcaru/followgraph"
|
||||
className="inline-flex items-center justify-center w-full px-5 py-3 text-sm font-medium text-center text-gray-900 border border-gray-200 rounded-lg sm:w-auto hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 dark:text-white dark:border-gray-700 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 text-gray-500 dark:fill-gray-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 496 512"
|
||||
>
|
||||
{/* Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) */}
|
||||
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
|
||||
</svg>{' '}
|
||||
View on GitHub
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#searchForm"
|
||||
className="
|
||||
inline-flex items-center justify-center w-full
|
||||
px-5 py-3 text-sm font-medium text-center
|
||||
text-gray-900 border
|
||||
|
@ -32,22 +45,30 @@ export default function Hero({ }) {
|
|||
|
||||
dark:bg-green-700 dark:hover:bg-green-600
|
||||
dark:focus:ring-gray-800
|
||||
dark:border-gray-700 ">
|
||||
|
||||
<svg className="w-4 h-4 mr-2 dark:fill-gray-300" 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="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352c79.5 0 144-64.5 144-144s-64.5-144-144-144S64 128.5 64 208s64.5 144 144 144z" /></svg>
|
||||
Use now
|
||||
</a>
|
||||
dark:border-gray-700 "
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 dark:fill-gray-300"
|
||||
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="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352c79.5 0 144-64.5 144-144s-64.5-144-144-144S64 128.5 64 208s64.5 144 144 144z" />
|
||||
</svg>
|
||||
Use now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:mt-0 lg:col-span-5 lg:flex">
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt="Picture of people at a party"
|
||||
width={500}
|
||||
height={500}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:mt-0 lg:col-span-5 lg:flex">
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt="Picture of people at a party"
|
||||
width={500}
|
||||
height={500}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 (<>
|
||||
<Component {...pageProps} />
|
||||
<Analytics />
|
||||
</>);
|
||||
return (
|
||||
<>
|
||||
<Component {...pageProps} />
|
||||
<Analytics />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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() {
|
|||
<>
|
||||
<Head>
|
||||
<title>Followgraph for Mastodon</title>
|
||||
<meta name="description" content="Find people to follow on Mastodon by expanding your follow graph." />
|
||||
<meta
|
||||
name="description"
|
||||
content="Find people to follow on Mastodon by expanding your follow graph."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
|
|
@ -1 +1 @@
|
|||
google-site-verification: googledfe269244d136c58.html
|
||||
google-site-verification: googledfe269244d136c58.html
|
||||
|
|
|
@ -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"}
|
||||
{
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -2,4 +2,6 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
|
|
@ -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: {},
|
||||
|
|
Ładowanie…
Reference in New Issue