diff --git a/package-lock.json b/package-lock.json index cbecf37..1d46cab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,8 @@ "firebase-tools": "^13.0.3", "husky": "^8.0.3", "lint-staged": "^15.2.0", - "prettier": "^3.2.4" + "prettier": "^3.2.4", + "tsx": "^4.16.2" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2214,6 +2215,397 @@ "kuler": "^2.0.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "license": "MIT", @@ -9577,6 +9969,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.1.1", "license": "MIT", @@ -12061,6 +12492,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz", + "integrity": "sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "7.1.6", "license": "ISC", @@ -20749,6 +21193,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/resolve-url-loader": { "version": "4.0.0", "license": "MIT", @@ -22627,6 +23081,26 @@ "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, + "node_modules/tsx": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.2.tgz", + "integrity": "sha512-C1uWweJDgdtX2x600HjaFaucXTilT7tgUZHbOE4+ypskZ1OP8CRCSDkCxG6Vya9EwaFIVagWwpaVAn5wzypaqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.21.5", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.3.2", "license": "MIT", diff --git a/package.json b/package.json index 129a2db..285357d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "eject": "react-scripts eject", "format": "prettier --write .", "lint": "eslint --ext .ts,.js,.jsx,.tsx src types --fix", - "prepare": "husky install" + "prepare": "husky install", + "scripts:addQueryTokens": "tsx scripts/addQueryTokensToPubs.ts" }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.5.1", @@ -46,7 +47,8 @@ "firebase-tools": "^13.0.3", "husky": "^8.0.3", "lint-staged": "^15.2.0", - "prettier": "^3.2.4" + "prettier": "^3.2.4", + "tsx": "^4.16.2" }, "eslintConfig": { "extends": [ diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..138465a --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +serviceAccount.json \ No newline at end of file diff --git a/scripts/addQueryTokensToPubs.ts b/scripts/addQueryTokensToPubs.ts new file mode 100644 index 0000000..c2da4fe --- /dev/null +++ b/scripts/addQueryTokensToPubs.ts @@ -0,0 +1,59 @@ +import { existsSync } from 'fs'; + +import admin from 'firebase-admin'; +import { applicationDefault } from 'firebase-admin/app'; +// import { Timestamp } from 'firebase-admin/firestore'; + +import { createPublicationTokens } from '../src/utils/firebase'; + +const SERVICE_ACCOUNT_PATH = './scripts/serviceAccount.json'; + +type DB = ReturnType<(typeof admin)['firestore']>; +type Pubs = Awaited>; + +function setup() { + if (existsSync(SERVICE_ACCOUNT_PATH)) { + process.env.GOOGLE_APPLICATION_CREDENTIALS = SERVICE_ACCOUNT_PATH; + console.log('Using Service Account Json'); + } else { + console.log('Unable to find Service Account Json. Aborting...'); + process.exit(-1); + } + + admin.initializeApp({ credential: applicationDefault(), projectId: 'ccv-pubs' }); + const db = admin.firestore(); + return { admin, db }; +} + +async function getPubs(db: DB) { + const moduleDocs = await db.collection('publications').get(); + if (moduleDocs.empty) { + console.error('No publications found.'); + return []; + } + return moduleDocs.docs; +} + +async function updatePubsWithTokens(db: DB, pubs: Pubs) { + await Promise.all( + pubs.map((pub) => { + const pubData = pub.data() as { title: string; author: string; updatedAt: number }; + const newData = { + ...pubData, + //updatedAt: Timestamp.fromDate(new Date(pubData.updatedAt)), + tokens: createPublicationTokens(pubData), + }; + return db.doc(`publications/${pub.id}`).set(newData); + }) + ); +} + +async function main() { + const { db } = setup(); + const pubs = await getPubs(db); + console.log('fetched', pubs.length, 'publications'); + await updatePubsWithTokens(db, pubs); + console.log('Done.'); +} + +main(); diff --git a/src/App.js b/src/App.js index a285e68..103273d 100755 --- a/src/App.js +++ b/src/App.js @@ -1,26 +1,28 @@ import React from 'react'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; + import { Navbar } from './components/react-ccv-components/Navbar.tsx'; import Footer from './components/react-ccv-components/Footer'; - import { ContentPage } from './components/ContentPage'; -import { useAuthStateChanged, usePublicationsCollection } from './utils/firebase.ts'; +import { PublicationsProvider } from './utils/PublicationsContext.tsx'; +import { useAuthStateChanged } from './utils/firebase.ts'; export function App() { useAuthStateChanged(); - usePublicationsCollection(); return ( -
- - -
- - } /> - -
-
-
-
+ +
+ + +
+ + } /> + +
+
+
+
+
); } diff --git a/src/components/ContentPage.jsx b/src/components/ContentPage.jsx index 02a1939..9300b0e 100755 --- a/src/components/ContentPage.jsx +++ b/src/components/ContentPage.jsx @@ -2,14 +2,11 @@ import React from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faBook } from '@fortawesome/free-solid-svg-icons'; import { useSelector } from 'react-redux'; -import { selectPublications, selectUser } from '../store/slice/appState'; +import { selectUser } from '../store/slice/appState'; import { PublicationsTable } from './PublicationsTable.tsx'; -import Spinner from './Spinner'; import { AddPublicationModal } from './AddPublicationModal.tsx'; -import { YearChart } from './YearChart.tsx'; export function ContentPage() { - const publications = useSelector(selectPublications); const user = useSelector(selectUser); return ( @@ -25,22 +22,14 @@ export function ContentPage() {

Publications

- + - {publications.length !== 0 && ( - <> - - - {/* TODO: Word Cloud #58 */} -

- What are these publications all about? -

- {/*
*/} - {/* */} - - {/*
*/} - - )} + {/* TODO: Word Cloud #58 */} + {/*

What are these publications all about?

*/} + {/*
*/} + {/* */} + {/**/} + {/*
*/} ); } diff --git a/src/components/PublicationsTable.tsx b/src/components/PublicationsTable.tsx index ce1ad84..e381429 100755 --- a/src/components/PublicationsTable.tsx +++ b/src/components/PublicationsTable.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { ColumnFiltersState, @@ -9,10 +8,8 @@ import { getFacetedMinMaxValues, getFacetedRowModel, getFacetedUniqueValues, - getFilteredRowModel, getPaginationRowModel, - getSortedRowModel, - PaginationState, + SortingState, useReactTable, } from '@tanstack/react-table'; @@ -26,17 +23,59 @@ import Form from 'react-bootstrap/Form'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowDownWideShort, faArrowUpShortWide } from '@fortawesome/free-solid-svg-icons'; -import { selectPublications } from '../store/slice/appState'; -import { Publication } from '../../types'; +import { Publication, PublicationOrderFields } from '../../types'; +import { usePublicationContext } from '../utils/PublicationsContext.tsx'; +import { cleanTokenString } from '../utils/firebase.ts'; import { ColumnFilter } from './ColumnFilter.tsx'; export function PublicationsTable() { - const publications = useSelector(selectPublications); + const { + pubs, + count, + orderBy, + pagination, + setters: { + setAuthorFilters, + setTitleFilters, + setYearMax, + setYearMin, + setPagination, + setOrderBy, + }, + } = usePublicationContext(); const [columnFilters, setColumnFilters] = React.useState([]); - const [pagination, setPagination] = React.useState({ - pageIndex: 0, - pageSize: 5, - }); + const [sorting, setSorting] = React.useState([ + { id: orderBy.field, desc: orderBy.dir === 'desc' }, + ]); + + React.useEffect(() => { + const sortingValue = sorting[0]; + if (sortingValue !== undefined) { + setOrderBy({ + field: sortingValue.id as PublicationOrderFields, + dir: sortingValue.desc ? 'desc' : 'asc', + }); + } else { + setSorting([{ id: orderBy.field, desc: orderBy.dir !== 'desc' }]); + } + }, [sorting, orderBy, setSorting, setOrderBy]); + + React.useEffect(() => { + const authorFilters = columnFilters.filter((filter) => filter.id === 'author').pop(); + const titleFilters = columnFilters.filter((filter) => filter.id === 'title').pop(); + const yearFilters = columnFilters.filter((filter) => filter.id === 'year').pop(); + if (authorFilters !== undefined) { + setAuthorFilters(cleanTokenString(authorFilters.value as string)); + } else setAuthorFilters([]); + if (titleFilters !== undefined) { + setTitleFilters(cleanTokenString(titleFilters.value as string)); + } else setTitleFilters([]); + if (yearFilters !== undefined) { + const [yearMin, yearMax] = yearFilters.value as [number | undefined, number | undefined]; + if (yearMin !== undefined) setYearMin(Number(yearMin)); + if (yearMax !== undefined) setYearMax(Number(yearMax)); + } + }, [sorting, columnFilters, setAuthorFilters, setTitleFilters, setYearMax, setYearMin]); const columnHelper = createColumnHelper(); @@ -61,18 +100,23 @@ export function PublicationsTable() { ]; const table = useReactTable({ - data: publications, + data: pubs, columns, state: { columnFilters, pagination, + sorting, }, columnResizeMode: 'onChange', onColumnFiltersChange: setColumnFilters, - onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getSortedRowModel: getSortedRowModel(), + manualSorting: true, + onSortingChange: setSorting, + manualFiltering: true, + manualPagination: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onPaginationChange: setPagination as any, + pageCount: Math.ceil(count / pagination.pageSize), getPaginationRowModel: getPaginationRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), diff --git a/src/utils/PublicationsContext.tsx b/src/utils/PublicationsContext.tsx new file mode 100644 index 0000000..453c604 --- /dev/null +++ b/src/utils/PublicationsContext.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { PaginationState } from '@tanstack/react-table'; +import { + Publication, + PublicationOrderFields, + PublicationContextData, + PublicationOrderOpts, + PublicationOrderDirs, + SnapshotDocs, +} from '../../types'; +import { makePubsSnapshot } from './firebase.ts'; + +const setterStub = () => { + throw new Error('Function not implemented.'); +}; + +export const PublicationContext = React.createContext({ + pubs: [], + count: 0, + filters: { + title: [], + author: [], + year: { + min: undefined, + max: undefined, + }, + }, + orderBy: { + field: 'title', + dir: 'desc', + }, + setters: { + setTitleFilters: setterStub, + setAuthorFilters: setterStub, + setYearMin: setterStub, + setYearMax: setterStub, + setOrderBy: setterStub, + setPagination: setterStub, + }, + pagination: { pageIndex: 0, pageSize: 10 }, +} as PublicationContextData); + +export function PublicationsProvider({ children }: React.PropsWithChildren) { + const [pubs, setPubs] = React.useState([]); + const [pubsTotal, setPubsTotal] = React.useState(0); + const [pubCheckpoints, setPubsCheckpoints] = React.useState([]); + const [titleFilters, setTitleFilters] = React.useState([]); + const [authorFilters, setAuthorFilters] = React.useState([]); + const [yearMin, setYearMin] = React.useState(); + const [yearMax, setYearMax] = React.useState(); + const [orderByField, setOrderByField] = React.useState('title'); + const [orderByDir, setOrderByDir] = React.useState('desc'); + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }); + + const setOrderBy = React.useCallback( + ({ field, dir }: PublicationOrderOpts) => { + setOrderByField(field); + setOrderByDir(dir); + }, + [setOrderByField, setOrderByDir] + ); + const filterData = React.useMemo( + () => + ({ + filters: { + title: titleFilters, + author: authorFilters, + year: { + min: yearMin, + max: yearMax, + }, + }, + orderBy: { + field: orderByField, + dir: orderByDir, + }, + setters: { + setTitleFilters, + setAuthorFilters, + setYearMin, + setYearMax, + setOrderBy, + setPagination, + }, + }) as Exclude, + [ + titleFilters, + authorFilters, + yearMax, + yearMin, + orderByField, + orderByDir, + setTitleFilters, + setAuthorFilters, + setYearMin, + setYearMax, + setOrderBy, + setPagination, + ] + ); + + /** + * Whenever the filter properties change, reset the page index to zero...? + */ + React.useEffect(() => { + setPubsCheckpoints([]); + setPagination({ ...pagination, pageIndex: 0 }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterData]); + + React.useEffect(() => { + return makePubsSnapshot( + setPubs, + setPubsTotal, + filterData, + pagination, + pubCheckpoints, + setPubsCheckpoints + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterData, pagination]); + + const contextData = React.useMemo( + () => ({ ...filterData, pubs, count: pubsTotal, pagination }), + [pubs, pagination, filterData, pubsTotal] + ); + + return {children}; +} + +export function usePublicationContext() { + return React.useContext(PublicationContext); +} diff --git a/src/utils/firebase.ts b/src/utils/firebase.ts index 983669a..c69b3e7 100644 --- a/src/utils/firebase.ts +++ b/src/utils/firebase.ts @@ -10,6 +10,13 @@ import { onSnapshot, query, orderBy, + Timestamp, + and, + where, + QueryConstraint, + getCountFromServer, + limit, + startAfter, } from 'firebase/firestore'; import { getAuth, @@ -19,8 +26,9 @@ import { signOut, } from 'firebase/auth'; +import { PaginationState } from '@tanstack/react-table'; import { setPublications, setUser as setUserState } from '../store/slice/appState'; -import { User } from '../../types'; +import { PublicationFilters, SnapshotDocs, User } from '../../types'; const firebaseConfig = { apiKey: 'AIzaSyBlu1GzA5jvM6mh6taIcjtNgcSEVxlxa1Q', @@ -144,13 +152,101 @@ export const usePublicationsCollection = () => { }, [dispatch]); }; +export const makePubsSnapshot = ( + setPubs: (publications) => void, + setCount: (count: number) => void, + filterOpts: PublicationFilters, + pagination: PaginationState, + pubCheckpoints: SnapshotDocs, + setPubsCheckpoints: (newCheckpoints: SnapshotDocs) => void +) => { + const { + filters, + orderBy: { field, dir }, + } = filterOpts; + const orderConstraint = orderBy(field, dir); + const constraints = [ + ...(filters.title.length + filters.author.length > 0 + ? [ + where('tokens', 'array-contains-any', [ + ...filters.title.map((titleFilter) => `title:${titleFilter}`), + ...filters.author.map((authorFilter) => `author:${authorFilter}`), + ]), + ] + : []), + ...(filters.year.min !== undefined ? [where('year', '>=', filters.year.min)] : []), + ...(filters.year.max !== undefined ? [where('year', '<=', filters.year.max)] : []), + ]; + const queryConditions = ( + constraints.length === 0 ? [orderConstraint] : [and(...constraints), orderConstraint] + ) as QueryConstraint[]; + getCountFromServer(query(collection(db, publicationsCollection), ...queryConditions)).then( + (snapshot) => { + setCount(snapshot.data().count); + } + ); + return onSnapshot( + query( + collection(db, publicationsCollection), + ...queryConditions, + ...(pubCheckpoints[pagination.pageIndex] === undefined + ? [limit(pagination.pageSize)] + : [limit(pagination.pageSize), startAfter(pubCheckpoints[pagination.pageIndex])]) + ), + (snapshot) => { + const newCheckpoints = [...pubCheckpoints]; + newCheckpoints[pagination.pageIndex] = snapshot.docs[0]; + newCheckpoints[pagination.pageIndex + 1] = snapshot.docs[snapshot.docs.length - 1]; + setPubsCheckpoints(newCheckpoints); + const publications = snapshot.docs.map((doc) => doc.data()); + setPubs(publications); + }, + (error) => { + throw error; + } + ); +}; + +export const cleanTokenString = (tokenString: string) => { + /** + * Query strings are converted to lower case and split on spaces, after the following characters are removed: + * . , : ; ' " + */ + return tokenString + .toLowerCase() + .replace('-', ' ') + .replace('.', '') + .replace(',', '') + .replace(':', '') + .replace(';', '') + .replace("'", '') + .replace('"', '') + .split(' '); +}; + +/** + * Format title and author as searchable arrays. + * @param publication + */ +export const createPublicationTokens = ({ title, author }: { title: string; author: string }) => { + // Split Title and Author into a list of individual tokens, and combine into a single token list. + return [ + ...cleanTokenString(title).map((token) => `title:${token}`), + // It's common to have middle initials -- these dont narrow a search field much, and are trimmed for the + ...cleanTokenString(author) + .filter((token) => token.length > 1) + .map((token) => `author:${token}`), + ]; +}; + export const addPublication = async (publication) => { const docId = publication.doi.toLowerCase().replace(/\//g, '_'); const docRef = doc(db, publicationsCollection, docId); await setDoc(docRef, { ...publication, - updatedAt: Date.now(), + tokens: createPublicationTokens(publication), + updatedAt: Timestamp.now(), }); }; diff --git a/types/index.d.ts b/types/index.d.ts index 15c8ef4..3eef434 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,3 +1,6 @@ +import { PaginationState } from '@tanstack/react-table'; +import { onSnapshot } from 'firebase/firestore'; + export interface Publication { title: string; author: string; @@ -15,3 +18,30 @@ export interface User { ccv: boolean; updatedAt: number; } + +export type SnapshotDocs = Parameters['2']>['0']['docs']; + +export type PublicationOrderFields = 'title' | 'author' | 'year'; +export type PublicationOrderDirs = 'asc' | 'desc'; +export type PublicationOrderOpts = { + field: PublicationOrderFields; + dir: PublicationOrderDirs; +}; + +export type PublicationFilters = { + filters: { title: string[]; author: string[]; year: { min?: number; max?: number } }; + orderBy: PublicationOrderOpts; +}; + +export type PublicationContextData = PublicationFilters & { pagination: PaginationState } & { + pubs: Publication[]; + count: number; + setters: { + setTitleFilters: (newTitleFilters: string[]) => void; + setAuthorFilters: (newTitleFilters: string[]) => void; + setYearMin: (yearMin: number | undefined) => void; + setYearMax: (yearMax: number | undefined) => void; + setOrderBy: (orderBy: PublicationOrderOpts) => void; + setPagination: (pagination: PaginationState) => void; + }; +};