kopia lustrzana https://github.com/Stopka/fedisearch
Finished nextjs appdir adaptations
rodzic
b8f5d28dd5
commit
3a02c39109
13
Dockerfile
13
Dockerfile
|
@ -1,10 +1,11 @@
|
||||||
FROM node:18-bullseye AS prebuild
|
FROM node:18-bullseye AS prebuild
|
||||||
|
|
||||||
FROM prebuild AS build
|
FROM prebuild AS build
|
||||||
WORKDIR /srv
|
WORKDIR /srv
|
||||||
COPY application/package*.json ./
|
COPY application/package*.json ./
|
||||||
|
COPY application/yarn.lock ./
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
COPY application/. .
|
COPY application/. .
|
||||||
RUN chmod -R uog+r .
|
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
FROM build as dev
|
FROM build as dev
|
||||||
|
@ -12,13 +13,9 @@ CMD yarn dev
|
||||||
|
|
||||||
FROM prebuild AS prod
|
FROM prebuild AS prod
|
||||||
RUN groupadd -g 1001 nodejs
|
RUN groupadd -g 1001 nodejs
|
||||||
RUN useradd -u 1001 -g 1001 nextjs
|
RUN useradd -m -u 1001 -g 1001 nextjs
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
WORKDIR /srv
|
WORKDIR /srv
|
||||||
COPY --from=build /srv/node_modules ./node_modules
|
COPY --from=build --chown=nextjs:nodejs /srv/. ./
|
||||||
COPY --from=build /srv/package*.json ./
|
CMD yarn build && yarn start
|
||||||
COPY --from=build /srv/next.config.js ./
|
|
||||||
COPY --from=build --chown=nextjs:nodejs /srv/src/.next ./src/.next
|
|
||||||
COPY --from=build /srv/src/public ./src/public
|
|
||||||
CMD yarn start
|
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import HtmlHead from '../../components/layout/HtmlHead'
|
||||||
|
|
||||||
|
export default function Head (): ReactElement {
|
||||||
|
return <>
|
||||||
|
<HtmlHead title={'People'} description={'Search people on Fediverse'}/>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ import createConfig from '../../config/createConfig'
|
||||||
export default async function Page (): Promise<ReactElement> {
|
export default async function Page (): Promise<ReactElement> {
|
||||||
const clientConfig = createConfig().get('client')
|
const clientConfig = createConfig().get('client')
|
||||||
return (
|
return (
|
||||||
<Layout title={'People'} description={'Search people on Fediverse'} config={clientConfig}>
|
<Layout title={'People'} config={clientConfig}>
|
||||||
<FeedSearch />
|
<FeedSearch />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
import { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
|
import HtmlHead from '../components/layout/HtmlHead'
|
||||||
|
|
||||||
export default function Head (): ReactElement {
|
export default function Head (): ReactElement {
|
||||||
return (
|
return <HtmlHead />
|
||||||
<>
|
|
||||||
<title></title>
|
|
||||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
|
import Footer from '../components/layout/Footer'
|
||||||
|
import NavBar from '../components/layout/NavBar'
|
||||||
|
import '../styles/global.scss'
|
||||||
|
|
||||||
export default function RootLayout ({
|
export default function RootLayout ({
|
||||||
children
|
children
|
||||||
|
@ -6,9 +9,16 @@ export default function RootLayout ({
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head />
|
<body>
|
||||||
<body>{children}</body>
|
<div className="container">
|
||||||
</html>
|
<NavBar/>
|
||||||
|
<main>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer/>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
|
||||||
|
export default function Loading (): ReactElement {
|
||||||
|
console.log('Loading')
|
||||||
|
return <div className={'container'}>
|
||||||
|
<h1 className={'placeholder-glow'} aria-hidden={true}><span className={'placeholder col-4'}/></h1>
|
||||||
|
</div>
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import HtmlHead from '../../components/layout/HtmlHead'
|
||||||
|
|
||||||
|
export default function Head (): ReactElement {
|
||||||
|
return <>
|
||||||
|
<HtmlHead title={'Servers'} description={'Search Fediverse servers'}/>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ import createConfig from '../../config/createConfig'
|
||||||
export default async function Page (): Promise<ReactElement> {
|
export default async function Page (): Promise<ReactElement> {
|
||||||
const clientConfig = createConfig().get('client')
|
const clientConfig = createConfig().get('client')
|
||||||
return (
|
return (
|
||||||
<Layout title={'Servers'} description={'Search Fediverse servers'} config={clientConfig}>
|
<Layout title={'Servers'} config={clientConfig}>
|
||||||
<NodeSearch />
|
<NodeSearch />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import HtmlHead from '../../components/layout/HtmlHead'
|
||||||
|
|
||||||
|
export default function Head (): ReactElement {
|
||||||
|
return <>
|
||||||
|
<HtmlHead title={'Opt out'} description={'How to opt out from our index'}/>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import Accordion from '../../components/accordion/Accordion'
|
import Accordion from '../../components/accordion/Accordion'
|
||||||
import AccordionItem from '../../components/accordion/AccordionItem'
|
|
||||||
import MastodonNoindexOptout from '../../components/optout/MastodonNoindexOptout'
|
import MastodonNoindexOptout from '../../components/optout/MastodonNoindexOptout'
|
||||||
import MastodonSuggestingOptout from '../../components/optout/MastodonSuggestingOptout'
|
import MastodonSuggestingOptout from '../../components/optout/MastodonSuggestingOptout'
|
||||||
import RobotsTxtOptout from '../../components/optout/RobotsTxtOptout'
|
import RobotsTxtOptout from '../../components/optout/RobotsTxtOptout'
|
||||||
|
@ -11,7 +10,7 @@ import createConfig from '../../config/createConfig'
|
||||||
export default async function Page (): Promise<ReactElement> {
|
export default async function Page (): Promise<ReactElement> {
|
||||||
const clientConfig = createConfig().get('client')
|
const clientConfig = createConfig().get('client')
|
||||||
return (
|
return (
|
||||||
<Layout title={'Opt out'} description={'What to do to opt out from the index'} config={clientConfig}>
|
<Layout title={'Opt out'} config={clientConfig}>
|
||||||
<p>You don't want to be listed here? There are several ways to opt-out from our index:</p>
|
<p>You don't want to be listed here? There are several ways to opt-out from our index:</p>
|
||||||
<Accordion>
|
<Accordion>
|
||||||
<MastodonNoindexOptout/>
|
<MastodonNoindexOptout/>
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import HtmlHead from '../../components/layout/HtmlHead'
|
||||||
|
|
||||||
|
export default function Head (): ReactElement {
|
||||||
|
return <>
|
||||||
|
<HtmlHead title={'Stats'} description={'Index statistics'}/>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -1,13 +1,12 @@
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import NodeSearch from '../../components/node/NodeSearch'
|
|
||||||
import Layout from '../../components/server/Layout'
|
import Layout from '../../components/server/Layout'
|
||||||
import Stats from "../../components/stats/Stats";
|
import Stats from '../../components/stats/Stats'
|
||||||
import createConfig from '../../config/createConfig'
|
import createConfig from '../../config/createConfig'
|
||||||
|
|
||||||
export default async function Page (): Promise<ReactElement> {
|
export default async function Page (): Promise<ReactElement> {
|
||||||
const clientConfig = createConfig().get('client')
|
const clientConfig = createConfig().get('client')
|
||||||
return (
|
return (
|
||||||
<Layout title={'Stats'} description={'Fediverse stats'} config={clientConfig}>
|
<Layout title={'Stats'} config={clientConfig}>
|
||||||
<Stats />
|
<Stats />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
|
|
||||||
const Spinner: React.FC = () => {
|
export default function Spinner (): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="spinner-border" role="status">
|
<div className="spinner-border" role="status">
|
||||||
<span className="visually-hidden">Loading...</span>
|
<span className="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Spinner
|
|
||||||
|
|
|
@ -6,10 +6,11 @@ import SearchInput from '../form/SearchInput'
|
||||||
import SubmitButton from '../form/SubmitButton'
|
import SubmitButton from '../form/SubmitButton'
|
||||||
|
|
||||||
export default function FeedForm (
|
export default function FeedForm (
|
||||||
{ onSubmit, onQueryChange, query }: {
|
{ onSubmit, onQueryChange, query, loading }: {
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onQueryChange: (query: FeedQueryInput) => void
|
onQueryChange: (query: FeedQueryInput) => void
|
||||||
query: FeedQueryInput
|
query: FeedQueryInput
|
||||||
|
loading?: boolean
|
||||||
}
|
}
|
||||||
): ReactElement {
|
): ReactElement {
|
||||||
const handleQueryChange = (event): void => {
|
const handleQueryChange = (event): void => {
|
||||||
|
@ -41,6 +42,7 @@ export default function FeedForm (
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
faIcon={faSearch}
|
faIcon={faSearch}
|
||||||
label={'Search'}
|
label={'Search'}
|
||||||
|
loading={loading}
|
||||||
id={'search-feeds-button'}
|
id={'search-feeds-button'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,7 +7,7 @@ import Badge from './badges/Badge'
|
||||||
export default function FeedPlaceholder (): ReactElement {
|
export default function FeedPlaceholder (): ReactElement {
|
||||||
const greyDotBlob = 'data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='
|
const greyDotBlob = 'data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='
|
||||||
return (
|
return (
|
||||||
<section className="card feed g-col-12 mb-3" aria-hidden="true">
|
<section className="card feed g-col-12 mb-3 placeholder-wrapper" aria-hidden="true">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h3 className={'card-title with-emoji display-name placeholder-glow'}>
|
<h3 className={'card-title with-emoji display-name placeholder-glow'}>
|
||||||
<a><span className="placeholder col-4"></span></a>
|
<a><span className="placeholder col-4"></span></a>
|
||||||
|
|
|
@ -16,7 +16,6 @@ export default function FeedResults ({ feeds }: { feeds: ListFeedsItemFragment[]
|
||||||
return (<div className={'grid'}>
|
return (<div className={'grid'}>
|
||||||
{
|
{
|
||||||
feeds.map((feed, index) => {
|
feeds.map((feed, index) => {
|
||||||
console.info('feed', feed)
|
|
||||||
return (<FeedResult key={index} feed={feed} />)
|
return (<FeedResult key={index} feed={feed} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useSearchParams } from 'next/navigation'
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { FeedQueryInput, ListFeedsDocument } from '../../graphql/generated/types'
|
import { FeedQueryInput, ListFeedsDocument } from '../../graphql/generated/types'
|
||||||
|
@ -27,7 +27,6 @@ export default function FeedSearch (): ReactElement {
|
||||||
const matomo = useMatomo()
|
const matomo = useMatomo()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
|
||||||
const routerQuery = feedQueryInputSchema.parse(Object.fromEntries(searchParams))
|
const routerQuery = feedQueryInputSchema.parse(Object.fromEntries(searchParams))
|
||||||
const [page, setPage] = useState<number>(0)
|
const [page, setPage] = useState<number>(0)
|
||||||
const [query, setQuery] = useState<FeedQueryInput>(routerQuery)
|
const [query, setQuery] = useState<FeedQueryInput>(routerQuery)
|
||||||
|
@ -39,7 +38,7 @@ export default function FeedSearch (): ReactElement {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
useEffect((): void => {
|
useEffect((): void => {
|
||||||
router.push(`${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
|
window.history.replaceState({}, '', `${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
|
||||||
matomo.trackEvent({
|
matomo.trackEvent({
|
||||||
category: 'feeds',
|
category: 'feeds',
|
||||||
action: 'new-search'
|
action: 'new-search'
|
||||||
|
@ -95,7 +94,7 @@ export default function FeedSearch (): ReactElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<FeedForm query={query} onQueryChange={handleQueryChange} onSubmit={handleSearchSubmit}/>
|
<FeedForm query={query} onQueryChange={handleQueryChange} onSubmit={handleSearchSubmit} loading={loading || pageLoading}/>
|
||||||
<FeedInfo show={query.search === ''}>
|
<FeedInfo show={query.search === ''}>
|
||||||
<Loader loading={loading || pageLoading} showBottom={true} placeholder={(<FeedPlaceholder/>)}>
|
<Loader loading={loading || pageLoading} showBottom={true} placeholder={(<FeedPlaceholder/>)}>
|
||||||
<FeedResults feeds={data?.listFeeds?.items}/>
|
<FeedResults feeds={data?.listFeeds?.items}/>
|
||||||
|
|
|
@ -2,13 +2,25 @@ import { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
|
|
||||||
export default function SubmitButton ({ faIcon, label, id }: {
|
export default function SubmitButton ({ faIcon, label, id, loading, loadingLabel }: {
|
||||||
faIcon: IconProp
|
faIcon: IconProp
|
||||||
label: string
|
label: string
|
||||||
|
loadingLabel?: string
|
||||||
|
loading?: boolean
|
||||||
id?: string
|
id?: string
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
|
loadingLabel = loadingLabel ?? label
|
||||||
return <button type={'submit'} className={'btn btn-primary'} id={id}>
|
return <button type={'submit'} className={'btn btn-primary'} id={id}>
|
||||||
<FontAwesomeIcon icon={faIcon}/>
|
{
|
||||||
<span>{label}</span>
|
loading === true
|
||||||
|
? <>
|
||||||
|
<span className="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
<span>{loadingLabel}</span>
|
||||||
|
</>
|
||||||
|
: <>
|
||||||
|
<FontAwesomeIcon icon={faIcon}/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,13 @@
|
||||||
'use client'
|
'use client'
|
||||||
import React, { ReactElement, ReactNode, useEffect } from 'react'
|
import React, { ReactElement, ReactNode, useEffect } from 'react'
|
||||||
import Head from 'next/head'
|
|
||||||
import { useMatomo } from '../../hooks/MatomoHook'
|
import { useMatomo } from '../../hooks/MatomoHook'
|
||||||
import Footer from './Footer'
|
|
||||||
import NavBar from './NavBar'
|
|
||||||
|
|
||||||
export default function ClientLayout ({
|
export default function ClientLayout ({
|
||||||
children,
|
children,
|
||||||
title,
|
title
|
||||||
description
|
|
||||||
}: {
|
}: {
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
title: string
|
title: string
|
||||||
description: string
|
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const matomo = useMatomo()
|
const matomo = useMatomo()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -20,24 +15,8 @@ export default function ClientLayout ({
|
||||||
}, [])
|
}, [])
|
||||||
return (
|
return (
|
||||||
<div className={'container'}>
|
<div className={'container'}>
|
||||||
<Head>
|
<h1>{title}</h1>
|
||||||
<title>{title}</title>
|
{children}
|
||||||
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
|
import Link from 'next/link'
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
|
|
||||||
export default function Footer (): ReactElement {
|
export default function Footer (): ReactElement {
|
||||||
return (
|
return (
|
||||||
<footer className={'text-center mt-5'}>
|
<footer className={'text-center mt-5'}>
|
||||||
<p><a href={'/optout'}>How to opt-out</a></p>
|
<p><Link href={'/optout'}>How to opt-out</Link></p>
|
||||||
<p>©{(new Date()).getFullYear()} <a href={'https://skorpil.cz'}>Štěpán Škorpil</a></p>
|
<p>©{(new Date()).getFullYear()} <a href={'https://skorpil.cz'}>Štěpán Škorpil</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
|
||||||
|
export default function ({ title, description }: {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}): ReactElement {
|
||||||
|
const pageName = 'FediSearch'
|
||||||
|
const htmlTitle = (title !== undefined ? `${title} | ` : '') + pageName
|
||||||
|
description = description ?? 'Search on Fediverse'
|
||||||
|
return <>
|
||||||
|
<title>{htmlTitle}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<meta name="description" content={description}/>
|
||||||
|
<meta property="og:image" content="/fedisearch.png"/>
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
|
<meta property="og:title" content={title ?? pageName}/>
|
||||||
|
<meta property="og:description" content={description}/>
|
||||||
|
<link rel="icon" href="/fedisearch.png"/>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -6,10 +6,11 @@ import SearchInput from '../form/SearchInput'
|
||||||
import SubmitButton from '../form/SubmitButton'
|
import SubmitButton from '../form/SubmitButton'
|
||||||
|
|
||||||
export default function NodeForm (
|
export default function NodeForm (
|
||||||
{ onSubmit, onQueryChange, query }: {
|
{ onSubmit, onQueryChange, query, loading }: {
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onQueryChange: (query: NodeQueryInput) => void
|
onQueryChange: (query: NodeQueryInput) => void
|
||||||
query: NodeQueryInput
|
query: NodeQueryInput
|
||||||
|
loading?: boolean
|
||||||
}
|
}
|
||||||
): ReactElement {
|
): ReactElement {
|
||||||
const handleQueryChange = (event): void => {
|
const handleQueryChange = (event): void => {
|
||||||
|
@ -42,6 +43,7 @@ export default function NodeForm (
|
||||||
label={'Search'}
|
label={'Search'}
|
||||||
faIcon={faSearch}
|
faIcon={faSearch}
|
||||||
id={'search-nodes-button'}
|
id={'search-nodes-button'}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -1,23 +1,30 @@
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import SoftwareBadgePlaceholder from '../SoftwareBadgePlaceholder'
|
import SoftwareBadgePlaceholder from '../SoftwareBadgePlaceholder'
|
||||||
|
|
||||||
export default function NodePlaceholder (): ReactElement {
|
const Row = (): ReactElement => <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>
|
||||||
|
|
||||||
|
export default function NodePlaceholder ({ rowCount }: { rowCount?: number }): ReactElement {
|
||||||
|
if (rowCount === undefined || rowCount <= 0) {
|
||||||
|
rowCount = 1
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<tbody>
|
<tbody className={'placeholder-wrapper'} aria-hidden="true">
|
||||||
<tr>
|
{[...Array(rowCount).keys()].map(key => {
|
||||||
<td className={'placeholder-glow'}><span className={'placeholder col-10'}/></td>
|
return <Row key={key}/>
|
||||||
<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>
|
</tbody>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useSearchParams } from 'next/navigation'
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
|
@ -38,7 +38,7 @@ export default function NodeSearch (): ReactElement {
|
||||||
const matomo = useMatomo()
|
const matomo = useMatomo()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const [lastRowCount, setLastRowCount] = useState<number>(1)
|
||||||
let routerQuery: NodeQueryInput
|
let routerQuery: NodeQueryInput
|
||||||
try {
|
try {
|
||||||
routerQuery = nodeQueryInputSchema.parse(Object.fromEntries(searchParams))
|
routerQuery = nodeQueryInputSchema.parse(Object.fromEntries(searchParams))
|
||||||
|
@ -49,10 +49,10 @@ export default function NodeSearch (): ReactElement {
|
||||||
sortWay: SortingWayEnum.Desc
|
sortWay: SortingWayEnum.Desc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('Router query', routerQuery)
|
|
||||||
const [query, setQuery] = useState<NodeQueryInput>(routerQuery)
|
const [query, setQuery] = useState<NodeQueryInput>(routerQuery)
|
||||||
const [page, setPage] = useState<number>(0)
|
const [page, setPage] = useState<number>(0)
|
||||||
const [pageLoading, setPageLoading] = useState<boolean>(false)
|
const [pageLoading, setPageLoading] = useState<undefined | 'sort' | 'submit' | 'more'>(undefined)
|
||||||
|
|
||||||
const { loading, error, data, fetchMore, refetch } = useQuery(ListNodesDocument, {
|
const { loading, error, data, fetchMore, refetch } = useQuery(ListNodesDocument, {
|
||||||
variables: {
|
variables: {
|
||||||
query,
|
query,
|
||||||
|
@ -62,6 +62,14 @@ export default function NodeSearch (): ReactElement {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const items = data?.listNodes?.items
|
||||||
|
if (items === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLastRowCount(items.length)
|
||||||
|
}, [data])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
matomo.trackEvent({
|
matomo.trackEvent({
|
||||||
category: 'nodes',
|
category: 'nodes',
|
||||||
|
@ -75,7 +83,7 @@ export default function NodeSearch (): ReactElement {
|
||||||
})
|
})
|
||||||
}, [page])
|
}, [page])
|
||||||
useEffect((): void => {
|
useEffect((): void => {
|
||||||
router.push(`${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
|
window.history.replaceState({}, '', `${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
|
||||||
matomo.trackEvent({
|
matomo.trackEvent({
|
||||||
category: 'nodes',
|
category: 'nodes',
|
||||||
action: 'new-search'
|
action: 'new-search'
|
||||||
|
@ -89,17 +97,17 @@ export default function NodeSearch (): ReactElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchSubmit = async (): Promise<void> => {
|
const handleSearchSubmit = async (): Promise<void> => {
|
||||||
setPageLoading(true)
|
setPageLoading('submit')
|
||||||
setQuery(query)
|
setQuery(query)
|
||||||
setPage(0)
|
setPage(0)
|
||||||
await refetch({ paging: { page: 0 } })
|
await refetch({ paging: { page: 0 } })
|
||||||
setPageLoading(false)
|
setPageLoading(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLoadMore = async (): Promise<void> => {
|
const handleLoadMore = async (): Promise<void> => {
|
||||||
setPage(page + 1)
|
setPage(page + 1)
|
||||||
console.info('Loading next page', { query, page })
|
console.info('Loading next page', { query, page })
|
||||||
setPageLoading(true)
|
setPageLoading('more')
|
||||||
await fetchMore({
|
await fetchMore({
|
||||||
variables: {
|
variables: {
|
||||||
paging: { page: page + 1 }
|
paging: { page: page + 1 }
|
||||||
|
@ -121,7 +129,7 @@ export default function NodeSearch (): ReactElement {
|
||||||
return fetchMoreResult
|
return fetchMoreResult
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setPageLoading(false)
|
setPageLoading(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleSort = (sortBy: NodeSortingByEnum): void => {
|
const toggleSort = (sortBy: NodeSortingByEnum): void => {
|
||||||
|
@ -145,15 +153,18 @@ export default function NodeSearch (): ReactElement {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NodeForm query={query} onQueryChange={handleQueryChange} onSubmit={handleSearchSubmit}/>
|
<NodeForm query={query} onQueryChange={handleQueryChange} onSubmit={handleSearchSubmit} loading={loading || pageLoading !== undefined}/>
|
||||||
<ResponsiveTable>
|
<ResponsiveTable>
|
||||||
<NodeHeader onSortToggle={toggleSort} query={query}/>
|
<NodeHeader onSortToggle={toggleSort} query={query}/>
|
||||||
<Loader loading={loading || pageLoading} showBottom={true} placeholder={(<NodePlaceholder/>)}>
|
<Loader
|
||||||
|
loading={loading || pageLoading !== undefined}
|
||||||
|
showBottom={true}
|
||||||
|
placeholder={(<NodePlaceholder rowCount={pageLoading === 'more' ? 1 : lastRowCount}/>)}>
|
||||||
<NodeResults nodes={data?.listNodes?.items}/>
|
<NodeResults nodes={data?.listNodes?.items}/>
|
||||||
</Loader>
|
</Loader>
|
||||||
</ResponsiveTable>
|
</ResponsiveTable>
|
||||||
<LoadMoreButton onClick={handleLoadMore}
|
<LoadMoreButton onClick={handleLoadMore}
|
||||||
show={!loading && !pageLoading && data?.listNodes?.paging?.hasNext === true}/>
|
show={!loading && pageLoading === undefined && data?.listNodes?.paging?.hasNext === true}/>
|
||||||
<ErrorMessage message={error?.message}/>
|
<ErrorMessage message={error?.message}/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,25 +1,21 @@
|
||||||
import React, { ReactElement, ReactNode } from 'react'
|
import React, { ReactElement, ReactNode } from 'react'
|
||||||
import ClientConfig from '../../config/ClientConfig'
|
import ClientConfig from '../../config/ClientConfig'
|
||||||
import 'server-only'
|
import 'server-only'
|
||||||
import '../../styles/global.scss'
|
|
||||||
import ClientLayout from '../layout/ClientLayout'
|
import ClientLayout from '../layout/ClientLayout'
|
||||||
import ClientProviders from '../layout/ClientProviders'
|
import ClientProviders from '../layout/ClientProviders'
|
||||||
|
|
||||||
export default function Layout ({
|
export default function Layout ({
|
||||||
children,
|
children,
|
||||||
config,
|
config,
|
||||||
title,
|
title
|
||||||
description
|
|
||||||
}: {
|
}: {
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
config: ClientConfig
|
config: ClientConfig
|
||||||
title: string
|
title: string
|
||||||
description: string
|
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
console.log('Layout')
|
|
||||||
return (
|
return (
|
||||||
<ClientProviders config={config}>
|
<ClientProviders config={config}>
|
||||||
<ClientLayout title={title} description={description}>
|
<ClientLayout title={title}>
|
||||||
{children}
|
{children}
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
</ClientProviders>
|
</ClientProviders>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useSearchParams } from 'next/navigation'
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ListStatsDocument,
|
ListStatsDocument,
|
||||||
|
@ -30,7 +30,6 @@ export default function Stats (): ReactElement {
|
||||||
})
|
})
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
|
||||||
const matomo = useMatomo()
|
const matomo = useMatomo()
|
||||||
let routerQuery: StatsQueryInput
|
let routerQuery: StatsQueryInput
|
||||||
try {
|
try {
|
||||||
|
@ -42,7 +41,6 @@ export default function Stats (): ReactElement {
|
||||||
sortWay: SortingWayEnum.Desc
|
sortWay: SortingWayEnum.Desc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('Router query', routerQuery)
|
|
||||||
const [query, setQuery] = useState<StatsQueryInput>(routerQuery)
|
const [query, setQuery] = useState<StatsQueryInput>(routerQuery)
|
||||||
const { loading, error, data } = useQuery(ListStatsDocument, {
|
const { loading, error, data } = useQuery(ListStatsDocument, {
|
||||||
variables: {
|
variables: {
|
||||||
|
@ -59,7 +57,7 @@ export default function Stats (): ReactElement {
|
||||||
setLastSum(sum)
|
setLastSum(sum)
|
||||||
}, [data])
|
}, [data])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
router.push(`${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
|
window.history.replaceState({}, '', `${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
|
||||||
matomo.trackEvent({
|
matomo.trackEvent({
|
||||||
category: 'stats',
|
category: 'stats',
|
||||||
action: 'new-search'
|
action: 'new-search'
|
||||||
|
|
|
@ -24,8 +24,11 @@ const Row = (): ReactElement => <tr>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
export default function StatsPlaceholder ({ rowCount }: { rowCount?: number }): ReactElement {
|
export default function StatsPlaceholder ({ rowCount }: { rowCount?: number }): ReactElement {
|
||||||
return <tbody>
|
if (rowCount === undefined || rowCount <= 0) {
|
||||||
{[...Array(rowCount ?? 1).keys()].map(key => {
|
rowCount = 1
|
||||||
|
}
|
||||||
|
return <tbody className="placeholder-wrapper" aria-hidden="true">
|
||||||
|
{[...Array(rowCount).keys()].map(key => {
|
||||||
return <Row key={key}/>
|
return <Row key={key}/>
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -137,4 +137,6 @@ table.stats {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.placeholder-wrapper{
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue