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
rodzic
22ae85c512
commit
be3f1abab0
31
README.md
31
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/
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
|
@ -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());
|
||||
|
|
|
@ -112,3 +112,7 @@ ul.navbar li:last-child {
|
|||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue