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.
pull/223/head
Atul Varma 2021-08-22 12:23:51 -04:00 zatwierdzone przez GitHub
rodzic 22ae85c512
commit be3f1abab0
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 241 dodań i 9 usunięć

Wyświetl plik

@ -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/

Wyświetl plik

@ -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 (
<FirebaseAppProvider>
<FirebaseGithubAuthProvider>
<PageContext.Provider value={ctx}>
<PageComponent />
</PageContext.Provider>
<FirebaseGalleryProvider>
<PageContext.Provider value={ctx}>
<PageComponent />
</PageContext.Provider>
</FirebaseGalleryProvider>
</FirebaseGithubAuthProvider>
</FirebaseAppProvider>
);

Wyświetl plik

@ -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 <FirebaseAppContext.Provider value={value} children={children} />;
@ -90,3 +102,53 @@ export const FirebaseGithubAuthProvider: React.FC<{}> = ({ children }) => {
return <AuthContext.Provider value={context} children={children} />;
};
type FirebaseCompositionDocument = Omit<GalleryComposition, "id">;
function getGalleryCollection(appCtx: FirebaseAppContext) {
return collection(
appCtx.db,
GALLERY_COLLECTION
) as CollectionReference<FirebaseCompositionDocument>;
}
export const FirebaseGalleryProvider: React.FC<{}> = ({ children }) => {
const appCtx = useContext(FirebaseAppContext);
const [compositions, setCompositions] = useState<GalleryComposition[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>(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 <GalleryContext.Provider value={context} children={children} />;
};

Wyświetl plik

@ -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<GalleryContext>({
compositions: [],
isLoading: false,
refresh: () => true,
lastRefresh: 0,
});

Wyświetl plik

@ -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<T> = {
/** The default state to use when the component is first rendered. */
@ -34,6 +35,16 @@ export type PageWithShareableStateOptions<T> = {
/** 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<T>({
(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());

Wyświetl plik

@ -112,3 +112,7 @@ ul.navbar li:last-child {
margin-top: 10px;
margin-bottom: 10px;
}
.error {
color: red;
}

Wyświetl plik

@ -107,7 +107,7 @@ const AuthWidget: React.FC<{}> = () => {
<button onClick={ctx.login}>Login with {ctx.providerName}</button>
);
const error = ctx.error ? <p style={{ color: "red" }}>{ctx.error}</p> : null;
const error = ctx.error ? <p className="error">{ctx.error}</p> : null;
return (
<div className="thingy">

Wyświetl plik

@ -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<GalleryComposition> = (props) => {
return (
<p>
<a href={compositionRemixUrl(props)} target="_blank">
{props.title}
</a>{" "}
{props.kind} by {props.ownerName}
</p>
);
};
export const GalleryPage: React.FC<{}> = () => {
const ctx = useContext(GalleryContext);
useEffect(() => {
if (ctx.lastRefresh === 0) {
ctx.refresh();
}
}, [ctx]);
return (
<Page title="Gallery!">
<div className="sidebar">
<p>
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.
</p>
<button onClick={ctx.refresh} disabled={ctx.isLoading}>
{ctx.isLoading ? "Loading\u2026" : "Refresh"}
</button>
{ctx.error && <p className="error">{ctx.error}</p>}
</div>
<div className="canvas scrollable">
{ctx.compositions.map((comp) => (
<GalleryCompositionView key={comp.id} {...comp} />
))}
</div>
</Page>
);
};

Wyświetl plik

@ -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,
};