Merge pull request #3 from Team-1280/webworkers

Use webworkers to asynchronously fetch data while triggering react suspense
This commit is contained in:
Youwen Wu 2024-02-12 21:35:17 -08:00 committed by GitHub
commit a63a5f3a63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 967 additions and 464 deletions

View file

@ -27,3 +27,17 @@ The dummies guide to maintaining a Next.js project:
- Important: THIS IS NOT JAVASCRIPT! You CANNOT use global variables, window variables, etc, or even stateful variables that are meant to persist beyond a single refresh cycle (which can happen many times per second). Use the STATE for this (see [the module we are using for state management](https://github.com/pmndrs/zustand)) - Important: THIS IS NOT JAVASCRIPT! You CANNOT use global variables, window variables, etc, or even stateful variables that are meant to persist beyond a single refresh cycle (which can happen many times per second). Use the STATE for this (see [the module we are using for state management](https://github.com/pmndrs/zustand))
- Try to define modules for your components instead of putting everything in one file to avoid JANK - Try to define modules for your components instead of putting everything in one file to avoid JANK
- You should put all static assets like images, icons, sound bytes, etc, in the `public` folder. These assets will be made available at the root directory `/`. For example, if `eecs-wordmark.png` is in `public`, then I can access it from an image element like so: `<img src="/eecs-wordmark.png" />`. - You should put all static assets like images, icons, sound bytes, etc, in the `public` folder. These assets will be made available at the root directory `/`. For example, if `eecs-wordmark.png` is in `public`, then I can access it from an image element like so: `<img src="/eecs-wordmark.png" />`.
- VERY IMPORTANT for performance and UI jank purposes:
- As you may have noticed, we store all of our data in a super large TypeScript file at `db/data.ts`. This module contains exports for all of our 5 main "databases."
- In order to access these databases from your components, there are two **critical** conventions to follow:
- If your component is **server side**, then simply import the components like normal (eg `import { documents } from '@/app/db/data`) and use them in your code.
- If your component is **client side**, then you should import the data using the utilities available in `db/loaders.ts`. The `loaders` library contains
functions which return a Promise that resolve to the requested data. The function will load the very large objects in a separate thread via a Web Worker, which
avoids blocking the main UI thread and freezing everything. You can then use the `useSuspenseQuery` hook from `@tanstack/react-query` to load this data in the background while
triggering a React Suspense (if you don't set one up yourself, the default site wide loading bar will be used). This helps vastly reduce UI jank when trying to load the entire
mega data object directly into memory.
- Not sure whether you're in a client or server side component? If your component has the `'use client'` directive at the top of its file, then it's a client side component.
Otherwise, by default, it should be a server side component.
- Footnote: why don't I have to use the utilities in `db/loaders.ts` to asynchronously load the data in server side components?
- Next.js will automatically pre-render all server side components into static HTML, which means there will be no performance impact (and in fact performance loss at build time)
to loading the entire objects into memory.

25
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "eexiv-2", "name": "eexiv-2",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.20.2",
"minisearch": "^6.3.0", "minisearch": "^6.3.0",
"next": "14.1.0", "next": "14.1.0",
"react": "^18", "react": "^18",
@ -466,6 +467,30 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.20.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.20.2.tgz",
"integrity": "sha512-sAILwNiyA1I52e6imOsmNDUA/PuOayOzqz5jcLiIB5wBXqVk+HIiriWouPcAkjS8RqARfHUehuoPwcZ7Uzh0GQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.20.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.20.2.tgz",
"integrity": "sha512-949myvMY77cPqwb71m3wRG2ypgwPijshO5kN9w0CDKWrFC0X8Wh1mwSqst88kIr58tWlWNsGy3U40AK23RgYQA==",
"dependencies": {
"@tanstack/query-core": "5.20.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18.0.0"
}
},
"node_modules/@types/file-saver": { "node_modules/@types/file-saver": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",

View file

@ -10,6 +10,7 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.20.2",
"minisearch": "^6.3.0", "minisearch": "^6.3.0",
"next": "14.1.0", "next": "14.1.0",
"react": "^18", "react": "^18",

View file

@ -0,0 +1,175 @@
import Link from 'next/link'
import { Fragment } from 'react'
import { affiliations, nationalities, authors } from '../../db/data'
import { Zilla_Slab } from 'next/font/google'
import { notFound } from 'next/navigation'
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500'] })
export default function AuthorDisplay({
author,
}: Readonly<{ author: string }>) {
const data = authors[author]
if (!data) {
notFound()
}
const { name, affiliation, image, nationality, formerAffiliations } = data
const MainPosition = () => {
const mainAffiliationShort = affiliation[0].split('@')[1]
const mainPosition = affiliation[0].split('@')[0]
const mainAffiliation = affiliations[mainAffiliationShort]
const { website } = data
return (
<>
<span>{mainPosition} at </span>
<Link href={`/affiliation/${mainAffiliationShort}`}>
{mainAffiliation.name}
</Link>
{website ? (
<div className='mt-2'>
Visit {name.nickname ? name.nickname : name.first} at:{' '}
<a href={website} target='_blank'>
{website}
</a>
</div>
) : null}
<div className='my-4 max-h-12 flex flex-wrap'>
{affiliation.map((a: string) => (
<Link key={a} href={`/affiliation/${a.split('@')[1]}`}>
<img
src={affiliations[a.split('@')[1]].image}
alt={affiliations[a.split('@')[1]].name}
className='h-12 mr-2'
/>
</Link>
))}
</div>
</>
)
}
const OtherPositions = () => {
if (affiliation.length === 1) return
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>
Other Positions:
</h1>
{affiliation.slice(1).map((a: string, i: number) => {
const position = a.split('@')[0]
const affiliation = affiliations[a.split('@')[1]].name
return (
<Fragment key={`${position}@${affiliation}`}>
<span className='text-slate-500 text-lg'>
{position} at{' '}
<Link href={`/affiliation/${a.split('@')[1]}`}>
{affiliation}
</Link>
</span>
</Fragment>
)
})}
</>
)
}
const FormerPositions = () => {
if (!formerAffiliations) return null
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>
Former Positions:
</h1>
{formerAffiliations?.map((a: string, i: number) => {
const position = a.split('@')[0]
const affiliation = affiliations[a.split('@')[1]].name
return (
<Fragment key={`${position}@${affiliation}`}>
<span className='text-slate-500 text-lg'>
{position} at{' '}
<Link href={`/affiliation/${a.split('@')[1]}`}>
{affiliation}
</Link>
</span>
</Fragment>
)
})}
</>
)
}
const NationalityDisplay = ({
nationality,
}: Readonly<{ nationality: string }>) => {
const nationalityData = nationalities[nationality]
const { demonym, flag } = nationalityData
return (
<div className='flex items-center'>
<img
src={flag}
className='w-10 border-2 border-slate-200'
alt={`${demonym} flag`}
/>
<span className='mx-3 font-semibold'>{demonym}</span>
</div>
)
}
const Bio = () => {
const { bio } = data
if (!bio) return null
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>Bio:</h1>
<p>{bio}</p>
</>
)
}
return (
<div>
<div className='grid grid-cols-1 md:grid-cols-2 items-center max-w-3xl mx-auto'>
<div className='aspect-square w-[60vw] md:w-[30vw] lg:w-[20vw] 2xl:w-[15vw] overflow-hidden mx-auto mb-4'>
<img
alt='profile'
className='rounded-full mx-auto object-cover w-full h-full border-slate-800 border-4'
src={image}
/>
</div>
<div>
<div>
<span
className={`${zillaSlab.className} font-bold text-5xl text-center md:text-left`}
>
{name.first}
{name.nickname ? ` "${name.nickname}"` : null} {name.last}
</span>
<div className='text-slate-600 text-md sm:text-lg mt-4'>
<MainPosition />
</div>
</div>
</div>
</div>
<div className='max-w-3xl mx-auto grid grid-cols-1 justify-center'>
<hr className='mx-auto w-full h-1 border-0 bg-slate-200 my-2 rounded-md' />
<OtherPositions />
<FormerPositions />
<h1 className='text-3xl md:my-6 my-4 font-serif'>
Ethnicity and Nationality:
</h1>
<div className='flex gap-2 flex-wrap'>
{nationality.map((n: string) => (
<Fragment key={n}>
<NationalityDisplay nationality={n} />
</Fragment>
))}
</div>
<Bio />
</div>
</div>
)
}

View file

@ -1,10 +1,5 @@
import { authors, affiliations, nationalities } from '../../db/data' import AuthorDisplay from './AuthorDisplay'
import { Zilla_Slab } from 'next/font/google' import { authors } from '@/app/db/data'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Fragment, Suspense } from 'react'
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500'] })
export function generateStaticParams() { export function generateStaticParams() {
const authorsList = Object.keys(authors) const authorsList = Object.keys(authors)
@ -16,172 +11,5 @@ export default function Page({
}: Readonly<{ }: Readonly<{
params: { author: string } params: { author: string }
}>) { }>) {
const authorData = authors[params.author] return <AuthorDisplay author={params.author} />
// console.log(authorData)
if (!authorData) {
notFound()
}
const { name, affiliation, image, nationality, formerAffiliations } =
authorData
const MainPosition = () => {
const mainAffiliationShort = affiliation[0].split('@')[1]
const mainPosition = affiliation[0].split('@')[0]
const mainAffiliation = affiliations[mainAffiliationShort]
return (
<>
<span>{mainPosition} at </span>
<Link href={`/affiliation/${mainAffiliationShort}`}>
{mainAffiliation.name}
</Link>
{authorData.website ? (
<div className='mt-2'>
Visit{' '}
{authorData.name.nickname
? authorData.name.nickname
: authorData.name.first}{' '}
at:{' '}
<a href={authorData.website} target='_blank'>
{authorData.website}
</a>
</div>
) : null}
<div className='my-4 max-h-12 flex flex-wrap'>
{affiliation.map((a: string) => (
<Link key={a} href={`/affiliation/${a.split('@')[1]}`}>
<img
src={affiliations[a.split('@')[1]].image}
alt={affiliations[a.split('@')[1]].name}
className='h-12 mr-2'
/>
</Link>
))}
</div>
</>
)
}
const OtherPositions = () => {
if (affiliation.length === 1) return
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>
Other Positions:
</h1>
{affiliation.slice(1).map((a: string, i: number) => {
const position = a.split('@')[0]
const affiliation = affiliations[a.split('@')[1]].name
return (
<Fragment key={`${position}@${affiliation}`}>
<span className='text-slate-500 text-lg'>
{position} at{' '}
<Link href={`/affiliation/${a.split('@')[1]}`}>
{affiliation}
</Link>
</span>
</Fragment>
)
})}
</>
)
}
const FormerPositions = () => {
if (!formerAffiliations) return null
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>
Former Positions:
</h1>
{formerAffiliations?.map((a: string, i: number) => {
const position = a.split('@')[0]
const affiliation = affiliations[a.split('@')[1]].name
return (
<Fragment key={`${position}@${affiliation}`}>
<span className='text-slate-500 text-lg'>
{position} at{' '}
<Link href={`/affiliation/${a.split('@')[1]}`}>
{affiliation}
</Link>
</span>
</Fragment>
)
})}
</>
)
}
const NationalityDisplay = ({
nationality,
}: Readonly<{ nationality: string }>) => {
const nationalityData = nationalities[nationality]
const { demonym, flag } = nationalityData
return (
<div className='flex items-center'>
<img
src={flag}
className='w-10 border-2 border-slate-200'
alt={`${demonym} flag`}
/>
<span className='mx-3 font-semibold'>{demonym}</span>
</div>
)
}
const Bio = () => {
const { bio } = authorData
if (!bio) return null
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>Bio:</h1>
<p>{authorData.bio}</p>
</>
)
}
return (
<div>
<div className='grid grid-cols-1 md:grid-cols-2 items-center max-w-3xl mx-auto'>
<div className='aspect-square w-[60vw] md:w-[30vw] lg:w-[20vw] 2xl:w-[15vw] overflow-hidden mx-auto mb-4'>
<img
alt='profile'
className='rounded-full mx-auto object-cover w-full h-full border-slate-800 border-4'
src={image}
/>
</div>
<div>
<div>
<span
className={`${zillaSlab.className} font-bold text-5xl text-center md:text-left`}
>
{name.first}
{name.nickname ? ` "${name.nickname}"` : null} {name.last}
</span>
<div className='text-slate-600 text-md sm:text-lg mt-4'>
<MainPosition />
</div>
</div>
</div>
</div>
<div className='max-w-3xl mx-auto grid grid-cols-1 justify-center'>
<hr className='mx-auto w-full h-1 border-0 bg-slate-200 my-2 rounded-md' />
<OtherPositions />
<FormerPositions />
<h1 className='text-3xl md:my-6 my-4 font-serif'>
Ethnicity and Nationality:
</h1>
<div className='flex gap-2 flex-wrap'>
{nationality.map((n: string) => (
<Fragment key={n}>
<NationalityDisplay nationality={n} />
</Fragment>
))}
</div>
<Bio />
</div>
</div>
)
} }

View file

@ -1,10 +1,8 @@
import { Fragment } from 'react' import { Fragment } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { import { reviewer } from '@/app/db/data'
topics as topicList, import { loadAllTopics, loadAllAuthors } from '@/app/db/loaders'
authors as authorList, import { useSuspenseQuery } from '@tanstack/react-query'
reviewer,
} from '@/app/db/data'
export const Code = ({ export const Code = ({
code, code,
@ -54,6 +52,18 @@ export const Topics = ({
topics, topics,
showTitle = true, showTitle = true,
}: Readonly<{ topics: string[]; showTitle?: boolean }>) => { }: Readonly<{ topics: string[]; showTitle?: boolean }>) => {
'use client'
const { data, error } = useSuspenseQuery({
queryKey: ['topics_all'],
queryFn: () => {
const data = loadAllTopics()
return data
},
})
if (error) throw error
const topicList = data
return ( return (
<> <>
{showTitle ? <span className='font-bold'>Topics: </span> : null} {showTitle ? <span className='font-bold'>Topics: </span> : null}
@ -72,6 +82,19 @@ export const Topics = ({
export const Authors = ({ export const Authors = ({
authors, authors,
}: Readonly<{ authors: string[]; noLink?: boolean }>) => { }: Readonly<{ authors: string[]; noLink?: boolean }>) => {
'use client'
const { data, error } = useSuspenseQuery({
queryKey: ['authors_all'],
queryFn: () => {
const data = loadAllAuthors()
return data
},
})
if (error) throw error
const authorList = data
return ( return (
<> <>
{authors.map((a: string, i) => ( {authors.map((a: string, i) => (
@ -95,20 +118,16 @@ export const Reviewers = ({
const ReviewerDisplay = ({ r }: Readonly<{ r: reviewer }>) => { const ReviewerDisplay = ({ r }: Readonly<{ r: reviewer }>) => {
if (r.profile) { if (r.profile) {
return ( return (
<>
<Link href={`/author/${r.profile}`} target='_blank'> <Link href={`/author/${r.profile}`} target='_blank'>
{r.first} {r.last} {r.first} {r.last}
</Link> </Link>
</>
) )
} }
if (r.url) { if (r.url) {
return ( return (
<>
<a href={r.url} target='_blank'> <a href={r.url} target='_blank'>
{r.first} {r.last} {r.first} {r.last}
</a> </a>
</>
) )
} }
return ( return (

View file

@ -13,13 +13,15 @@ export default function News() {
<br className='my-4' /> <br className='my-4' />
<ul className='text-slate-50 px-6 list-disc'> <ul className='text-slate-50 px-6 list-disc'>
<li key={1}> <li key={1}>
eeXiv 1.0 has been released! All basic features like search and eeXiv 2.0 has been released! The site should feel significantly more
document viewing are available. responsive. Data cacheing has also been implemented so search results
and documents will load instantly the second time.
</li> </li>
<li key={2}>eeXiv is currently under active development!</li> <li key={2}>Mobile support is currently in beta.</li>
<li key={3}> <li key={3}>
There may be major updates, breaking changes, or weird bugs. Report eeXiv is currently under active development! There may be major
bugs, suggest new features, or give us feedback at{' '} updates, breaking changes, or weird bugs. Report bugs, suggest new
features, or give us feedback at{' '}
<a href='https://github.com/team-1280/eexiv-2/issues' target='_blank'> <a href='https://github.com/team-1280/eexiv-2/issues' target='_blank'>
our issue tracker. our issue tracker.
</a> </a>

View file

@ -17,7 +17,6 @@ export default function SearchBar() {
const setSearchBarStore = useSearchBarStore((state) => state.setSearchInput) const setSearchBarStore = useSearchBarStore((state) => state.setSearchInput)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
navigate(`/search?q=${searchBarStore.split(' ').join('+')}`) navigate(`/search?q=${searchBarStore.split(' ').join('+')}`)
} }
@ -27,7 +26,6 @@ export default function SearchBar() {
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault()
navigate(`/search?q=${searchBarStore.split(' ').join('+')}`) navigate(`/search?q=${searchBarStore.split(' ').join('+')}`)
} }
} }

View file

@ -282,14 +282,12 @@ On Day 1 we will discuss the ins and outs of the robot. The Electrical Sub team
}, },
} }
export interface Topics { export interface Topic {
[key: string]: {
name: string name: string
description: string description: string
wiki: string wiki: string
} }
} export const topics: { [key: string]: Topic } = {
export const topics: Topics = {
frc: { frc: {
name: 'FIRST Robotics Competition', name: 'FIRST Robotics Competition',
description: description:
@ -346,8 +344,7 @@ authorName (as a slug): {
website: website url website: website url
} }
*/ */
export interface Authors { export interface Author {
[key: string]: {
name: { name: {
first: string first: string
last: string last: string
@ -360,8 +357,7 @@ export interface Authors {
bio?: string bio?: string
website?: string website?: string
} }
} export const authors: { [key: string]: Author } = {
export const authors: Authors = {
shasan: { shasan: {
name: { name: {
first: 'Saim', first: 'Saim',
@ -501,16 +497,14 @@ export const authors: Authors = {
}, },
} }
export interface Affiliations { export interface Affiliation {
[key: string]: {
name: string name: string
short: string short: string
image: string image: string
description: string description: string
} }
}
export const affiliations: Affiliations = { export const affiliations: { [key: string]: Affiliation } = {
'1280-mech': { '1280-mech': {
name: "Team 1280, the Ragin' C Biscuits, Mechanical Subteam", name: "Team 1280, the Ragin' C Biscuits, Mechanical Subteam",
short: '1280 Mech', short: '1280 Mech',
@ -661,14 +655,12 @@ Raid Zero's influence extends beyond the technical achievements in robotics comp
}, },
} }
export interface Nationalities { export interface Nationality {
[key: string]: {
name: string name: string
demonym: string demonym: string
flag: string flag: string
} }
} export const nationalities: { [key: string]: Nationality } = {
export const nationalities: Nationalities = {
pak: { pak: {
name: 'Pakistan', name: 'Pakistan',
demonym: 'Pakistani', demonym: 'Pakistani',

330
src/app/db/loaders.ts Normal file
View file

@ -0,0 +1,330 @@
import { Document, Author, Affiliation, Topic, Nationality } from './data'
export const loadDocument = (id: string): Promise<Document> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/documentLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Document }>) => {
const data = e.data
const doc: Document | undefined = data[id]
if (!doc) {
return reject(new Error('404'))
} else {
resolve(doc)
}
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
'Web Workers are not supported in this environment. Please avoid using a prehistoric browser.'
)
)
}
})
}
export const loadAllDocuments = (): Promise<{ [key: string]: Document }> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/documentLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Document }>) => {
resolve(e.data)
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
'Web Workers are not supported in this environment. Please avoid using a prehistoric browser.'
)
)
}
})
}
export const loadAllAuthors = (): Promise<{ [key: string]: Author }> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/authorLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Author }>) => {
resolve(e.data)
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
'Web Workers are not supported in this environment. Please avoid using a prehistoric browser.'
)
)
}
})
}
export const loadAuthor = (id: string): Promise<Author> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/authorLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Author }>) => {
const data = e.data
const author: Author | undefined = data[id]
if (!author) {
return reject(new Error('404'))
} else {
resolve(author)
}
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
`Web Workers are not supported in this environment. Please avoid using a prehistoric browser.
If nothing else seems wrong, this error message is probably showing up due to ghosts in your browser.`
)
)
}
})
}
export const loadAffiliation = (id: string): Promise<Affiliation> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/affiliationLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Affiliation }>) => {
const data = e.data
const affiliation: Affiliation | undefined = data[id]
if (!affiliation) {
return reject(new Error('404'))
} else {
resolve(affiliation)
}
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
`Web Workers are not supported in this environment. Please avoid using a prehistoric browser.
If nothing else seems wrong, this error message is probably showing up due to ghosts in your browser.`
)
)
}
})
}
export const loadAllAffiliations = (): Promise<{
[key: string]: Affiliation
}> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/affiliationLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Affiliation }>) => {
resolve(e.data)
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
'Web Workers are not supported in this environment. Please avoid using a prehistoric browser.'
)
)
}
})
}
export const loadTopic = (id: string): Promise<Topic> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/topicLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Topic }>) => {
const data = e.data
const topic: Topic | undefined = data[id]
if (!topic) {
return reject(new Error('404'))
} else {
resolve(topic)
}
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
`Web Workers are not supported in this environment. Please avoid using a prehistoric browser.
If nothing else seems wrong, this error message is probably showing up due to ghosts in your browser.`
)
)
}
})
}
export const loadAllTopics = (): Promise<{
[key: string]: Topic
}> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/topicLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Topic }>) => {
resolve(e.data)
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
'Web Workers are not supported in this environment. Please avoid using a prehistoric browser.'
)
)
}
})
}
export const loadNationality = (id: string): Promise<Nationality> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/nationalityLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Nationality }>) => {
const data = e.data
const nationality: Nationality | undefined = data[id]
if (!nationality) {
return reject(new Error('404'))
} else {
resolve(nationality)
}
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
`Web Workers are not supported in this environment. Please avoid using a prehistoric browser.
If nothing else seems wrong, this error message is probably showing up due to ghosts in your browser.`
)
)
}
})
}
export const loadAllNationalities = (): Promise<{
[key: string]: Nationality
}> => {
return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./workers/topicLoader.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<{ [key: string]: Nationality }>) => {
resolve(e.data)
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage('LOAD')
} else {
reject(
new Error(
'Web Workers are not supported in this environment. Please avoid using a prehistoric browser.'
)
)
}
})
}

View file

@ -0,0 +1,2 @@
These worker files should not be touched unless you know what you are doing! They're simply utilities to be imported
by the loaders library to launch asynchronous web worker threads.

View file

@ -0,0 +1,7 @@
import { affiliations } from '../data'
onmessage = (e) => {
if (e.data === 'LOAD') {
self.postMessage(affiliations)
}
}

View file

@ -0,0 +1,7 @@
import { authors } from '../data'
onmessage = (e) => {
if (e.data === 'LOAD') {
self.postMessage(authors)
}
}

View file

@ -0,0 +1,7 @@
import { documents } from '../data'
onmessage = (e) => {
if (e.data === 'LOAD') {
self.postMessage(documents)
}
}

View file

@ -0,0 +1,7 @@
import { nationalities } from '../data'
onmessage = (e) => {
if (e.data === 'LOAD') {
self.postMessage(nationalities)
}
}

View file

@ -0,0 +1,7 @@
import { topics } from '../data'
onmessage = (e) => {
if (e.data === 'LOAD') {
self.postMessage(topics)
}
}

View file

@ -0,0 +1,110 @@
import { DocumentType } from '@/app/db/data'
import { Zilla_Slab } from 'next/font/google'
import { epoch2datestring } from '@/app/utils/epoch2datestring'
import {
Code,
References,
Topics,
Authors,
Reviewers,
} from '@/app/components/DataDisplay'
import { ItemBadge, Status } from '@/app/components/Badges'
import Link from 'next/link'
import { useSuspenseQuery } from '@tanstack/react-query'
import { loadDocument } from '@/app/db/loaders'
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500', '700'] })
export default function DocumentViewer({ slug }: Readonly<{ slug: string }>) {
const { data, error } = useSuspenseQuery({
queryKey: [slug],
queryFn: () => {
const data = loadDocument(slug)
return data
},
})
const { manifest, abstract, file, citation } = data
const {
title,
authors,
topics,
dates,
references,
code,
type,
latest,
reviewers,
status,
} = manifest
if (error) throw error
return (
<div className='max-w-4xl lg:max-w-6xl mx-auto'>
<h1
className={`
text-slate-800 font-bold text-5xl mb-4
${zillaSlab.className}
text-wrap
`}
>
{title}
</h1>
<p className={`text-slate-800 mt-2`}>
<Authors authors={authors} />
</p>
<p className='mt-4 mb-2'>
Latest revision published{' '}
<span className='font-bold'>
{epoch2datestring(dates[dates.length - 1])}
</span>
</p>
<ItemBadge itemName={type as DocumentType} />
<Status statusName={status} />
<span className='inline-block border-gray-200 border-2 rounded px-2 py-1.5 mr-2'>
Revision {latest}
</span>
<hr className='my-4' />
<h4 className='text-2xl mt-5 font-serif font-semibold'>Abstract</h4>
<p className='my-4 text-xl text-slate-600 font-serif text-balance'>
{abstract}
</p>
<p className='my-2'>
<Topics topics={topics} />
</p>
<p className='my-2'>
<Code code={code} />
</p>
<p className='my-2'>
<References references={references} />
</p>
<p className='my-2'>
<Reviewers reviewers={reviewers} />
</p>
<p className='my-2'>
<span className='font-bold'>Cite as: </span>
{citation ? <>{citation}</> : <>eeXiv:{slug}</>}
</p>
<Link
href={`/download/${slug}/file${latest}${file === 'other' ? '' : `.${file}`}`}
download={`${slug}-rev-${latest}${file === 'other' ? '' : `.${file}`}`}
target='_blank'
>
<button className='button-default'>
Download{' '}
{(() => {
switch (file) {
case 'other':
return <></>
case 'tar.gz':
return 'Tarball'
default:
return file.toUpperCase()
}
})()}
</button>
</Link>
</div>
)
}

View file

@ -1,117 +1,13 @@
'use client' 'use client'
import { Zilla_Slab } from 'next/font/google' import DocumentViewer from './DocumentViewer'
import { DocumentType, documents, reviewer } from '@/app/db/data' import ErrorBoundary from '@/app/utils/ErrorBoundary'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Fragment, useEffect } from 'react'
import { epoch2datestring } from '@/app/utils/epoch2datestring'
import { toast } from 'react-toastify'
import { ItemBadge, Status } from '@/app/components/Badges'
import {
Code,
References,
Topics,
Authors,
Reviewers,
} from '@/app/components/DataDisplay'
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500'] })
export default function Page({ export default function Page({
params, params,
}: Readonly<{ params: { slug: string } }>) { }: Readonly<{ params: { slug: string } }>) {
const doc = documents[params.slug]
if (!doc) {
notFound()
}
const { abstract, file, citation } = doc
const {
title,
authors,
topics,
dates,
references,
code,
type,
latest,
reviewers,
status,
} = doc.manifest
useEffect(() => {
if (status === 'reviewed' && !reviewers) {
toast.warn(
`This document is marked as peer reviewed, but the author
forgot to add a list of reviewers.`
)
}
}, [])
return ( return (
<div className='max-w-4xl lg:max-w-6xl mx-auto'> <ErrorBoundary>
<h1 <DocumentViewer slug={params.slug} />
className={` </ErrorBoundary>
text-slate-800 font-bold text-5xl mb-4
${zillaSlab.className}
text-wrap
`}
>
{title}
</h1>
<p className={`text-slate-800 mt-2`}>
<Authors authors={authors} />
</p>
<p className='mt-4 mb-2'>
Latest revision published{' '}
<span className='font-bold'>
{epoch2datestring(dates[dates.length - 1])}
</span>
</p>
<ItemBadge itemName={type as DocumentType} />
<Status statusName={status} />
<span className='inline-block border-gray-200 border-2 rounded px-2 py-1.5 mr-2'>
Revision {latest}
</span>
<hr className='my-4' />
<h4 className='text-2xl mt-5 font-serif font-semibold'>Abstract</h4>
<p className='my-4 text-xl text-slate-600 font-serif text-balance'>
{abstract}
</p>
<p className='my-2'>
<Topics topics={topics} />
</p>
<p className='my-2'>
<Code code={code} />
</p>
<p className='my-2'>
<References references={references} />
</p>
<p className='my-2'>
<Reviewers reviewers={reviewers} />
</p>
<p className='my-2'>
<span className='font-bold'>Cite as: </span>
{citation ? <>{citation}</> : <>eeXiv:{params.slug}</>}
</p>
<Link
href={`/download/${params.slug}/file${latest}${file === 'other' ? '' : `.${file}`}`}
download={`${params.slug}-rev-${latest}${file === 'other' ? '' : `.${file}`}`}
target='_blank'
>
<button className='button-default'>
Download{' '}
{(() => {
switch (file) {
case 'other':
return <></>
case 'tar.gz':
return 'Tarball'
default:
return file.toUpperCase()
}
})()}
</button>
</Link>
</div>
) )
} }

View file

@ -8,6 +8,7 @@ import Container from './container/Container'
import MobileMenu from './components/MobileMenu' import MobileMenu from './components/MobileMenu'
import { ToastContainer } from 'react-toastify' import { ToastContainer } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css' import 'react-toastify/dist/ReactToastify.css'
import Providers from './providers'
/* The default font is Inter. If you want to use Zilla Slab (or any other Google Font, /* The default font is Inter. If you want to use Zilla Slab (or any other Google Font,
which are pre-provided by Next.js in the 'next/font/google' module), you need to which are pre-provided by Next.js in the 'next/font/google' module), you need to
@ -31,6 +32,7 @@ export default function RootLayout({
return ( return (
<html lang='en'> <html lang='en'>
<body className={inter.className}> <body className={inter.className}>
<Providers>
<ToastContainer /> <ToastContainer />
<div className={styles.header}> <div className={styles.header}>
<div className='max-w-[1200px] flex flex-nowrap mx-auto justify-between items-center'> <div className='max-w-[1200px] flex flex-nowrap mx-auto justify-between items-center'>
@ -105,6 +107,7 @@ export default function RootLayout({
</ul> </ul>
</div> </div>
</footer> </footer>
</Providers>
</body> </body>
</html> </html>
) )

20
src/app/providers.jsx Normal file
View file

@ -0,0 +1,20 @@
'use client'
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function Providers({ children }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
)
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}

View file

@ -1,27 +1,15 @@
'use client' 'use client'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { Zilla_Slab } from 'next/font/google' import { Zilla_Slab } from 'next/font/google'
import Link from 'next/link'
import { Topics, Authors } from '@/app/components/DataDisplay' import { Topics, Authors } from '@/app/components/DataDisplay'
import { Status, ItemBadge } from '@/app/components/Badges' import { Status, ItemBadge } from '@/app/components/Badges'
import { epoch2datestring } from '../utils/epoch2datestring' import { epoch2datestring } from '../utils/epoch2datestring'
import searchDocs, { CustomSearchResult } from '@/app/utils/searchDocs' import searchDocs, { CustomSearchResult } from '@/app/utils/searchDocs'
import { useEffect } from 'react'
import { create } from 'zustand'
import { navigate } from '@/app/actions' import { navigate } from '@/app/actions'
import { useSuspenseQuery } from '@tanstack/react-query'
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500', '700'] }) const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500', '700'] })
interface SearchState {
results: CustomSearchResult[]
setResults: (newResults: CustomSearchResult[]) => void
}
const useSearchStore = create<SearchState>((set) => ({
results: [],
setResults: (newResults: CustomSearchResult[]) =>
set({ results: newResults }),
}))
const SearchResult = ({ result }: { result: CustomSearchResult }) => { const SearchResult = ({ result }: { result: CustomSearchResult }) => {
const { manifest, abstract, id } = result const { manifest, abstract, id } = result
const { title, authors, topics, dates, status, type } = manifest const { title, authors, topics, dates, status, type } = manifest
@ -66,11 +54,15 @@ const SearchResult = ({ result }: { result: CustomSearchResult }) => {
export default function Page() { export default function Page() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const search = searchParams.get('q') as string const search = searchParams.get('q') as string
const searchStore = useSearchStore((state) => state.results)
const setSearchStore = useSearchStore((state) => state.setResults) const { data, error } = useSuspenseQuery({
useEffect(() => { queryKey: [search],
setSearchStore(searchDocs(search)) queryFn: () => {
}, [search]) const data = searchDocs(search)
return data
},
})
if (error) throw error
return ( return (
<div className='max-w-4xl mx-auto'> <div className='max-w-4xl mx-auto'>
@ -80,12 +72,10 @@ export default function Page() {
{search} {search}
{`"`} {`"`}
</h1> </h1>
{searchStore.length === 0 ? ( {data.length === 0 ? (
<p className='text-lg px-2'>No results found.</p> <p className='text-lg px-2'>No results found.</p>
) : ( ) : (
searchStore.map((result) => ( data.map((result) => <SearchResult key={result.id} result={result} />)
<SearchResult key={result.id} result={result} />
))
)} )}
</div> </div>
) )

View file

@ -0,0 +1,29 @@
import React from 'react'
import { notFound } from 'next/navigation'
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI.
return { hasError: true, error }
}
componentDidCatch(error, errorInfo) {
// You can also log the error to an error reporting service
console.error('Error caught by Error Boundary:', error, errorInfo)
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
if (this.state.error.message === '404') notFound()
return <h1>Something went wrong.</h1>
}
return this.props.children
}
}

View file

@ -0,0 +1,34 @@
import { documents } from '@/app/db/data'
import MiniSearch from 'minisearch'
import { CustomSearchResult } from './searchDocs'
const docs = Object.entries(documents).map(([key, value]) => ({
id: key,
keywords: value.manifest.keywords.join(' '),
abstract: value.abstract,
topics: value.manifest.topics.join(' '),
authors: value.manifest.authors.join(' '),
title: value.manifest.title,
manifest: value.manifest,
type: value.manifest.type,
}))
const miniSearch = new MiniSearch({
fields: ['abstract', 'keywords', 'topics', 'authors', 'title', 'type'],
storeFields: ['key', 'abstract', 'manifest'],
searchOptions: {
boost: {
title: 2,
keywords: 2,
topics: 1,
authors: 2,
},
fuzzy: 0.2,
prefix: true,
},
})
miniSearch.addAll(docs)
onmessage = (e: MessageEvent<string>) => {
postMessage(miniSearch.search(e.data) as CustomSearchResult[])
}

View file

@ -1,32 +1,5 @@
import MiniSearch, { SearchResult } from 'minisearch' import { SearchResult } from 'minisearch'
import { documents, DocumentManifest } from '../db/data' import { DocumentManifest } from '../db/data'
const docs = Object.entries(documents).map(([key, value]) => ({
id: key,
keywords: value.manifest.keywords.join(' '),
abstract: value.abstract,
topics: value.manifest.topics.join(' '),
authors: value.manifest.authors.join(' '),
title: value.manifest.title,
manifest: value.manifest,
type: value.manifest.type,
}))
const miniSearch = new MiniSearch({
fields: ['abstract', 'keywords', 'topics', 'authors', 'title', 'type'],
storeFields: ['key', 'abstract', 'manifest'],
searchOptions: {
boost: {
title: 2,
keywords: 2,
topics: 1,
authors: 2,
},
fuzzy: 0.2,
prefix: true,
},
})
miniSearch.addAll(docs)
export interface CustomSearchResult extends SearchResult { export interface CustomSearchResult extends SearchResult {
manifest: DocumentManifest manifest: DocumentManifest
@ -36,6 +9,33 @@ export interface CustomSearchResult extends SearchResult {
export default function searchDocs( export default function searchDocs(
query: string, query: string,
limit = 10 limit = 10
): CustomSearchResult[] { ): Promise<CustomSearchResult[]> {
return miniSearch.search(query).slice(0, limit) as CustomSearchResult[] return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') {
const worker = new Worker(
new URL('./search.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<CustomSearchResult[]>) => {
const data = e.data
resolve(data.slice(0, limit))
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage(query)
} else {
reject(
new Error(
`Web Workers are not supported in this environment. Please avoid using a prehistoric browser.
If nothing else seems wrong, this error message is probably showing up due to ghosts in your browser.`
)
)
}
})
} }