From be3f1abab05f2b43ccd6206dffbdd9afa338cb99 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Sun, 22 Aug 2021 12:23:51 -0400 Subject: [PATCH] Implement read-only, text-only gallery (#217) This implements an _extremely_ basic Firebase-powered gallery (#26). At present it's just a list of links to creatures/manadalas that open in new tabs, without preview thumbnails. Or the ability to submit new entries. These features will be forthcoming in future PRs. As with #215, this decouples the view logic from Firebase so we can use something else in the future if we want. --- README.md | 31 +++++++++++++++ lib/browser-main.tsx | 14 +++++-- lib/firebase.tsx | 64 +++++++++++++++++++++++++++++- lib/gallery-context.tsx | 66 +++++++++++++++++++++++++++++++ lib/page-with-shareable-state.tsx | 15 +++++-- lib/page.css | 4 ++ lib/pages/debug-page.tsx | 2 +- lib/pages/gallery-page.tsx | 52 ++++++++++++++++++++++++ lib/pages/index.tsx | 2 + 9 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 lib/gallery-context.tsx create mode 100644 lib/pages/gallery-page.tsx diff --git a/README.md b/README.md index f902bf8..ead01a5 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,37 @@ To deploy the project to GitHub Pages, run: npm run deploy ``` +## Firebase support + +The website features optional Firebase integration. + +Currently, the details for the integration are hard-coded +into the application code; see `lib/firebase.tsx` for details. + +Currently, the Firebase project that we integrate with needs +to have the following configured: + +* Cloud Firestore should be enabled with a collection called + `compositions` and the following rules: + + ``` + rules_version = '2'; + service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + // The gallery is globally readable. + allow read: if true; + + // We don't yet support submitting to the gallery, so + // deny all writes for now. + allow write: if false; + } + } + } + ``` + +* The GitHub sign-in provider must be enabled. + [NodeJS]: https://nodejs.org/en/ [Nina Paley]: https://blog.ninapaley.com/ [Atul Varma]: https://portfolio.toolness.org/ diff --git a/lib/browser-main.tsx b/lib/browser-main.tsx index 115e794..1e7ff2f 100644 --- a/lib/browser-main.tsx +++ b/lib/browser-main.tsx @@ -1,6 +1,10 @@ import React, { useCallback, useEffect, useState } from "react"; import ReactDOM from "react-dom"; -import { FirebaseAppProvider, FirebaseGithubAuthProvider } from "./firebase"; +import { + FirebaseAppProvider, + FirebaseGalleryProvider, + FirebaseGithubAuthProvider, +} from "./firebase"; import { PageContext, PAGE_QUERY_ARG } from "./page"; import { pageNames, Pages, toPageName, DEFAULT_PAGE } from "./pages"; @@ -59,9 +63,11 @@ const App: React.FC<{}> = (props) => { return ( - - - + + + + + ); diff --git a/lib/firebase.tsx b/lib/firebase.tsx index 29af6fe..26325b2 100644 --- a/lib/firebase.tsx +++ b/lib/firebase.tsx @@ -8,8 +8,18 @@ import { Auth, User, } from "firebase/auth"; +import { + FirebaseFirestore, + getFirestore, + collection, + getDocs, + CollectionReference, +} from "firebase/firestore"; import React, { useCallback, useContext, useEffect, useState } from "react"; import { AuthContext } from "./auth-context"; +import { GalleryComposition, GalleryContext } from "./gallery-context"; + +const GALLERY_COLLECTION = "compositions"; const DEFAULT_APP_CONFIG: FirebaseOptions = { apiKey: "AIzaSyAV1kkVvSKEicEa8rLke9o_BxYBu1rb8kw", @@ -25,6 +35,7 @@ type FirebaseAppContext = { app: FirebaseApp; auth: Auth; provider: GithubAuthProvider; + db: FirebaseFirestore; }; export const FirebaseAppContext = @@ -47,8 +58,9 @@ export const FirebaseAppProvider: React.FC<{ config?: FirebaseOptions }> = ({ const app = initializeApp(config || DEFAULT_APP_CONFIG); const auth = getAuth(app); const provider = new GithubAuthProvider(); + const db = getFirestore(app); - setValue({ app, auth, provider }); + setValue({ app, auth, provider, db }); }, [config]); return ; @@ -90,3 +102,53 @@ export const FirebaseGithubAuthProvider: React.FC<{}> = ({ children }) => { return ; }; + +type FirebaseCompositionDocument = Omit; + +function getGalleryCollection(appCtx: FirebaseAppContext) { + return collection( + appCtx.db, + GALLERY_COLLECTION + ) as CollectionReference; +} + +export const FirebaseGalleryProvider: React.FC<{}> = ({ children }) => { + const appCtx = useContext(FirebaseAppContext); + const [compositions, setCompositions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + const [lastRefresh, setLastRefresh] = useState(0); + + const handleError = (e: Error) => { + setIsLoading(false); + setError(e.message); + }; + + const context: GalleryContext = { + compositions, + isLoading, + error, + lastRefresh, + refresh: useCallback(() => { + if (!(appCtx && !isLoading)) return false; + + setError(undefined); + setIsLoading(true); + getDocs(getGalleryCollection(appCtx)) + .then((snapshot) => { + setLastRefresh(Date.now()); + setIsLoading(false); + setCompositions( + snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) + ); + }) + .catch(handleError); + return true; + }, [appCtx, isLoading]), + }; + + return ; +}; diff --git a/lib/gallery-context.tsx b/lib/gallery-context.tsx new file mode 100644 index 0000000..24b3158 --- /dev/null +++ b/lib/gallery-context.tsx @@ -0,0 +1,66 @@ +import React from "react"; + +export type GalleryComposition = { + /** A unique identifier/primary key for the composition. */ + id: string; + + /** The type of composition. */ + kind: "creature" | "mandala"; + + /** + * The serialized value of the composition. This + * is interpreted differently based on the composition + * type. + */ + serializedValue: string; + + /** The user ID of the user who submitted the composition. */ + owner: string; + + /** + * The name of the user who submitted the composition. + */ + ownerName: string; + + /** The title of the composition. */ + title: string; +}; + +/** + * A generic interface for interacting with the gallery. + */ +export interface GalleryContext { + /** + * All the compositions in the gallery that have been loaded + * from the network. + */ + compositions: GalleryComposition[]; + + /** Whether we're currently loading the gallery from the network. */ + isLoading: boolean; + + /** If a network error occurred, this is it. */ + error?: string; + + /** + * Attempt to refresh the gallery from the network. Return whether + * the request was accepted (if we're already loading the gallery, or + * if a prequisite service hasn't been initialized yet, this can + * return `false`). + */ + refresh(): boolean; + + /** + * The timestamp (milliseconds since 1970) of the last time + * the gallery data was refreshed. If it has never been loaded, + * this will be 0. + */ + lastRefresh: number; +} + +export const GalleryContext = React.createContext({ + compositions: [], + isLoading: false, + refresh: () => true, + lastRefresh: 0, +}); diff --git a/lib/page-with-shareable-state.tsx b/lib/page-with-shareable-state.tsx index bb46d92..1003030 100644 --- a/lib/page-with-shareable-state.tsx +++ b/lib/page-with-shareable-state.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; import { PageContext, PAGE_QUERY_ARG } from "./page"; +import type { PageName } from "./pages"; export type ComponentWithShareableStateProps = { /** The default state to use when the component is first rendered. */ @@ -34,6 +35,16 @@ export type PageWithShareableStateOptions = { /** The query string argument that will store the serialized state. */ export const STATE_QUERY_ARG = "s"; +export function createPageWithStateSearchParams( + page: PageName, + state: string +): URLSearchParams { + const search = new URLSearchParams(); + search.set(PAGE_QUERY_ARG, page); + search.set(STATE_QUERY_ARG, state); + return search; +} + /** * Create a component that represents a page which exposes some * aspect of its state in the current URL, so that it can be @@ -86,9 +97,7 @@ export function createPageWithShareableState({ (value: T) => { const newState = serialize(value); if (state !== newState) { - const newSearch = new URLSearchParams(); - newSearch.set(PAGE_QUERY_ARG, currPage); - newSearch.set(STATE_QUERY_ARG, newState); + const newSearch = createPageWithStateSearchParams(currPage, newState); setIsInOnChange(true); setLatestState(newState); pushState("?" + newSearch.toString()); diff --git a/lib/page.css b/lib/page.css index aa748eb..3af522b 100644 --- a/lib/page.css +++ b/lib/page.css @@ -112,3 +112,7 @@ ul.navbar li:last-child { margin-top: 10px; margin-bottom: 10px; } + +.error { + color: red; +} diff --git a/lib/pages/debug-page.tsx b/lib/pages/debug-page.tsx index 20f1f92..d59e123 100644 --- a/lib/pages/debug-page.tsx +++ b/lib/pages/debug-page.tsx @@ -107,7 +107,7 @@ const AuthWidget: React.FC<{}> = () => { ); - const error = ctx.error ?

{ctx.error}

: null; + const error = ctx.error ?

{ctx.error}

: null; return (
diff --git a/lib/pages/gallery-page.tsx b/lib/pages/gallery-page.tsx new file mode 100644 index 0000000..86b765d --- /dev/null +++ b/lib/pages/gallery-page.tsx @@ -0,0 +1,52 @@ +import React, { useContext, useEffect } from "react"; +import { GalleryComposition, GalleryContext } from "../gallery-context"; +import { Page } from "../page"; +import { createPageWithStateSearchParams } from "../page-with-shareable-state"; + +function compositionRemixUrl(comp: GalleryComposition): string { + return ( + "?" + + createPageWithStateSearchParams(comp.kind, comp.serializedValue).toString() + ); +} + +const GalleryCompositionView: React.FC = (props) => { + return ( +

+ + {props.title} + {" "} + {props.kind} by {props.ownerName} +

+ ); +}; + +export const GalleryPage: React.FC<{}> = () => { + const ctx = useContext(GalleryContext); + + useEffect(() => { + if (ctx.lastRefresh === 0) { + ctx.refresh(); + } + }, [ctx]); + + return ( + +
+

+ This gallery is a work in progress! You can't yet submit anything to + it, and we have no thumbnails either. It will improve over time. +

+ + {ctx.error &&

{ctx.error}

} +
+
+ {ctx.compositions.map((comp) => ( + + ))} +
+
+ ); +}; diff --git a/lib/pages/index.tsx b/lib/pages/index.tsx index 6807331..4f7e350 100644 --- a/lib/pages/index.tsx +++ b/lib/pages/index.tsx @@ -3,12 +3,14 @@ import { VocabularyPage } from "./vocabulary-page"; import { CreaturePage } from "./creature-page"; import { MandalaPage } from "./mandala-page"; import { DebugPage } from "./debug-page"; +import { GalleryPage } from "./gallery-page"; export const Pages = { vocabulary: VocabularyPage, creature: CreaturePage, waves: WavesPage, mandala: MandalaPage, + gallery: GalleryPage, debug: DebugPage, };