Grapql api extracted to Fedistore, improved architecture, config using convict, nextjs app api, bugfixes

main
Štěpán Škorpil 2023-01-03 21:20:08 +01:00
rodzic 1cd12fc7d3
commit 82e84c09fe
161 zmienionych plików z 4900 dodań i 3509 usunięć

Wyświetl plik

@ -1,11 +1,4 @@
FROM node:18-bullseye AS prebuild
ENV ELASTIC_URL='http://elastic:9200' \
ELASTIC_USER='elastic' \
ELASTIC_PASSWORD='' \
MATOMO_URL='' \
MATOMO_SITE_ID='' \
STATS_CACHE_MINUTES=60 \
TZ='UTC'
FROM prebuild AS build
WORKDIR /srv
COPY application/package*.json ./

Wyświetl plik

@ -2,27 +2,22 @@
Search accounts and channels to follow on Fediverse
App makes queries to database of collected Fediverse feeds and nodes.
App makes queries to Fedistore app using graphql api.
Only fulltext search is currently supported. More precise filtering is planned for one of the future releases.
## Config
Configuration is done using environmental variables:
Configuration is done using environmental variables or command flags
| Variable | Description | Value example |
|-----------------------|--------------------------------------------------------------------------------------------------------------------|-------------------------------|
| `ELASTIC_URL` | Url address of ElasticSearch server | `http://elastic:9200` |
| `ELASTIC_USER` | Username for EalsticSearch server | `elastic` |
| `ELASTIC_PASSWORD` | Username for EalsticSearch server | empty |
| `MATOMO_URL` | Optional url of Matomo server for collecting usage statistics. Leaving it empty disables collecting analytics. | `https://matomo.myserver.tld` |
| `MATOMO_SITE_ID` | Optional Matomo site id parameter for collecting usage statistics. Leaving it empty disables collecting analytics. | `8` |
| `STATS_CACHE_MINUTES` | Optional number of minutes to cache heavily calculated stats data | `60` |
| Env variable | Command argument | Description | Value example | Default value |
|------------------|--------------------|----------------------------------------------------------------------------------------------------------------------|---------------------------------|----------------|
| `MATOMO_URL` | `--matomo-url` | _Optional_ url of Matomo server for collecting usage statistics. Leaving it empty disables collecting analytics. | `https://matomo.myserver.tld` | empty |
| `MATOMO_SITE_ID` | `--matomo-site-id` | _Optional_ Matomo site id parameter for collecting usage statistics. Leaving it empty disables collecting analytics. | `8` | `0` |
| `GRAPHQL_URL` | `--graphql-url` | *Required* Fedistore graphql api url | `https://fedistore.example/api` | `/api/graphql` |
## Deploy
App is designed to be run in docker container and deployed using docker-compose. More info can be found
in [FediSearch example docker-compose](https://github.com/Stopka/fedisearch-compose) project
For crawling Fediverse network and collecting feeds to database there is a companion
app [FediCrawl](https://github.com/Stopka/fedicrawl)

Wyświetl plik

@ -0,0 +1,11 @@
overwrite: true
schema:
- './src/graphql/generated/schema.graphql'
documents:
- './src/**/*.gql'
generates:
src/graphql/generated/types.ts:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typed-document-node'

Wyświetl plik

@ -1,4 +1,8 @@
module.exports = {
const config = {
experimental: {
appDir: true,
esmExternals: true
},
webpack (config) {
config.module.rules.push({
test: /\.svg$/,
@ -20,3 +24,5 @@ module.exports = {
tsconfigPath: '../tsconfig.json'
}
}
export default config

Wyświetl plik

@ -6,16 +6,17 @@
"private": true,
"author": "Štěpán Škorpil",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "next dev ./src --hostname 0.0.0.0",
"build": "next build ./src",
"start": "next start ./src",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"generate:graphql-types": "graphql-codegen-esm --config graphql-codegen.yml"
},
"dependencies": {
"@apollo/client": "^3.6.9",
"@datapunt/matomo-tracker-js": "^0.5.1",
"@elastic/elasticsearch": "^8.2.1",
"@fortawesome/fontawesome-common-types": "^6.2.0",
"@fortawesome/fontawesome-svg-core": "^6.2.0",
"@fortawesome/free-brands-svg-icons": "^6.2.0",
@ -25,27 +26,32 @@
"@hookform/resolvers": "^2.9.10",
"@popperjs/core": "^2.11.6",
"@svgr/webpack": "^6.2.1",
"apollo-server-micro": "^3.10.1",
"axios": "^0.21.1",
"bootstrap": "^5.1.3",
"convict": "~6.1.0",
"graphql": "^16.5.0",
"micro": "^9.4.1",
"micro-cors": "^0.1.1",
"next": "^12.2.5",
"nexus": "^1.3.0",
"next": "^13.0.6",
"node-cache": "^5.1.2",
"npmlog": "^6.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-smooth-collapse": "^2.1.2",
"sass": "^1.45.1",
"server-only": "^0.0.1",
"striptags": "^3.2.0",
"typescript-collections": "^1.3.3",
"yargs-parser": "^20.2.7",
"zod": "^3.11.6"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.15.0",
"@graphql-codegen/introspection": "^2.2.1",
"@graphql-codegen/typed-document-node": "^2.3.8",
"@graphql-codegen/typescript": "^2.8.3",
"@graphql-codegen/typescript-operations": "^2.5.8",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@next/eslint-plugin-next": "^13.0.0",
"@types/convict": "^6.1.1",
"@types/jest": "^29.2.0",
"@types/micro-cors": "^0.1.2",
"@types/node": "^18.7.18",
"@types/npmlog": "^4.1.3",
"@types/react": "^17.0.14",
@ -60,7 +66,7 @@
"eslint-plugin-react": "^7.31.8",
"jest": "^29.2.2",
"ts-jest": "^29.0.3",
"typescript": "^4.3.5"
"typescript": "^4.9.4"
},
"eslintConfig": {
"env": {
@ -76,6 +82,9 @@
"tsconfig.json"
]
},
"ignorePatterns": [
"src/next-env.d.ts"
],
"rules": {
"@typescript-eslint/no-misused-promises": [
"error",

Wyświetl plik

@ -0,0 +1,13 @@
import React, { ReactElement } from 'react'
import FeedSearch from '../../components/feed/FeedSearch'
import Layout from '../../components/server/Layout'
import createConfig from '../../config/createConfig'
export default async function Page (): Promise<ReactElement> {
const clientConfig = createConfig().get('client')
return (
<Layout title={'People'} description={'Search people on Fediverse'} config={clientConfig}>
<FeedSearch />
</Layout>
)
}

Wyświetl plik

@ -0,0 +1,11 @@
import { ReactElement } from 'react'
export default function Head (): ReactElement {
return (
<>
<title></title>
<meta content="width=device-width, initial-scale=1" name="viewport" />
<link rel="icon" href="/favicon.ico" />
</>
)
}

Wyświetl plik

@ -0,0 +1,14 @@
import React, { ReactElement } from 'react'
export default function RootLayout ({
children
}: {
children: React.ReactNode
}): ReactElement {
return (
<html>
<head />
<body>{children}</body>
</html>
)
}

Wyświetl plik

@ -0,0 +1,13 @@
import React, { ReactElement } from 'react'
import NodeSearch from '../../components/node/NodeSearch'
import Layout from '../../components/server/Layout'
import createConfig from '../../config/createConfig'
export default async function Page (): Promise<ReactElement> {
const clientConfig = createConfig().get('client')
return (
<Layout title={'Servers'} description={'Search Fediverse servers'} config={clientConfig}>
<NodeSearch />
</Layout>
)
}

Wyświetl plik

@ -0,0 +1,26 @@
import React, { ReactElement } from 'react'
import Accordion from '../../components/accordion/Accordion'
import AccordionItem from '../../components/accordion/AccordionItem'
import MastodonNoindexOptout from '../../components/optout/MastodonNoindexOptout'
import MastodonSuggestingOptout from '../../components/optout/MastodonSuggestingOptout'
import RobotsTxtOptout from '../../components/optout/RobotsTxtOptout'
import TagNobotOptout from '../../components/optout/TagNobotOptout'
import Layout from '../../components/server/Layout'
import createConfig from '../../config/createConfig'
export default async function Page (): Promise<ReactElement> {
const clientConfig = createConfig().get('client')
return (
<Layout title={'Opt out'} description={'What to do to opt out from the index'} config={clientConfig}>
<p>You don&apos;t want to be listed here? There are several ways to opt-out from our index:</p>
<Accordion>
<MastodonNoindexOptout/>
<MastodonSuggestingOptout/>
<TagNobotOptout/>
<RobotsTxtOptout/>
</Accordion>
<p>It can take up to <strong>3 weeks</strong> for the change to be processed and to records be deleted from
the index.</p>
</Layout>
)
}

Wyświetl plik

@ -0,0 +1,14 @@
import React, { ReactElement } from 'react'
import NodeSearch from '../../components/node/NodeSearch'
import Layout from '../../components/server/Layout'
import Stats from "../../components/stats/Stats";
import createConfig from '../../config/createConfig'
export default async function Page (): Promise<ReactElement> {
const clientConfig = createConfig().get('client')
return (
<Layout title={'Stats'} description={'Fediverse stats'} config={clientConfig}>
<Stats />
</Layout>
)
}

Wyświetl plik

@ -1,14 +0,0 @@
import React from 'react'
import FallbackImage from './FallbackImage'
const Avatar: React.FC<{ url: string | null | undefined }> = ({ url }) => {
return (
<FallbackImage
className={'avatar'}
src={url ?? undefined}
fallbackSrc={'/avatar.svg'}
alt={'Avatar'}
/>
)
}
export default Avatar

Wyświetl plik

@ -0,0 +1,18 @@
'use client'
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { ReactElement } from 'react'
export default function ErrorMessage ({ message }: { message?: string }): ReactElement {
if (message === undefined) {
return (
<></>
)
}
return (
<div className={'d-flex justify-content-center'}>
<FontAwesomeIcon icon={faExclamationTriangle} className={'margin-right'}/>
<span>{message}</span>
</div>
)
}

Wyświetl plik

@ -1,3 +1,4 @@
'use client'
import React, { ImgHTMLAttributes, ReactElement, useEffect, useState } from 'react'
export default function FallbackImage ({

Wyświetl plik

@ -1,38 +0,0 @@
import React, { useEffect } from 'react'
import Head from 'next/head'
import Footer from './Footer'
import getMatomo from '../lib/getMatomo'
import { UserOptions } from '@datapunt/matomo-tracker-js/es/types'
import NavBar from './NavBar'
export const siteTitle = 'FediSearch'
export const siteDescription = 'Search people on Fediverse'
const Layout: React.FC<{ matomoConfig: UserOptions, children: React.ReactNode }> = ({ matomoConfig, children }) => {
useEffect(() => {
getMatomo(matomoConfig).trackPageView()
}, [])
return (
<div className={'container'}>
<Head>
<title>{siteTitle}</title>
<link rel="icon" href="/fedisearch.png"/>
<meta name="description" content={siteDescription}/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta property="og:title" content={siteTitle}/>
<meta property="og:description" content={siteDescription}/>
<meta property="og:image" content="/fedisearch.png"/>
<meta property="og:type" content="website"/>
</Head>
<div className="container">
<NavBar />
<main>
{children}
</main>
<Footer/>
</div>
</div>
)
}
export default Layout

Wyświetl plik

@ -0,0 +1,29 @@
'use client'
import { faAngleDoubleDown } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { MouseEventHandler, ReactElement } from 'react'
export default function LoadMoreButton (
{ onClick, show }: {
onClick: () => void
show: boolean
}
): ReactElement {
const handleClick: MouseEventHandler = (event): void => {
event.preventDefault()
onClick()
}
if (!show) {
return <></>
}
return (
<div className={'d-flex justify-content-center'}>
<button className={'btn btn-secondary'} onClick={handleClick}>
<FontAwesomeIcon icon={faAngleDoubleDown} className={'margin-right'}/>
<span>Load more</span>
</button>
</div>
)
}

Wyświetl plik

@ -1,53 +1,26 @@
import React, { ReactNode } from 'react'
import React, { ReactElement, ReactNode } from 'react'
import Spinner from './Spinner'
const Loader: React.FC<{ children: ReactNode, loading: boolean, hideContent?: boolean, table?: number, showTop?: boolean, showBottom?: boolean }> = ({
export default function Loader ({
showTop,
showBottom,
hideContent,
children,
table,
loading
}) => {
const className = 'loader' + (loading ? ' -loading' : '')
const spinner = (
<div className={'d-flex justify-content-center'}>
<Spinner/>
</div>
loading,
placeholder
}: {
children: ReactNode
loading: boolean
hideContent?: boolean
showTop?: boolean
showBottom?: boolean
placeholder?: ReactNode
}): ReactElement {
const spinner = placeholder ?? (
<div className={'d-flex justify-content-center'}>
<Spinner/>
</div>
)
if (table !== undefined || table !== 0) {
return (
<>
{(showTop ?? false) && loading
? (
<tbody>
<tr className={className}>
<td colSpan={table}>
{spinner}
</td>
</tr>
</tbody>
)
: ''}
{(hideContent ?? false) && loading ? '' : children}
{(showBottom ?? false) && loading
? (
<tbody>
<tr className={className}>
<td colSpan={table}>
<div className={'d-flex justify-content-center'}>
<Spinner/>
</div>
</td>
</tr>
</tbody>
)
: ''}
</>
)
}
return (
<>
{(showTop ?? false) && loading ? spinner : ''}
@ -56,5 +29,3 @@ const Loader: React.FC<{ children: ReactNode, loading: boolean, hideContent?: bo
</>
)
}
export default Loader

Wyświetl plik

@ -0,0 +1,14 @@
import React, {ReactElement, ReactNode} from "react";
export default function ResponsiveTable({children, className}: {
children: ReactNode,
className?: string
}): ReactElement {
return (
<div className={'table-responsive'}>
<table className={`table table-dark table-striped table-bordered nodes ${className??''}`}>
{children}
</table>
</div>
)
}

Wyświetl plik

@ -1,7 +1,7 @@
import React from 'react'
import FallbackImage from '../FallbackImage'
import React, { ReactElement } from 'react'
import FallbackImage from './FallbackImage'
const SoftwareBadge: React.FC<{ softwareName: string | null }> = ({ softwareName }) => {
export default function SoftwareBadge ({ softwareName }: { softwareName: string | null }): ReactElement {
const fallbackImage = '/software/fediverse.svg'
return (<div className={'software-name'} title={'Software name'}>
@ -14,5 +14,3 @@ const SoftwareBadge: React.FC<{ softwareName: string | null }> = ({ softwareName
<span className={'value'}>{softwareName}</span>
</div>)
}
export default SoftwareBadge

Wyświetl plik

@ -0,0 +1,11 @@
'use client'
import {faCircle} from "@fortawesome/free-solid-svg-icons";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import React, {ReactElement} from "react";
export default function SoftwareBadgePlaceholder():ReactElement{
return <div className={'software-name placeholder-glow'}>
<FontAwesomeIcon icon={faCircle} className={'icon'}/>
<span className={'value placeholder col-6'}/>
</div>
}

Wyświetl plik

@ -1,23 +1,29 @@
import React from 'react'
import React, { MouseEventHandler, ReactElement, ReactNode } from 'react'
import { SortingWayEnum } from '../graphql/generated/types'
import { Sort } from '../types/Sort'
import { faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
const SortToggle: React.FC<{
export default function SortToggle ({ onToggle, field, sort, children }: {
onToggle: (StatsRequestSortBy) => void
field: string
sort: Sort
}> = ({ onToggle, field, sort, children }) => {
children: ReactNode
}): ReactElement {
const handleToggle: MouseEventHandler = (event) => {
event.preventDefault()
onToggle(field)
}
return (
<a className={'sort-toggle'} href={'#'} onClick={() => onToggle(field)}>
<a className={'sort-toggle'} href={''} onClick={handleToggle}>
<span>{children}</span>
{sort.sortBy === field && sort.sortWay === 'asc'
{sort.sortBy === field && sort.sortWay === SortingWayEnum.Asc
? (
<FontAwesomeIcon icon={faSortUp} className={'margin-left'} />
)
: ''
}
{sort.sortBy === field && sort.sortWay === 'desc'
{sort.sortBy === field && sort.sortWay === SortingWayEnum.Desc
? (
<FontAwesomeIcon icon={faSortDown} className={'margin-left'} />
)
@ -26,5 +32,3 @@ const SortToggle: React.FC<{
</a>
)
}
export default SortToggle

Wyświetl plik

@ -0,0 +1,32 @@
'use client'
import React, {ReactElement, ReactNode, useContext, useState} from "react";
const AccordionContext = React.createContext<{
expandedId: string | undefined,
setExpandedId: (id: string | undefined) => void
} | undefined>(undefined)
export const useAccordion = (id: string): [boolean, () => void] => {
const context = useContext(AccordionContext)
if (context === undefined) {
throw new Error('Hook useAccordion needs to be used in Accordion element')
}
const {expandedId, setExpandedId} = context;
return [
expandedId === id,
() => {
setExpandedId(expandedId === id ? undefined : id)
}
]
}
export default function Accordion({children}: {
children: ReactNode
}): ReactElement {
const [expandedId, setExpandedId] = useState<string | undefined>(undefined)
return <AccordionContext.Provider value={{expandedId, setExpandedId}}>
<div className="accordion" id="accordionExample">
{children}
</div>
</AccordionContext.Provider>
}

Wyświetl plik

@ -0,0 +1,30 @@
'use client'
import React, { ReactElement, ReactNode } from 'react'
import SmoothCollapse from 'react-smooth-collapse'
import { useAccordion } from './Accordion'
export default function AccordionItem ({ label, children, id }: {
label: string | ReactNode
children: ReactNode
id: string
}): ReactElement {
const [expanded, toggle] = useAccordion(id)
return <div className="accordion-item">
<h2 className="accordion-header" id={id}>
<button
className={`accordion-button ${expanded ? '' : 'collapsed'}`}
type="button"
aria-expanded={expanded ? 'true' : 'false'}
aria-controls={`${id}_body`}
onClick={toggle}
>
{label}
</button>
</h2>
<SmoothCollapse expanded={expanded} id={`${id}_body`} aria-labelledby={id}>
<div className="accordion-body">
{children}
</div>
</SmoothCollapse>
</div>
}

Wyświetl plik

@ -1,17 +0,0 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconProp } from '@fortawesome/fontawesome-svg-core'
const Badge: React.FC<{ faIcon: IconProp, label: string, value: string | number | null, className?: string, showUnknown?: boolean }> = ({ faIcon, label, value, className, showUnknown }) => {
if (value === null && showUnknown !== true) {
return (<></>)
}
return (
<div className={`badge bg-secondary ${className ?? ''}`} title={label}>
<FontAwesomeIcon icon={faIcon} className={'margin-right'}/>
<span className="visually-hidden">{label}:</span>
<span className={'value'}>{value === null ? '?' : value}</span>
</div>
)
}
export default Badge

Wyświetl plik

@ -1,14 +0,0 @@
import React from 'react'
import { faRobot } from '@fortawesome/free-solid-svg-icons'
import Badge from './Badge'
const BotBadge: React.FC<{ bot: boolean | null }> = ({ bot }) => {
return (
<Badge faIcon={faRobot}
label={'Bot'}
value={bot !== null ? (bot ? 'Yes' : 'No') : null}
className={'bot'}
/>
)
}
export default BotBadge

Wyświetl plik

@ -0,0 +1,14 @@
'use client'
import React, { ReactElement } from 'react'
import FallbackImage from '../FallbackImage'
export default function Avatar ({ url }: { url?: string | null | undefined }): ReactElement {
return (
<FallbackImage
className={'avatar'}
src={url ?? '/avatar.svg'}
fallbackSrc={'/avatar.svg'}
alt={'Avatar'}
/>
)
}

Wyświetl plik

@ -0,0 +1,50 @@
'use client'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { ReactElement } from 'react'
import { FeedQueryInput } from '../../graphql/generated/types'
import SearchInput from "../form/SearchInput";
import SubmitButton from "../form/SubmitButton";
export default function FeedForm (
{ onSubmit, onQueryChange, query }: {
onSubmit: () => void
onQueryChange: (query: FeedQueryInput) => void
query: FeedQueryInput
}
): ReactElement {
const handleQueryChange = (event): void => {
const inputElement = event.target
const value = inputElement.value
const name = inputElement.name
const newQuery = {
...query
}
newQuery[name] = value
onQueryChange(newQuery)
event.preventDefault()
}
const handleSubmit = (event): void => {
event.preventDefault()
onSubmit()
}
return (
<form onSubmit={handleSubmit}>
<div className="input-group mb-3">
<SearchInput
label={'Search people on Fediverse'}
onChange={handleQueryChange}
value={query.search ?? ''}
describedBy="search-feeds-button"
/>
<SubmitButton
faIcon={faSearch}
label={'Search'}
id={"search-feeds-button"}
/>
</div>
</form>
)
}

Wyświetl plik

@ -0,0 +1,8 @@
import {ReactElement, ReactNode} from "react";
export default function FeedInfo({children, show}: { children?: ReactNode, show?: boolean }): ReactElement {
if (show === false) {
return <>{children}</>
}
return <></>
}

Wyświetl plik

@ -0,0 +1,91 @@
import { faCircle } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { ReactElement } from 'react'
import SoftwareBadge from "../SoftwareBadge";
import SoftwareBadgePlaceholder from "../SoftwareBadgePlaceholder";
import Avatar from './Avatar'
import Badge from './badges/Badge'
export default function FeedPlaceholder (): ReactElement {
const greyDotBlob = 'data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='
return (
<section className="card feed g-col-12 mb-3" aria-hidden="true">
<div className="card-body">
<h3 className={'card-title with-emoji display-name placeholder-glow'}>
<a><span className="placeholder col-4"></span></a>
</h3>
<Avatar url={greyDotBlob} />
<div className={'address placeholder-glow'}>
<span className="placeholder col-6"></span>
</div>
<SoftwareBadgePlaceholder />
<div className={'badges placeholder-glow'}>
<Badge faIcon={faCircle}
label={''}
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
/>
<Badge faIcon={faCircle}
label={''}
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
/>
<Badge faIcon={faCircle}
label={''}
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
/>
<Badge faIcon={faCircle}
label={''}
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
/>
<Badge faIcon={faCircle}
label={''}
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
/>
<Badge faIcon={faCircle}
label={''}
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
/>
<Badge faIcon={faCircle}
label={''}
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
/>
</div>
<div className={'table-responsive fields'}>
<table className={'table'}>
<tbody>
<tr>
<th className={'with-emoji table-active placeholder-glow'} style={{ width: '30%' }}>
<span className={'placeholder col-8'}/>
</th>
<td className={'with-emoji placeholder-glow'}>
<span className={'placeholder col-10'}/>
</td>
</tr>
<tr>
<th className={'with-emoji table-active placeholder-glow'} style={{ width: '30%' }}>
<span className={'placeholder col-8'}/>
</th>
<td className={'with-emoji placeholder-glow'}>
<span className={'placeholder col-10'}/>
</td>
</tr>
</tbody>
</table>
</div>
<div className={'description placeholder-glow'}>
<p>
<span className={'placeholder col-4'}/>
<span className={'placeholder col-4'}/>
<span className={'placeholder col-2'}/>
<span className={'placeholder col-3'}/>
</p>
<p>
<span className={'placeholder col-4'}/>
<span className={'placeholder col-4'}/>
<span className={'placeholder col-2'}/>
<span className={'placeholder col-3'}/>
</p>
</div>
</div>
</section>
)
}

Wyświetl plik

@ -1,7 +1,8 @@
import React, { ReactElement, useEffect } from 'react'
import striptags from 'striptags'
import { ListFeedsItemFragment } from '../../graphql/generated/types'
import Avatar from './Avatar'
import SoftwareBadge from './badges/SoftwareBadge'
import SoftwareBadge from '../SoftwareBadge'
import FeedTypeBadge from './badges/FeedTypeBadge'
import CreatedAtBadge from './badges/CreatedAtBadge'
import LastPostAtBadge from './badges/LastPostAtBadge'
@ -10,11 +11,8 @@ import ParentFeed from './ParentFeed'
import StatusesCountBadge from './badges/StatusesCountBadge'
import FollowersBadge from './badges/FollowersBadge'
import FollowingBadge from './badges/FollowingBadge'
import { FeedResultItem } from '../graphql/client/queries/ListFeedsQuery'
const FeedResult = ({
feed
}: { feed: FeedResultItem }): ReactElement => {
export default function FeedResult ({ feed }: { feed: ListFeedsItemFragment }): ReactElement {
const fallbackEmojiImage = '/emoji.svg'
const handleEmojiImageError = (event): void => {
@ -44,7 +42,7 @@ const FeedResult = ({
<span>{feed.id}</span>
<ParentFeed feed={feed.parent}/>
</div>
<SoftwareBadge softwareName={feed.node.softwareName}/>
<SoftwareBadge softwareName={feed.node.softwareName ?? ''}/>
<div className={'badges'}>
<FeedTypeBadge type={feed.type}/>
<FollowersBadge followers={feed.followersCount}/>
@ -79,7 +77,6 @@ const FeedResult = ({
<div className={'description with-emoji'}
dangerouslySetInnerHTML={{ __html: striptags(feed.description, ['img', 'p', 'strong', 'em', 'br', 'a']) }}/>
</div>
</section>)
</section>
)
}
export default FeedResult

Wyświetl plik

@ -1,10 +1,11 @@
import React, { ReactElement } from 'react'
import { ListFeedsItemFragment } from '../../graphql/generated/types'
import FeedResult from './FeedResult'
import { FeedResultItem } from '../graphql/client/queries/ListFeedsQuery'
const FeedResults = ({
feeds
}: { feeds: FeedResultItem[] }): ReactElement => {
export default function FeedResults ({ feeds }: { feeds: ListFeedsItemFragment[] | undefined }): ReactElement {
if (feeds === undefined) {
return <></>
}
if (feeds.length === 0) {
return (
<>
@ -21,5 +22,3 @@ const FeedResults = ({
}
</div>)
}
export default FeedResults

Wyświetl plik

@ -0,0 +1,110 @@
'use client'
import { useQuery } from '@apollo/client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import React, { ReactElement, useEffect, useState } from 'react'
import { z } from 'zod'
import { FeedQueryInput, ListFeedsDocument } from '../../graphql/generated/types'
import { useMatomo } from '../../hooks/MatomoHook'
import createUrlSearchParams from '../../utils/createUrlSearchParams'
import { stringTrimmed, transform } from '../../utils/transform'
import FeedInfo from './FeedInfo'
import FeedResults from './FeedResults'
import Loader from '../Loader'
import ErrorMessage from '../ErrorMessage'
import LoadMoreButton from '../LoadMoreButton'
import FeedForm from './FeedForm'
import FeedPlaceholder from './FeedPlaceholder'
export const feedQueryInputSchema = z.object({
search: transform(
z.string().optional(),
stringTrimmed,
z.string()
)
})
export default function FeedSearch (): ReactElement {
const matomo = useMatomo()
const searchParams = useSearchParams()
const pathname = usePathname()
const router = useRouter()
const routerQuery = feedQueryInputSchema.parse(Object.fromEntries(searchParams))
const [page, setPage] = useState<number>(0)
const [query, setQuery] = useState<FeedQueryInput>(routerQuery)
const [pageLoading, setPageLoading] = useState<boolean>(false)
const { loading, data, error, fetchMore, refetch } = useQuery(ListFeedsDocument, {
variables: {
paging: { page: 0 },
query
}
})
useEffect((): void => {
router.push(`${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
matomo.trackEvent({
category: 'feeds',
action: 'new-search'
})
}, [query])
useEffect(() => {
matomo.trackEvent({
category: 'feeds',
action: 'next-page',
customDimensions: [
{
value: page.toString(),
id: 1
}
]
})
}, [page])
const handleQueryChange = (query: FeedQueryInput): void => {
setQuery(query)
setPage(0)
}
const handleSearchSubmit = async (): Promise<void> => {
setPageLoading(true)
setPage(0)
await refetch({ paging: { page: 0 } })
setPageLoading(false)
}
const handleLoadMore = async (): Promise<void> => {
setPageLoading(true)
await fetchMore({
variables: {
paging: { page: page + 1 }
},
updateQuery: (previousData, { fetchMoreResult }) => {
if (undefined === fetchMoreResult?.listFeeds?.items) {
return previousData
}
if (undefined === previousData?.listFeeds?.items) {
return fetchMoreResult
}
fetchMoreResult.listFeeds.items = [
...previousData.listFeeds.items,
...fetchMoreResult.listFeeds.items
]
return fetchMoreResult
}
})
setPageLoading(false)
setPage(page + 1)
}
return <>
<FeedForm query={query} onQueryChange={handleQueryChange} onSubmit={handleSearchSubmit}/>
<FeedInfo show={query.search === ''}>
<Loader loading={loading || pageLoading} showBottom={true} placeholder={(<FeedPlaceholder/>)}>
<FeedResults feeds={data?.listFeeds?.items}/>
</Loader>
<LoadMoreButton
show={!loading && !pageLoading && data?.listFeeds?.paging?.hasNext === true}
onClick={handleLoadMore}
/>
<ErrorMessage message={error?.message}/>
</FeedInfo>
</>
}

Wyświetl plik

@ -1,9 +1,9 @@
import React from 'react'
import React, { ReactElement } from 'react'
import { Maybe, ParentFeedFragment } from '../../graphql/generated/types'
import Avatar from './Avatar'
import { ParentFeedItem } from '../graphql/client/queries/ListFeedsQuery'
const ParentFeed: React.FC<{ feed: ParentFeedItem | null }> = ({ feed }) => {
if (feed == null) {
export default function ParentFeed ({ feed }: { feed: Maybe<ParentFeedFragment> | undefined }): ReactElement {
if (feed === null || feed === undefined) {
return (<></>)
}
return (
@ -16,5 +16,3 @@ const ParentFeed: React.FC<{ feed: ParentFeedItem | null }> = ({ feed }) => {
</div>
)
}
export default ParentFeed

Wyświetl plik

@ -0,0 +1,22 @@
import React, { ReactElement } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconProp } from '@fortawesome/fontawesome-svg-core'
export default function Badge ({ faIcon, label, value, className, showUnknown }: {
faIcon: IconProp
label: string
value: string | number | null | undefined | ReactElement
className?: string
showUnknown?: boolean
}): ReactElement {
if ((value === null || value === undefined) && showUnknown !== true) {
return (<></>)
}
return (
<div className={`badge bg-secondary ${className ?? ''}`} title={label}>
<FontAwesomeIcon icon={faIcon} className={'margin-right'}/>
<span className="visually-hidden">{label}:</span>
<span className={'value'}>{value === null || value === undefined ? '?' : value}</span>
</div>
)
}

Wyświetl plik

@ -0,0 +1,13 @@
import React, { ReactElement } from 'react'
import { faRobot } from '@fortawesome/free-solid-svg-icons'
import Badge from './Badge'
export default function BotBadge ({ bot }: { bot: boolean | null | undefined }): ReactElement {
return (
<Badge faIcon={faRobot}
label={'Bot'}
value={bot !== null && bot !== undefined ? (bot ? 'Yes' : 'No') : null}
className={'bot'}
/>
)
}

Wyświetl plik

@ -1,8 +1,8 @@
import React from 'react'
import React, { ReactElement } from 'react'
import { faCalendarPlus } from '@fortawesome/free-solid-svg-icons'
import Badge from './Badge'
const CreatedAtBadge: React.FC<{ createdAt: string | null }> = ({ createdAt }) => {
export default function CreatedAtBadge ({ createdAt }: { createdAt: string | null }): ReactElement {
return (
<Badge faIcon={faCalendarPlus}
label={'Created at'}
@ -11,4 +11,3 @@ const CreatedAtBadge: React.FC<{ createdAt: string | null }> = ({ createdAt }) =
/>
)
}
export default CreatedAtBadge

Wyświetl plik

@ -1,8 +1,8 @@
import React from 'react'
import React, { ReactElement } from 'react'
import { faRss, faUser } from '@fortawesome/free-solid-svg-icons'
import Badge from './Badge'
const FeedTypeBadge: React.FC<{ type: 'account' | 'channel' }> = ({ type }) => {
export default function FeedTypeBadge ({ type }: { type: 'account' | 'channel' }): ReactElement {
return (
<Badge faIcon={type === 'channel' ? faRss : faUser}
label={'Feed type'}
@ -11,5 +11,3 @@ const FeedTypeBadge: React.FC<{ type: 'account' | 'channel' }> = ({ type }) => {
/>
)
}
export default FeedTypeBadge

Wyświetl plik

@ -1,8 +1,8 @@
import { faUserFriends } from '@fortawesome/free-solid-svg-icons'
import React from 'react'
import React, { ReactElement } from 'react'
import Badge from './Badge'
const FollowersBadge: React.FC<{ followers: number | null }> = ({ followers }) => {
export default function FollowersBadge ({ followers }: { followers: number | null | undefined }): ReactElement {
return (
<Badge faIcon={faUserFriends}
label={'Followers'}
@ -11,4 +11,3 @@ const FollowersBadge: React.FC<{ followers: number | null }> = ({ followers }) =
/>
)
}
export default FollowersBadge

Wyświetl plik

@ -1,8 +1,8 @@
import { faEye } from '@fortawesome/free-solid-svg-icons'
import React from 'react'
import React, { ReactElement } from 'react'
import Badge from './Badge'
const FollowingBadge: React.FC<{ following: number | null }> = ({ following }) => {
export default function FollowingBadge ({ following }: { following: number | null | undefined }): ReactElement {
return (
<Badge faIcon={faEye}
label={'Following'}
@ -11,4 +11,3 @@ const FollowingBadge: React.FC<{ following: number | null }> = ({ following }) =
/>
)
}
export default FollowingBadge

Wyświetl plik

@ -1,8 +1,8 @@
import React from 'react'
import React, { ReactElement } from 'react'
import Badge from './Badge'
import { faCalendarCheck } from '@fortawesome/free-solid-svg-icons'
const LastPostAtBadge: React.FC<{ lastStatusAt: string | null }> = ({ lastStatusAt }) => {
export default function LastPostAtBadge ({ lastStatusAt }: { lastStatusAt: string | null }): ReactElement {
return (
<Badge faIcon={faCalendarCheck}
label={'Last status at'}
@ -11,5 +11,3 @@ const LastPostAtBadge: React.FC<{ lastStatusAt: string | null }> = ({ lastStatus
/>
)
}
export default LastPostAtBadge

Wyświetl plik

@ -1,8 +1,8 @@
import React from 'react'
import React, { ReactElement } from 'react'
import { faCommentAlt } from '@fortawesome/free-solid-svg-icons'
import Badge from './Badge'
const StatusesCountBadge: React.FC<{ statusesCount: number | null }> = ({ statusesCount }) => {
export default function StatusesCountBadge ({ statusesCount }: { statusesCount: number | null | undefined }): ReactElement {
return (
<Badge faIcon={faCommentAlt}
label={'Status count'}
@ -11,5 +11,3 @@ const StatusesCountBadge: React.FC<{ statusesCount: number | null }> = ({ status
/>
)
}
export default StatusesCountBadge

Wyświetl plik

@ -0,0 +1,21 @@
import React, {ChangeEventHandler, ReactElement} from "react";
export default function SearchInput({label, onChange, value, describedBy}: {
label: string,
onChange?: ChangeEventHandler,
value?: string,
describedBy?: string
}): ReactElement {
return <input
name={'search'}
id={'search'}
type={'search'}
className={'form-control'}
onChange={onChange}
value={value}
placeholder={label}
autoFocus={true}
aria-label={label}
aria-describedby={describedBy}
/>
}

Wyświetl plik

@ -0,0 +1,14 @@
import {IconProp} from "@fortawesome/fontawesome-svg-core";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import React, {ReactElement} from "react";
export default function SubmitButton({faIcon, label, id}: {
faIcon: IconProp,
label: string,
id?: string
}): ReactElement {
return <button type={'submit'} className={'btn btn-primary'} id={id}>
<FontAwesomeIcon icon={faIcon}/>
<span>{label}</span>
</button>
}

Wyświetl plik

@ -0,0 +1,43 @@
'use client'
import React, { ReactElement, ReactNode, useEffect } from 'react'
import Head from 'next/head'
import { useMatomo } from '../../hooks/MatomoHook'
import Footer from './Footer'
import NavBar from './NavBar'
export default function ClientLayout ({
children,
title,
description
}: {
children?: ReactNode
title: string
description: string
}): ReactElement {
const matomo = useMatomo()
useEffect(() => {
matomo.trackPageView()
}, [])
return (
<div className={'container'}>
<Head>
<title>{title}</title>
<link rel="icon" href="/fedisearch.png"/>
<meta name="description" content={description}/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta property="og:title" content={title}/>
<meta property="og:description" content={description}/>
<meta property="og:image" content="/fedisearch.png"/>
<meta property="og:type" content="website"/>
</Head>
<div className="container">
<NavBar />
<main>
<h1>{title}</h1>
{children}
</main>
<Footer/>
</div>
</div>
)
}

Wyświetl plik

@ -0,0 +1,23 @@
'use client'
import { ApolloProvider } from '@apollo/client'
import React, { ReactElement, ReactNode } from 'react'
import ClientConfig from '../../config/ClientConfig'
import createGraphqlClient from '../../graphql/client/createGraphqlClient'
import { MatomoProvider } from '../../hooks/MatomoHook'
import createMatomo from '../../matomo/createMatomo'
export default function ClientProviders ({
children, config
}: {
children: ReactNode
config: ClientConfig
}): ReactElement {
return (
<ApolloProvider client={createGraphqlClient(config.graphql)}>
<MatomoProvider matomo={createMatomo(config.matomo)}>
{children}
</MatomoProvider>
</ApolloProvider>
)
}

Wyświetl plik

@ -1,6 +1,6 @@
import React from 'react'
import React, { ReactElement } from 'react'
const Footer: React.FC = () => {
export default function Footer (): ReactElement {
return (
<footer className={'text-center mt-5'}>
<p><a href={'/optout'}>How to opt-out</a></p>
@ -8,5 +8,3 @@ const Footer: React.FC = () => {
</footer>
)
}
export default Footer

Wyświetl plik

@ -1,9 +1,10 @@
import React, { useState } from 'react'
'use client'
import React, { ReactElement, useState } from 'react'
import NavItem from './NavItem'
import { faUser, faServer, faChartPie } from '@fortawesome/free-solid-svg-icons'
import FallbackImage from './FallbackImage'
import FallbackImage from '../FallbackImage'
const NavBar: React.FC = () => {
export default function NavBar (): ReactElement {
const [showMenu, setShowMenu] = useState<boolean>(false)
return (
<nav className="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
@ -31,5 +32,3 @@ const NavBar: React.FC = () => {
</nav>
)
}
export default NavBar

Wyświetl plik

@ -1,19 +1,21 @@
import { usePathname } from 'next/navigation'
import React, { FC } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { IconProp } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
const NavItem: FC<{ path: string, label: string, icon: IconProp }> = ({ path, label, icon }) => {
const router = useRouter()
const active = router.pathname === path
const currentPath = usePathname()
const active = currentPath === path
return (
<li className={'nav-item'}>
<Link href={path}>
<a className={'nav-link' + (active ? ' active' : '')} aria-current={active ? 'page' : undefined}>
<Link
href={path}
className={'nav-link' + (active ? ' active' : '')}
aria-current={active ? 'page' : undefined}
>
<FontAwesomeIcon icon={icon} className={'margin-right'} />
<span>{label}</span>
</a>
</Link>
</li>
)

Wyświetl plik

@ -0,0 +1,49 @@
'use client'
import {faSearch} from '@fortawesome/free-solid-svg-icons'
import React, {ReactElement} from 'react'
import {NodeQueryInput} from '../../graphql/generated/types'
import SearchInput from "../form/SearchInput";
import SubmitButton from "../form/SubmitButton";
export default function NodeForm(
{onSubmit, onQueryChange, query}: {
onSubmit: () => void
onQueryChange: (query: NodeQueryInput) => void
query: NodeQueryInput
}
): ReactElement {
const handleQueryChange = (event): void => {
const inputElement = event.target
const value = inputElement.value
const name = inputElement.name
const newQuery = {
...query
}
newQuery[name] = value
onQueryChange(newQuery)
event.preventDefault()
}
const handleSubmit = (event): void => {
event.preventDefault()
onSubmit()
}
return (
<form onSubmit={handleSubmit}>
<div className={'input-group mb-3'}>
<SearchInput
label={'Search Fediverse servers'}
value={query.search}
onChange={handleQueryChange}
describedBy={'search-nodes-button'}
/>
<SubmitButton
label={'Search'}
faIcon={faSearch}
id={'search-nodes-button'}
/>
</div>
</form>
)
}

Wyświetl plik

@ -0,0 +1,63 @@
import React, {ReactElement} from "react";
import {NodeQueryInput, NodeSortingByEnum} from "../../graphql/generated/types";
import SortToggle from "../SortToggle";
export default function NodeHeader({query,onSortToggle}:{
query: NodeQueryInput
onSortToggle: (sortBy: NodeSortingByEnum)=> void
}):ReactElement{
return (
<thead>
<tr>
<th rowSpan={2}>
<SortToggle onToggle={onSortToggle} field={'domain'} sort={query}>
Domain
</SortToggle>
</th>
<th rowSpan={2}>
<SortToggle onToggle={onSortToggle} field={'softwareName'} sort={query}>
Software
</SortToggle>
</th>
<th colSpan={4}>User count</th>
<th rowSpan={2} className={'number-cell'}>
<SortToggle onToggle={onSortToggle} field={'statusesCount'} sort={query}>
Statuses
</SortToggle>
</th>
<th rowSpan={2}>
<SortToggle onToggle={onSortToggle} field={'openRegistrations'} sort={query}>
Registrations
</SortToggle>
</th>
<th rowSpan={2}>
<SortToggle onToggle={onSortToggle} field={'refreshedAt'} sort={query}>
Last refreshed
</SortToggle>
</th>
</tr>
<tr>
<th className={'text-end'}>
<SortToggle onToggle={onSortToggle} field={'totalUserCount'} sort={query}>
Total
</SortToggle>
</th>
<th className={'text-end'}>
<SortToggle onToggle={onSortToggle} field={'accountFeedCount'} sort={query}>
Indexed
</SortToggle>
</th>
<th className={'text-end'}>
<SortToggle onToggle={onSortToggle} field={'monthActiveUserCount'} sort={query}>
Month active
</SortToggle>
</th>
<th className={'text-end'}>
<SortToggle onToggle={onSortToggle} field={'halfYearActiveUserCount'} sort={query}>
Half year active
</SortToggle>
</th>
</tr>
</thead>
)
}

Wyświetl plik

@ -0,0 +1,23 @@
import React, { ReactElement } from 'react'
import SoftwareBadgePlaceholder from '../SoftwareBadgePlaceholder'
export default function NodePlaceholder (): ReactElement {
return (
<tbody>
<tr>
<td className={'placeholder-glow'}><span className={'placeholder col-10'}/></td>
<td>
<div><SoftwareBadgePlaceholder /></div>
<div className={'placeholder-glow'}><span className={'placeholder col-5'}/></div>
</td>
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
<td className={' placeholder-glow'}><span className={'placeholder col-6'}/></td>
<td className={' placeholder-glow'}><span className={'placeholder col-6'}/></td>
</tr>
</tbody>
)
}

Wyświetl plik

@ -0,0 +1,28 @@
import React, { ReactElement } from 'react'
import { ListNodesItemFragment } from '../../graphql/generated/types'
import SoftwareBadge from '../SoftwareBadge'
export default function NodeResult ({ node }: { node: ListNodesItemFragment }): ReactElement {
return (
<tr>
<td>{node.domain}</td>
<td>
<div title={'Name'}>
<SoftwareBadge softwareName={node.softwareName ?? null}/>
</div>
<div title={`Version: ${node.softwareVersion ?? '?'}`}>{node.standardizedSoftwareVersion ?? ''}</div>
</td>
<td className={'text-end'}>{node.totalUserCount ?? '?'}</td>
<td className={'text-end'}>{node.accountFeedCount ?? '0'}</td>
<td className={'text-end'}>{node.monthActiveUserCount ?? '?'}</td>
<td className={'text-end'}>{node.halfYearActiveUserCount ?? '?'}</td>
<td className={'text-end'}>{node.statusesCount ?? '?'}</td>
<td>{
node.openRegistrations === null || node.openRegistrations === undefined
? '?'
: (node.openRegistrations ? 'Opened' : 'Closed')
}</td>
<td>{node.refreshedAt !== '' ? (new Date(node.refreshedAt)).toLocaleDateString() : 'Never'}</td>
</tr>
)
}

Wyświetl plik

@ -0,0 +1,27 @@
import React, {ReactElement} from "react";
import {ListNodesItemFragment} from "../../graphql/generated/types";
import NodeResult from "./NodeResult";
export default function NodeResults({nodes}:{
nodes:ListNodesItemFragment[]|undefined,
}):ReactElement{
if(nodes === undefined){
return <></>
}
return (
<tbody>
{(nodes.length > 0)
? nodes.map((node, index) => {
return (
<NodeResult node={node} key={index}/>
)
})
: (
<tr>
<td colSpan={9}>No servers found</td>
</tr>
)}
</tbody>
)
}

Wyświetl plik

@ -0,0 +1,160 @@
'use client'
import { useQuery } from '@apollo/client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import React, { ReactElement, useEffect, useState } from 'react'
import { z } from 'zod'
import {
FeedQueryInput,
ListNodesDocument,
NodeQueryInput,
NodeSortingByEnum,
SortingWayEnum
} from '../../graphql/generated/types'
import { useMatomo } from '../../hooks/MatomoHook'
import createUrlSearchParams from '../../utils/createUrlSearchParams'
import { stringTrimmed, transform } from '../../utils/transform'
import createSortingInputSchema from '../../schema/createSortingInputSchema'
import ErrorMessage from '../ErrorMessage'
import Loader from '../Loader'
import LoadMoreButton from '../LoadMoreButton'
import ResponsiveTable from '../ResponsiveTable'
import NodeForm from './NodeForm'
import NodeHeader from './NodeHeader'
import NodePlaceholder from './NodePlaceholder'
import NodeResults from './NodeResults'
export const nodeQueryInputSchema = createSortingInputSchema(NodeSortingByEnum)
.extend(
{
search: transform(
z.string().optional(),
stringTrimmed,
z.string()
)
}
)
export default function NodeSearch (): ReactElement {
const matomo = useMatomo()
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
let routerQuery: NodeQueryInput
try {
routerQuery = nodeQueryInputSchema.parse(Object.fromEntries(searchParams))
} catch (e) {
routerQuery = {
search: '',
sortBy: NodeSortingByEnum.RefreshedAt,
sortWay: SortingWayEnum.Desc
}
}
console.log('Router query', routerQuery)
const [query, setQuery] = useState<NodeQueryInput>(routerQuery)
const [page, setPage] = useState<number>(0)
const [pageLoading, setPageLoading] = useState<boolean>(false)
const { loading, error, data, fetchMore, refetch } = useQuery(ListNodesDocument, {
variables: {
query,
paging: {
page: 0
}
}
})
useEffect(() => {
matomo.trackEvent({
category: 'nodes',
action: 'next-page',
customDimensions: [
{
value: page.toString(),
id: 1
}
]
})
}, [page])
useEffect((): void => {
router.push(`${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
matomo.trackEvent({
category: 'nodes',
action: 'new-search'
})
}, [query])
const handleQueryChange = (query: FeedQueryInput): void => {
console.info('Query changed', { query })
setQuery(query)
setPage(0)
}
const handleSearchSubmit = async (): Promise<void> => {
setPageLoading(true)
setQuery(query)
setPage(0)
await refetch({ paging: { page: 0 } })
setPageLoading(false)
}
const handleLoadMore = async (): Promise<void> => {
setPage(page + 1)
console.info('Loading next page', { query, page })
setPageLoading(true)
await fetchMore({
variables: {
paging: { page: page + 1 }
},
updateQuery: (previousData, { fetchMoreResult }) => {
console.log('more', {
previousData, fetchMoreResult
})
if (undefined === fetchMoreResult?.listNodes?.items) {
return previousData
}
if (undefined === previousData?.listNodes?.items) {
return fetchMoreResult
}
fetchMoreResult.listNodes.items = [
...previousData.listNodes.items,
...fetchMoreResult.listNodes.items
]
return fetchMoreResult
}
})
setPageLoading(false)
}
const toggleSort = (sortBy: NodeSortingByEnum): void => {
const sortWay = query.sortBy === sortBy && query.sortWay === SortingWayEnum.Asc ? SortingWayEnum.Desc : SortingWayEnum.Asc
matomo.trackEvent({
category: 'nodes',
action: 'sort',
customDimensions: [
{
value: `${sortBy} ${sortWay}`,
id: 2
}
]
})
setQuery({
...query,
sortBy,
sortWay
})
}
return (
<>
<NodeForm query={query} onQueryChange={handleQueryChange} onSubmit={handleSearchSubmit}/>
<ResponsiveTable>
<NodeHeader onSortToggle={toggleSort} query={query}/>
<Loader loading={loading || pageLoading} showBottom={true} placeholder={(<NodePlaceholder/>)}>
<NodeResults nodes={data?.listNodes?.items}/>
</Loader>
</ResponsiveTable>
<LoadMoreButton onClick={handleLoadMore}
show={!loading && !pageLoading && data?.listNodes?.paging?.hasNext === true}/>
<ErrorMessage message={error?.message}/>
</>
)
}

Wyświetl plik

@ -0,0 +1,16 @@
import React, { ReactElement } from 'react'
import AccordionItem from '../accordion/AccordionItem'
export default function MastodonNoindexOptout (): ReactElement {
return <AccordionItem
label={(<>Mastodon no index option</>)}
id={'mastodon-noindex'}
>
<p className={'lead'}>On Mastodon you can set noindex option in your profile.</p>
<ol>
<li>Head to <code>Preferences</code> <code>Other</code></li>
<li>Check the option labeled as <code>Opt-out of search engine indexing</code></li>
<li>Confirm the change by clicking on the button labeled as <code>Save changes</code></li>
</ol>
</AccordionItem>
}

Wyświetl plik

@ -0,0 +1,16 @@
import React, { ReactElement } from 'react'
import AccordionItem from '../accordion/AccordionItem'
export default function MastodonSuggestingOptout (): ReactElement {
return <AccordionItem
label={<>Mastodon profile suggesting</>}
id={'mastodon-suggesting'}
>
<p className={'lead'}>On Mastodon you can remove yourself from data offered by your instance&apos;s API.</p>
<ol>
<li>Head to <code>Preferences</code> <code>Profile</code> <code>Appereance</code></li>
<li>Uncheck the option labeled as <code>Suggest account to others</code></li>
<li>Confirm the change by clicking on the button labeled as <code>Save changes</code></li>
</ol>
</AccordionItem>
}

Wyświetl plik

@ -0,0 +1,24 @@
import React, { ReactElement } from 'react'
import AccordionItem from '../accordion/AccordionItem'
export default function RobotsTxtOptout (): ReactElement {
return <AccordionItem
label={<>Server robots.txt</>}
id={'robots-txt'}
>
<p className={'lead'}>If you are a server maintainer, you can disable crawling of your instance using
<strong>robots.txt</strong>.</p>
<p>This method will remove all users on your instance from our index.
Your users can't bypass your decision.</p>
<ol>
<li>Create a text file with following content:
<pre><code>
User-agent: FediCrawl/1.0<br/>
Disallow: /
</code></pre></li>
<li>Expose the file on your instance's domain, on path:<br/>
<code>https://&lt;your instace&#39;s domain&gt;/robots.txt</code>
</li>
</ol>
</AccordionItem>
}

Wyświetl plik

@ -0,0 +1,17 @@
import React, { ReactElement } from 'react'
import AccordionItem from '../accordion/AccordionItem'
export default function TagNobotOptout (): ReactElement {
return <AccordionItem
label={<>#nobot in profile description</>}
id={'tag-nobot'}
>
<p className={'lead'}>On any platform you can add <strong>#nobot</strong> tag to your profile description.</p>
<p>Depending on your platform:</p>
<ol>
<li>Open profile editing</li>
<li>Enter the word <code>#nobot</code> to de description field (including the hash symbol).</li>
<li>Save changes</li>
</ol>
</AccordionItem>
}

Wyświetl plik

@ -0,0 +1,27 @@
import React, { ReactElement, ReactNode } from 'react'
import ClientConfig from '../../config/ClientConfig'
import 'server-only'
import '../../styles/global.scss'
import ClientLayout from '../layout/ClientLayout'
import ClientProviders from '../layout/ClientProviders'
export default function Layout ({
children,
config,
title,
description
}: {
children?: ReactNode
config: ClientConfig
title: string
description: string
}): ReactElement {
console.log('Layout')
return (
<ClientProviders config={config}>
<ClientLayout title={title} description={description}>
{children}
</ClientLayout>
</ClientProviders>
)
}

Wyświetl plik

@ -0,0 +1,100 @@
'use client'
import { useQuery } from '@apollo/client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { ReactElement, useEffect, useState } from 'react'
import {
ListStatsDocument,
SortingWayEnum, StatsAggregationFragment,
StatsQueryInput,
StatsSortingByEnum
} from '../../graphql/generated/types'
import { useMatomo } from '../../hooks/MatomoHook'
import createSortingInputSchema from '../../schema/createSortingInputSchema'
import createUrlSearchParams from '../../utils/createUrlSearchParams'
import ErrorMessage from '../ErrorMessage'
import Loader from '../Loader'
import ResponsiveTable from '../ResponsiveTable'
import StatsFooter from './StatsFooter'
import StatsHeader from './StatsHeader'
import StatsPlaceholder from './StatsPlaceholder'
import StatsResults from './StatsResults'
const statsQueryInputSchema = createSortingInputSchema(StatsSortingByEnum)
export default function Stats (): ReactElement {
const [lastRowCount, setLastRowCount] = useState<number>(1)
const [lastSum, setLastSum] = useState<StatsAggregationFragment>({
nodeCount: 0,
accountFeedCount: 0,
channelFeedCount: 0
})
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const matomo = useMatomo()
let routerQuery: StatsQueryInput
try {
routerQuery = statsQueryInputSchema.parse(Object.fromEntries(searchParams))
} catch (e) {
console.warn(e)
routerQuery = {
sortBy: StatsSortingByEnum.NodeCount,
sortWay: SortingWayEnum.Desc
}
}
console.log('Router query', routerQuery)
const [query, setQuery] = useState<StatsQueryInput>(routerQuery)
const { loading, error, data } = useQuery(ListStatsDocument, {
variables: {
query
}
})
useEffect(() => {
const items = data?.listStats?.items
const sum = data?.listStats?.aggregations.sum
if (items === undefined || sum === undefined) {
return
}
setLastRowCount(items.length)
setLastSum(sum)
}, [data])
useEffect(() => {
router.push(`${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
matomo.trackEvent({
category: 'stats',
action: 'new-search'
})
}, [query])
const toggleSort = (sortBy: StatsSortingByEnum): void => {
const sortWay = query.sortBy === sortBy && query.sortWay === SortingWayEnum.Asc ? SortingWayEnum.Desc : SortingWayEnum.Asc
matomo.trackEvent({
category: 'stats',
action: 'sort',
customDimensions: [
{
value: `${sortBy} ${sortWay}`,
id: 2
}
]
})
setQuery({
...query,
sortBy,
sortWay
})
}
return (<>
<ResponsiveTable className={'stats'}>
<StatsHeader query={query} onSortToggle={toggleSort}/>
<Loader loading={loading} showTop={true} hideContent={true}
placeholder={(<StatsPlaceholder rowCount={lastRowCount}/>)}>
<StatsResults items={data?.listStats?.items} maxAggregation={data?.listStats?.aggregations.max}/>
</Loader>
<StatsFooter sumAggregation={lastSum}/>
</ResponsiveTable>
<ErrorMessage message={error?.message}/>
</>
)
}

Wyświetl plik

@ -0,0 +1,15 @@
import {ReactElement} from "react";
import {StatsAggregationFragment} from "../../graphql/generated/types";
export default function StatsFooter({sumAggregation}: { sumAggregation: StatsAggregationFragment | undefined }): ReactElement {
return (
<tfoot>
<tr>
<th>Summary</th>
<th className={'text-end'}>{sumAggregation?.nodeCount??0}</th>
<th className={'text-end'}>{sumAggregation?.accountFeedCount??0}</th>
<th className={'text-end'}>{sumAggregation?.channelFeedCount??0}</th>
</tr>
</tfoot>
)
}

Wyświetl plik

@ -0,0 +1,34 @@
'use client'
import {ReactElement} from "react";
import {NodeSortingByEnum, StatsQueryInput, StatsSortingByEnum} from "../../graphql/generated/types";
import SortToggle from "../SortToggle";
export default function StatsHeader({query,onSortToggle}: {
query: StatsQueryInput,
onSortToggle: (sortBy: StatsSortingByEnum) => void
}): ReactElement {
return <thead>
<tr>
<th>
<SortToggle onToggle={onSortToggle} field={'softwareName'} sort={query}>
Software name
</SortToggle>
</th>
<th className={'text-end'}>
<SortToggle onToggle={onSortToggle} field={'nodeCount'} sort={query}>
Instance count
</SortToggle>
</th>
<th className={'text-end'}>
<SortToggle onToggle={onSortToggle} field={'accountFeedCount'} sort={query}>
Account count
</SortToggle>
</th>
<th className={'text-end'}>
<SortToggle onToggle={onSortToggle} field={'channelFeedCount'} sort={query}>
Channel count
</SortToggle>
</th>
</tr>
</thead>
}

Wyświetl plik

@ -0,0 +1,32 @@
import React, { ReactElement } from 'react'
import ProgressBar from '../ProgressBar'
import SoftwareBadgePlaceholder from '../SoftwareBadgePlaceholder'
const Row = (): ReactElement => <tr>
<td>
<SoftwareBadgePlaceholder/>
</td>
<td className={'text-end placeholder-glow'}>
<span className={'placeholder col-5'}></span>
<ProgressBar way={'left'}
percents={0}/>
</td>
<td className={'text-end placeholder-glow'}>
<span className={'placeholder col-5'}></span>
<ProgressBar way={'left'}
percents={0}/>
</td>
<td className={'text-end placeholder-glow'}>
<span className={'placeholder col-5'}></span>
<ProgressBar way={'left'}
percents={0}/>
</td>
</tr>
export default function StatsPlaceholder ({ rowCount }: { rowCount?: number }): ReactElement {
return <tbody>
{[...Array(rowCount ?? 1).keys()].map(key => {
return <Row key={key}/>
})}
</tbody>
}

Wyświetl plik

@ -0,0 +1,30 @@
import React, { ReactElement } from 'react'
import { Stats, StatsAggregationFragment } from '../../graphql/generated/types'
import SoftwareBadge from '../SoftwareBadge'
import ProgressBar from '../ProgressBar'
export default function StatsResult ({ software, maxAggregation }: {
software: Stats
maxAggregation: StatsAggregationFragment
}): ReactElement {
return <tr>
<td>
<SoftwareBadge softwareName={software.softwareName}/>
</td>
<td className={'text-end'}>
<span>{software.nodeCount}</span>
<ProgressBar way={'left'}
percents={100 * software.nodeCount / maxAggregation.nodeCount}/>
</td>
<td className={'text-end'}>
<span>{software.accountFeedCount}</span>
<ProgressBar way={'left'}
percents={100 * software.accountFeedCount / maxAggregation.accountFeedCount}/>
</td>
<td className={'text-end'}>
<span>{software.channelFeedCount}</span>
<ProgressBar way={'left'}
percents={100 * software.channelFeedCount / maxAggregation.channelFeedCount}/>
</td>
</tr>
}

Wyświetl plik

@ -0,0 +1,27 @@
import { ReactElement } from 'react'
import { StatsAggregationFragment, StatsItemFragment } from '../../graphql/generated/types'
import StatsResult from './StatsResult'
export default function StatsResults ({ items, maxAggregation }: {
items: StatsItemFragment[] | undefined
maxAggregation: StatsAggregationFragment | undefined
}): ReactElement {
if (items === undefined || maxAggregation === undefined || items.length === 0) {
return (
<tbody>
<tr>
<td colSpan={4}><em>No stats found.</em></td>
</tr>
</tbody>
)
}
return (
<tbody>
{
items.map((software, index) => {
return <StatsResult key={index} software={software} maxAggregation={maxAggregation} />
})
}
</tbody>
)
}

Wyświetl plik

@ -0,0 +1,8 @@
import { Config } from 'convict'
import ClientConfig from './ClientConfig'
type AppConfig = Config<{
client: ClientConfig
}>
export default AppConfig

Wyświetl plik

@ -0,0 +1,7 @@
import GraphqlConfig from './GraphqlConfig'
import MatomoConfig from './MatomoConfig'
export default interface ClientConfig {
graphql: GraphqlConfig
matomo: MatomoConfig
}

Wyświetl plik

@ -0,0 +1,3 @@
export default interface GraphqlConfig {
url: string
}

Wyświetl plik

@ -0,0 +1,4 @@
export default interface MatomoConfig {
url: string
siteId: number
}

Wyświetl plik

@ -0,0 +1,37 @@
import convict from 'convict'
import AppConfig from './AppConfig'
import 'server-only'
export default function createConfig (): AppConfig {
console.info('Creating config')
return convict({
client: {
graphql: {
url: {
doc: 'Storage graphql endpoint url',
format: '*',
env: 'GRAPHQL_URL',
arg: 'graphql-url',
default: '/api/graphql'
}
},
matomo: {
url: {
doc: 'Matomo endpoint url',
env: 'MATOMO_URL',
arg: 'matomo-url',
format: '*',
default: ''
},
siteId: {
doc: 'Matomo site identificator',
env: 'MATOMO_SITE_ID',
arg: 'matomo-site-id',
format: 'int',
default: 0
}
}
}
})
}

Wyświetl plik

@ -1,8 +1,9 @@
import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client'
import GraphqlConfig from '../../config/GraphqlConfig.js'
export default function createGraphqlClient (): ApolloClient<NormalizedCacheObject> {
export default function createGraphqlClient (config: GraphqlConfig): ApolloClient<NormalizedCacheObject> {
return new ApolloClient({
uri: '/api/graphql',
uri: config.url,
cache: new InMemoryCache()
})
}

Wyświetl plik

@ -1,113 +0,0 @@
import { gql } from '@apollo/client'
import { List } from '../types/List'
export const ListFeedsQuery = gql`
query ListFeeds($paging: PagingInput, $query: FeedQueryInput) {
listFeeds(paging: $paging,query: $query){
paging {
hasNext
},
items {
id,
avatar,
displayName,
foundAt,
bot,
createdAt,
description,
displayName,
followersCount,
followingCount,
lastStatusAt,
locked,
name,
refreshedAt,
statusesCount,
type,
url,
fields {
name,value
}
node {
domain,
foundAt,
geoip {
city_name,
country_iso_code,
},
halfYearActiveUserCount,
id,
monthActiveUserCount,
name,
openRegistrations,
refreshAttemptedAt,
refreshedAt,
softwareName
},
parent {
id,
avatar,
displayName
name,
domain,
url
}
}
}
}
`
export interface ParentFeedItem {
id: string
avatar: string
displayName: string
name: string
domain: string
url: string
}
export interface FeedResultItem {
id: string
avatar: string
displayName: string
foundAt: string
bot: boolean
createdAt: string
description: string
followersCount: number
followingCount: number
lastStatusAt: string
locked: boolean
name: string
refreshedAt: string
statusesCount: number
type: 'account' | 'channel'
url: string
fields: Array<{
name: string
value: string
}>
node: {
domain: string
foundAt: string
geoip: {
// eslint-disable-next-line camelcase
city_name: string
// eslint-disable-next-line camelcase
country_iso_code: string
}
halfYearActiveUserCount: number
id: string
monthActiveUserCount: number
name: string
openRegistrations: boolean
refreshAttemptedAt: string
refreshedAt: string
softwareName: string
}
parent: ParentFeedItem | null
}
export interface ListFeedsResult {
listFeeds: List<FeedResultItem>
}

Wyświetl plik

@ -1,61 +0,0 @@
import { gql } from '@apollo/client'
import { List } from '../types/List'
export const ListNodesQuery = gql`
query ListNodes($paging: PagingInput, $query: NodeQueryInput) {
listNodes(paging: $paging,query: $query){
paging {
hasNext
}
items {
domain,
foundAt,
geoip {
city_name,country_iso_code
},
halfYearActiveUserCount,
id,
monthActiveUserCount,
accountFeedCount,
name,
openRegistrations,
refreshAttemptedAt,
refreshedAt,
serverIps,
softwareName,
softwareVersion,
standardizedSoftwareVersion,
statusesCount,
totalUserCount
}
}
}
`
export interface NodeResultItem {
domain: string
foundAt: string
geoip: {
// eslint-disable-next-line camelcase
city_name: string
// eslint-disable-next-line camelcase
country_iso_code: string
}
halfYearActiveUserCount: number
id: string
monthActiveUserCount: number
name: string
openRegistrations: boolean
refreshAttemptedAt: string
refreshedAt: string
softwareName: string
softwareVersion: string
standardizedSoftwareVersion: string
totalUserCount: number
statusesCount: number
accountFeedCount: number
}
export interface ListNodesResult {
listNodes: List<NodeResultItem>
}

Wyświetl plik

@ -1,26 +0,0 @@
import { gql } from '@apollo/client'
import { List } from '../types/List'
export const ListStatsQuery = gql`
query ListStats($query: StatsQueryInput) {
listStats(query:$query) {
items {
softwareName
nodeCount
accountFeedCount
channelFeedCount
}
}
}
`
export interface StatsResultItem {
softwareName: string
nodeCount: number
accountFeedCount: number
channelFeedCount: number
}
export interface ListStatsResult {
listStats: List<StatsResultItem>
}

Wyświetl plik

@ -1,6 +0,0 @@
import { PagingType } from '../../server/schema/types'
export interface List<TItem> {
paging: PagingType
items: TItem[]
}

Wyświetl plik

@ -1,7 +0,0 @@
import { FeedQueryInputType } from '../types/FeedQueryInput'
import { PagingInputType } from '../types/PagingInput'
export interface ListFeedsVariables {
paging: PagingInputType
query: FeedQueryInputType
}

Wyświetl plik

@ -1,7 +0,0 @@
import { PagingInputType } from '../types/PagingInput'
import { NodeQueryInputType } from '../types/NodeQueryInput'
export interface ListNodesVariables {
paging: PagingInputType
query: NodeQueryInputType
}

Wyświetl plik

@ -1,5 +0,0 @@
import { StatsQueryInputType } from '../types/StatsQueryInput'
export interface ListStatsVariables {
query: StatsQueryInputType
}

Wyświetl plik

@ -1,12 +0,0 @@
import { z } from 'zod'
import { stringTrimmed, transform } from '../../../lib/transform'
export const feedQueryInputSchema = z.object({
search: transform(
z.string().optional(),
stringTrimmed,
z.string()
)
})
export type FeedQueryInputType = z.infer<typeof feedQueryInputSchema>

Wyświetl plik

@ -1,17 +0,0 @@
import { stringTrimmed, transform } from '../../../lib/transform'
import { z } from 'zod'
import { createSortingInputSchema } from './SortingInput'
import { nodeSortingBySchema } from './NodeSortingByEnum'
export const nodeQueryInputSchema = createSortingInputSchema(nodeSortingBySchema)
.extend(
{
search: transform(
z.string().optional(),
stringTrimmed,
z.string()
)
}
)
export type NodeQueryInputType = z.infer<typeof nodeQueryInputSchema>

Wyświetl plik

@ -1,17 +0,0 @@
import { z } from 'zod'
export const NodeSortingByValues: readonly [string, ...string[]] = [
'domain',
'softwareName',
'totalUserCount',
'monthActiveUserCount',
'halfYearActiveUserCount',
'statusesCount',
'accountFeedCount',
'openRegistrations',
'refreshedAt'
]
export const nodeSortingBySchema = z.enum(NodeSortingByValues)
export type NodeSoringByEnumType = z.infer<typeof nodeSortingBySchema>

Wyświetl plik

@ -1,3 +0,0 @@
export interface PagingInputType {
page: number
}

Wyświetl plik

@ -1,14 +0,0 @@
import { z } from 'zod'
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export const createSortingInputSchema = (members: z.ZodEnum<[string, ...string[]]>) => {
return z.object({
sortBy: members,
sortWay: z.enum(['asc', 'desc'])
})
}
export interface SortingInputType<TMembers> {
sortBy: TMembers
sortWay: 'asc' | 'desc'
}

Wyświetl plik

@ -1,7 +0,0 @@
import { z } from 'zod'
import { createSortingInputSchema } from './SortingInput'
import { statsSortingBySchema } from './StatsSortingByEnum'
export const statsQueryInputSchema = createSortingInputSchema(statsSortingBySchema)
export type StatsQueryInputType = z.infer<typeof statsQueryInputSchema>

Wyświetl plik

@ -1,12 +0,0 @@
import { z } from 'zod'
export const StatsSortingByValues: readonly [string, ...string[]] = [
'softwareName',
'nodeCount',
'accountFeedCount',
'channelFeedCount'
]
export const statsSortingBySchema = z.enum(StatsSortingByValues)
export type StatsSoringByEnumType = z.infer<typeof statsSortingBySchema>

Wyświetl plik

@ -0,0 +1,28 @@
fragment ListFeedsItem on Feed{
id,
avatar,
displayName,
foundAt,
bot,
createdAt,
description,
displayName,
followersCount,
followingCount,
lastStatusAt,
locked,
name,
refreshedAt,
statusesCount,
type,
url,
fields {
name,value
}
node {
...ListFeedsNode
},
parent {
...ParentFeed
}
}

Wyświetl plik

@ -0,0 +1,15 @@
fragment ListFeedsNode on Node{
domain,
foundAt,
geoip {
...ListNodesGeoIp
},
halfYearActiveUserCount,
id,
monthActiveUserCount,
name,
openRegistrations,
refreshAttemptedAt,
refreshedAt,
softwareName
}

Wyświetl plik

@ -0,0 +1,4 @@
fragment ListNodesGeoIp on GeoIp{
city_name,
country_iso_code
}

Wyświetl plik

@ -0,0 +1,21 @@
fragment ListNodesItem on Node{
domain,
foundAt,
geoip {
...ListNodesGeoIp
},
halfYearActiveUserCount,
id,
monthActiveUserCount,
accountFeedCount,
name,
openRegistrations,
refreshAttemptedAt,
refreshedAt,
serverIps,
softwareName,
softwareVersion,
standardizedSoftwareVersion,
statusesCount,
totalUserCount
}

Wyświetl plik

@ -0,0 +1,3 @@
fragment Paging on Paging{
hasNext
}

Wyświetl plik

@ -0,0 +1,8 @@
fragment ParentFeed on Feed{
id,
avatar,
displayName
name,
domain,
url
}

Wyświetl plik

@ -0,0 +1,5 @@
fragment StatsAggregation on StatsAggregation{
accountFeedCount
channelFeedCount
nodeCount
}

Wyświetl plik

@ -0,0 +1,8 @@
fragment StatsAggregations on StatsAggregations{
sum {
...StatsAggregation
}
max {
...StatsAggregation
}
}

Wyświetl plik

@ -0,0 +1,6 @@
fragment StatsItem on Stats{
softwareName
nodeCount
accountFeedCount
channelFeedCount
}

Wyświetl plik

@ -0,0 +1,192 @@
scalar DateTime
type Feed {
id: ID!
domain: String!
foundAt: DateTime!
refreshedAt: DateTime
name: String!
displayName: String!
description: String!
followersCount: Int
followingCount: Int
statusesCount: Int
lastStatusAt: DateTime
createdAt: DateTime
bot: Boolean
locked: Boolean!
url: String!
avatar: String
type: FeedTypeEnum!
parent: Feed
fields: [Field!]!
node: Node!
}
input FeedIdentityInput {
name: String!
nodeDomain: String!
}
input FeedInput {
name: String!
displayName: String!
description: String!
followersCount: Int!
followingCount: Int!
statusesCount: Int
bot: Boolean
url: String!
avatar: String
locked: Boolean!
lastStatusAt: DateTime
createdAt: DateTime!
fields: [FieldInput!]!
type: FeedTypeEnum!
parentFeed: FeedIdentityInput
tags: [String!]!
emails: [String!]!
}
type FeedList {
paging: Paging!
items: [Feed!]!
}
input FeedQueryInput {
search: String! = ""
}
enum FeedTypeEnum {
account
channel
}
type Field {
name: String!
value: String!
}
input FieldInput {
name: String!
value: String!
}
type GeoIp {
city_name: String
continent_name: String
country_iso_code: String
country_name: String
location: String
region_iso_code: String
region_name: String
}
type Node {
id: ID!
name: String
foundAt: DateTime!
refreshAttemptedAt: DateTime
refreshedAt: DateTime
openRegistrations: Boolean
domain: String!
serverIps: [String!]
geoip: GeoIp
softwareName: String
accountFeedCount: Int
channelFeedCount: Int
softwareVersion: String
standardizedSoftwareVersion: String
halfYearActiveUserCount: Int
monthActiveUserCount: Int
statusesCount: Int
totalUserCount: Int
}
type NodeList {
paging: Paging!
items: [Node!]!
}
input NodeQueryInput {
sortBy: NodeSortingByEnum = refreshedAt
sortWay: SortingWayEnum = desc
search: String! = ""
}
enum NodeSortingByEnum {
domain
softwareName
totalUserCount
monthActiveUserCount
halfYearActiveUserCount
statusesCount
accountFeedCount
openRegistrations
refreshedAt
}
type NodeStats {
channel: Int!
account: Int!
}
type Paging {
hasNext: Boolean!
}
input PagingInput {
page: Int! = 0
}
type Sorting {
by: String!
way: SortingWayEnum!
}
enum SortingWayEnum {
asc
desc
}
type Stats {
softwareName: String!
nodeCount: Int!
accountFeedCount: Int!
channelFeedCount: Int!
}
type StatsList {
items: [Stats!]!
aggregations: StatsAggregations!
}
input StatsQueryInput {
sortBy: StatsSortingByEnum = nodeCount
sortWay: SortingWayEnum = desc
}
enum StatsSortingByEnum {
softwareName
nodeCount
accountFeedCount
channelFeedCount
}
type StatsAggregations {
sum: StatsAggregation!
max: StatsAggregation!
}
type StatsAggregation {
nodeCount: Int!
accountFeedCount: Int!
channelFeedCount: Int!
}
type Query {
countNodeFeeds(nodeDomain: String!): NodeStats
listFeeds(paging: PagingInput! = {page: 0}, query: FeedQueryInput! = {search: ""}): FeedList
listNodes(paging: PagingInput! = {page: 0}, query: NodeQueryInput! = {sortBy: refreshedAt, sortWay: desc, search: ""}): NodeList
listStats(query: StatsQueryInput! = {sortBy: nodeCount, sortWay: desc}): StatsList
}

Wyświetl plik

@ -0,0 +1,289 @@
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'
export type Maybe<T> = T | null
export type InputMaybe<T> = Maybe<T>
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }
/** All built-in and custom scalars, mapped to their actual values */
export interface Scalars {
ID: string
String: string
Boolean: boolean
Int: number
Float: number
DateTime: any
}
export interface Feed {
__typename?: 'Feed'
avatar?: Maybe<Scalars['String']>
bot?: Maybe<Scalars['Boolean']>
createdAt?: Maybe<Scalars['DateTime']>
description: Scalars['String']
displayName: Scalars['String']
domain: Scalars['String']
fields: Field[]
followersCount?: Maybe<Scalars['Int']>
followingCount?: Maybe<Scalars['Int']>
foundAt: Scalars['DateTime']
id: Scalars['ID']
lastStatusAt?: Maybe<Scalars['DateTime']>
locked: Scalars['Boolean']
name: Scalars['String']
node: Node
parent?: Maybe<Feed>
refreshedAt?: Maybe<Scalars['DateTime']>
statusesCount?: Maybe<Scalars['Int']>
type: FeedTypeEnum
url: Scalars['String']
}
export interface FeedIdentityInput {
name: Scalars['String']
nodeDomain: Scalars['String']
}
export interface FeedInput {
avatar?: InputMaybe<Scalars['String']>
bot?: InputMaybe<Scalars['Boolean']>
createdAt: Scalars['DateTime']
description: Scalars['String']
displayName: Scalars['String']
emails: Array<Scalars['String']>
fields: FieldInput[]
followersCount: Scalars['Int']
followingCount: Scalars['Int']
lastStatusAt?: InputMaybe<Scalars['DateTime']>
locked: Scalars['Boolean']
name: Scalars['String']
parentFeed?: InputMaybe<FeedIdentityInput>
statusesCount?: InputMaybe<Scalars['Int']>
tags: Array<Scalars['String']>
type: FeedTypeEnum
url: Scalars['String']
}
export interface FeedList {
__typename?: 'FeedList'
items: Feed[]
paging: Paging
}
export interface FeedQueryInput {
search?: Scalars['String']
}
export enum FeedTypeEnum {
Account = 'account',
Channel = 'channel'
}
export interface Field {
__typename?: 'Field'
name: Scalars['String']
value: Scalars['String']
}
export interface FieldInput {
name: Scalars['String']
value: Scalars['String']
}
export interface GeoIp {
__typename?: 'GeoIp'
city_name?: Maybe<Scalars['String']>
continent_name?: Maybe<Scalars['String']>
country_iso_code?: Maybe<Scalars['String']>
country_name?: Maybe<Scalars['String']>
location?: Maybe<Scalars['String']>
region_iso_code?: Maybe<Scalars['String']>
region_name?: Maybe<Scalars['String']>
}
export interface Node {
__typename?: 'Node'
accountFeedCount?: Maybe<Scalars['Int']>
channelFeedCount?: Maybe<Scalars['Int']>
domain: Scalars['String']
foundAt: Scalars['DateTime']
geoip?: Maybe<GeoIp>
halfYearActiveUserCount?: Maybe<Scalars['Int']>
id: Scalars['ID']
monthActiveUserCount?: Maybe<Scalars['Int']>
name?: Maybe<Scalars['String']>
openRegistrations?: Maybe<Scalars['Boolean']>
refreshAttemptedAt?: Maybe<Scalars['DateTime']>
refreshedAt?: Maybe<Scalars['DateTime']>
serverIps?: Maybe<Array<Scalars['String']>>
softwareName?: Maybe<Scalars['String']>
softwareVersion?: Maybe<Scalars['String']>
standardizedSoftwareVersion?: Maybe<Scalars['String']>
statusesCount?: Maybe<Scalars['Int']>
totalUserCount?: Maybe<Scalars['Int']>
}
export interface NodeList {
__typename?: 'NodeList'
items: Node[]
paging: Paging
}
export interface NodeQueryInput {
search?: Scalars['String']
sortBy?: InputMaybe<NodeSortingByEnum>
sortWay?: InputMaybe<SortingWayEnum>
}
export enum NodeSortingByEnum {
AccountFeedCount = 'accountFeedCount',
Domain = 'domain',
HalfYearActiveUserCount = 'halfYearActiveUserCount',
MonthActiveUserCount = 'monthActiveUserCount',
OpenRegistrations = 'openRegistrations',
RefreshedAt = 'refreshedAt',
SoftwareName = 'softwareName',
StatusesCount = 'statusesCount',
TotalUserCount = 'totalUserCount'
}
export interface NodeStats {
__typename?: 'NodeStats'
account: Scalars['Int']
channel: Scalars['Int']
}
export interface Paging {
__typename?: 'Paging'
hasNext: Scalars['Boolean']
}
export interface PagingInput {
page?: Scalars['Int']
}
export interface Query {
__typename?: 'Query'
countNodeFeeds?: Maybe<NodeStats>
listFeeds?: Maybe<FeedList>
listNodes?: Maybe<NodeList>
listStats?: Maybe<StatsList>
}
export interface QueryCountNodeFeedsArgs {
nodeDomain: Scalars['String']
}
export interface QueryListFeedsArgs {
paging?: PagingInput
query?: FeedQueryInput
}
export interface QueryListNodesArgs {
paging?: PagingInput
query?: NodeQueryInput
}
export interface QueryListStatsArgs {
query?: StatsQueryInput
}
export interface Sorting {
__typename?: 'Sorting'
by: Scalars['String']
way: SortingWayEnum
}
export enum SortingWayEnum {
Asc = 'asc',
Desc = 'desc'
}
export interface Stats {
__typename?: 'Stats'
accountFeedCount: Scalars['Int']
channelFeedCount: Scalars['Int']
nodeCount: Scalars['Int']
softwareName: Scalars['String']
}
export interface StatsAggregation {
__typename?: 'StatsAggregation'
accountFeedCount: Scalars['Int']
channelFeedCount: Scalars['Int']
nodeCount: Scalars['Int']
}
export interface StatsAggregations {
__typename?: 'StatsAggregations'
max: StatsAggregation
sum: StatsAggregation
}
export interface StatsList {
__typename?: 'StatsList'
aggregations: StatsAggregations
items: Stats[]
}
export interface StatsQueryInput {
sortBy?: InputMaybe<StatsSortingByEnum>
sortWay?: InputMaybe<SortingWayEnum>
}
export enum StatsSortingByEnum {
AccountFeedCount = 'accountFeedCount',
ChannelFeedCount = 'channelFeedCount',
NodeCount = 'nodeCount',
SoftwareName = 'softwareName'
}
export interface ListFeedsItemFragment { __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, foundAt: any, bot?: boolean | null, createdAt?: any | null, description: string, followersCount?: number | null, followingCount?: number | null, lastStatusAt?: any | null, locked: boolean, name: string, refreshedAt?: any | null, statusesCount?: number | null, type: FeedTypeEnum, url: string, fields: Array<{ __typename?: 'Field', name: string, value: string }>, node: { __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, softwareName?: string | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }, parent?: { __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, name: string, domain: string, url: string } | null }
export interface ListFeedsNodeFragment { __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, softwareName?: string | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }
export interface ListNodesGeoIpFragment { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null }
export interface ListNodesItemFragment { __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, accountFeedCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, serverIps?: string[] | null, softwareName?: string | null, softwareVersion?: string | null, standardizedSoftwareVersion?: string | null, statusesCount?: number | null, totalUserCount?: number | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }
export interface PagingFragment { __typename?: 'Paging', hasNext: boolean }
export interface ParentFeedFragment { __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, name: string, domain: string, url: string }
export interface StatsAggregationFragment { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number }
export interface StatsAggregationsFragment { __typename?: 'StatsAggregations', sum: { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number }, max: { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number } }
export interface StatsItemFragment { __typename?: 'Stats', softwareName: string, nodeCount: number, accountFeedCount: number, channelFeedCount: number }
export type ListFeedsQueryVariables = Exact<{
paging: PagingInput
query: FeedQueryInput
}>
export interface ListFeedsQuery { __typename?: 'Query', listFeeds?: { __typename?: 'FeedList', paging: { __typename?: 'Paging', hasNext: boolean }, items: Array<{ __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, foundAt: any, bot?: boolean | null, createdAt?: any | null, description: string, followersCount?: number | null, followingCount?: number | null, lastStatusAt?: any | null, locked: boolean, name: string, refreshedAt?: any | null, statusesCount?: number | null, type: FeedTypeEnum, url: string, fields: Array<{ __typename?: 'Field', name: string, value: string }>, node: { __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, softwareName?: string | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }, parent?: { __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, name: string, domain: string, url: string } | null }> } | null }
export type ListNodesQueryVariables = Exact<{
paging: PagingInput
query: NodeQueryInput
}>
export interface ListNodesQuery { __typename?: 'Query', listNodes?: { __typename?: 'NodeList', paging: { __typename?: 'Paging', hasNext: boolean }, items: Array<{ __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, accountFeedCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, serverIps?: string[] | null, softwareName?: string | null, softwareVersion?: string | null, standardizedSoftwareVersion?: string | null, statusesCount?: number | null, totalUserCount?: number | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }> } | null }
export type ListStatsQueryVariables = Exact<{
query: StatsQueryInput
}>
export interface ListStatsQuery { __typename?: 'Query', listStats?: { __typename?: 'StatsList', items: Array<{ __typename?: 'Stats', softwareName: string, nodeCount: number, accountFeedCount: number, channelFeedCount: number }>, aggregations: { __typename?: 'StatsAggregations', sum: { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number }, max: { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number } } } | null }
export const ListNodesGeoIpFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ListNodesGeoIp' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'GeoIp' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'city_name' } }, { kind: 'Field', name: { kind: 'Name', value: 'country_iso_code' } }] } }] } as unknown as DocumentNode<ListNodesGeoIpFragment, unknown>
export const ListFeedsNodeFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ListFeedsNode' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Node' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'domain' } }, { kind: 'Field', name: { kind: 'Name', value: 'foundAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'geoip' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListNodesGeoIp' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'halfYearActiveUserCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'monthActiveUserCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'openRegistrations' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshAttemptedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'softwareName' } }] } }, ...ListNodesGeoIpFragmentDoc.definitions] } as unknown as DocumentNode<ListFeedsNodeFragment, unknown>
export const ParentFeedFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ParentFeed' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Feed' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'avatar' } }, { kind: 'Field', name: { kind: 'Name', value: 'displayName' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'domain' } }, { kind: 'Field', name: { kind: 'Name', value: 'url' } }] } }] } as unknown as DocumentNode<ParentFeedFragment, unknown>
export const ListFeedsItemFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ListFeedsItem' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Feed' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'avatar' } }, { kind: 'Field', name: { kind: 'Name', value: 'displayName' } }, { kind: 'Field', name: { kind: 'Name', value: 'foundAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'bot' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'displayName' } }, { kind: 'Field', name: { kind: 'Name', value: 'followersCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'followingCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'lastStatusAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'locked' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusesCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'url' } }, { kind: 'Field', name: { kind: 'Name', value: 'fields' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'value' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'node' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListFeedsNode' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'parent' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ParentFeed' } }] } }] } }, ...ListFeedsNodeFragmentDoc.definitions, ...ParentFeedFragmentDoc.definitions] } as unknown as DocumentNode<ListFeedsItemFragment, unknown>
export const ListNodesItemFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ListNodesItem' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Node' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'domain' } }, { kind: 'Field', name: { kind: 'Name', value: 'foundAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'geoip' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListNodesGeoIp' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'halfYearActiveUserCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'monthActiveUserCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'accountFeedCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'openRegistrations' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshAttemptedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'serverIps' } }, { kind: 'Field', name: { kind: 'Name', value: 'softwareName' } }, { kind: 'Field', name: { kind: 'Name', value: 'softwareVersion' } }, { kind: 'Field', name: { kind: 'Name', value: 'standardizedSoftwareVersion' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusesCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'totalUserCount' } }] } }, ...ListNodesGeoIpFragmentDoc.definitions] } as unknown as DocumentNode<ListNodesItemFragment, unknown>
export const PagingFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'Paging' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Paging' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'hasNext' } }] } }] } as unknown as DocumentNode<PagingFragment, unknown>
export const StatsAggregationFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'StatsAggregation' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'StatsAggregation' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'accountFeedCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'channelFeedCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'nodeCount' } }] } }] } as unknown as DocumentNode<StatsAggregationFragment, unknown>
export const StatsAggregationsFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'StatsAggregations' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'StatsAggregations' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'sum' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'StatsAggregation' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'max' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'StatsAggregation' } }] } }] } }, ...StatsAggregationFragmentDoc.definitions] } as unknown as DocumentNode<StatsAggregationsFragment, unknown>
export const StatsItemFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'StatsItem' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Stats' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'softwareName' } }, { kind: 'Field', name: { kind: 'Name', value: 'nodeCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'accountFeedCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'channelFeedCount' } }] } }] } as unknown as DocumentNode<StatsItemFragment, unknown>
export const ListFeedsDocument = { kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: 'query', name: { kind: 'Name', value: 'ListFeeds' }, variableDefinitions: [{ kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'paging' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'PagingInput' } } } }, { kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'query' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'FeedQueryInput' } } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'listFeeds' }, arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'paging' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'paging' } } }, { kind: 'Argument', name: { kind: 'Name', value: 'query' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'query' } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'paging' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'Paging' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'items' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListFeedsItem' } }] } }] } }] } }, ...PagingFragmentDoc.definitions, ...ListFeedsItemFragmentDoc.definitions] } as unknown as DocumentNode<ListFeedsQuery, ListFeedsQueryVariables>
export const ListNodesDocument = { kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: 'query', name: { kind: 'Name', value: 'ListNodes' }, variableDefinitions: [{ kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'paging' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'PagingInput' } } } }, { kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'query' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'NodeQueryInput' } } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'listNodes' }, arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'paging' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'paging' } } }, { kind: 'Argument', name: { kind: 'Name', value: 'query' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'query' } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'paging' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'Paging' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'items' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListNodesItem' } }] } }] } }] } }, ...PagingFragmentDoc.definitions, ...ListNodesItemFragmentDoc.definitions] } as unknown as DocumentNode<ListNodesQuery, ListNodesQueryVariables>
export const ListStatsDocument = { kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: 'query', name: { kind: 'Name', value: 'ListStats' }, variableDefinitions: [{ kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'query' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'StatsQueryInput' } } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'listStats' }, arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'query' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'query' } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'items' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'StatsItem' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'aggregations' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'StatsAggregations' } }] } }] } }] } }, ...StatsItemFragmentDoc.definitions, ...StatsAggregationsFragmentDoc.definitions] } as unknown as DocumentNode<ListStatsQuery, ListStatsQueryVariables>

Wyświetl plik

@ -0,0 +1,10 @@
query ListFeeds($paging: PagingInput!, $query: FeedQueryInput!) {
listFeeds(paging: $paging,query: $query){
paging {
...Paging
},
items {
...ListFeedsItem
}
}
}

Wyświetl plik

@ -0,0 +1,10 @@
query ListNodes($paging: PagingInput!, $query: NodeQueryInput!) {
listNodes(paging: $paging,query: $query){
paging {
...Paging
}
items {
...ListNodesItem
}
}
}

Wyświetl plik

@ -0,0 +1,10 @@
query ListStats($query: StatsQueryInput!) {
listStats(query:$query) {
items {
...StatsItem
}
aggregations {
...StatsAggregations
}
}
}

Some files were not shown because too many files have changed in this diff Show More