diff --git a/README.md b/README.md index ead01a5..55591ab 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,14 @@ to have the following configured: // 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; + allow write: if request.auth != null && + request.resource.data.keys().hasOnly(['kind', 'serializedValue', 'owner', 'ownerName', 'title', 'createdAt']) && + [request.resource.data.kind].hasAny(['creature', 'mandala']) && + request.resource.data.serializedValue is string && + request.resource.data.owner == request.auth.uid && + request.resource.data.ownerName is string && + request.resource.data.title is string && + request.resource.data.createdAt is timestamp; } } } diff --git a/lib/auth-context.tsx b/lib/auth-context.tsx index 9a24076..946a995 100644 --- a/lib/auth-context.tsx +++ b/lib/auth-context.tsx @@ -6,10 +6,9 @@ import React from "react"; export interface AuthContext { /** * The currently logged-in user. This will be - * null if the user isn't logged in, otherwise it will - * be their name. + * null if the user isn't logged in. */ - loggedInUser: string | null; + loggedInUser: { name: string; id: string } | null; /** * The name of the authentication provider, e.g. "GitHub", diff --git a/lib/firebase.tsx b/lib/firebase.tsx index 26325b2..d2ceb7b 100644 --- a/lib/firebase.tsx +++ b/lib/firebase.tsx @@ -13,11 +13,19 @@ import { getFirestore, collection, getDocs, + query, + orderBy, CollectionReference, + Timestamp, + addDoc, } from "firebase/firestore"; import React, { useCallback, useContext, useEffect, useState } from "react"; import { AuthContext } from "./auth-context"; -import { GalleryComposition, GalleryContext } from "./gallery-context"; +import { + GalleryComposition, + GalleryContext, + GallerySubmitStatus, +} from "./gallery-context"; const GALLERY_COLLECTION = "compositions"; @@ -86,7 +94,10 @@ export const FirebaseGithubAuthProvider: React.FC<{}> = ({ children }) => { }, [appCtx]); const context: AuthContext = { - loggedInUser: user && user.displayName, + loggedInUser: user && { + id: user.uid, + name: user.displayName || `GitHub user ${user.uid}`, + }, providerName: appCtx && "GitHub", error, login: useCallback(() => { @@ -103,7 +114,12 @@ export const FirebaseGithubAuthProvider: React.FC<{}> = ({ children }) => { return ; }; -type FirebaseCompositionDocument = Omit; +type FirebaseCompositionDocument = Omit< + GalleryComposition, + "id" | "createdAt" +> & { + createdAt: Timestamp; +}; function getGalleryCollection(appCtx: FirebaseAppContext) { return collection( @@ -112,40 +128,76 @@ function getGalleryCollection(appCtx: FirebaseAppContext) { ) as CollectionReference; } +function docToComp( + doc: FirebaseCompositionDocument, + id: string +): GalleryComposition { + const { createdAt, ...data } = doc; + return { + ...data, + id, + createdAt: createdAt.toDate(), + }; +} + 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 [submitStatus, setSubmitStatus] = useState("idle"); + const [lastSubmission, setLastSubmission] = useState< + GalleryComposition | undefined + >(undefined); const context: GalleryContext = { compositions, isLoading, error, lastRefresh, + lastSubmission, + submitStatus, + submit(props, onSuccess) { + if (!(appCtx && submitStatus === "idle")) return; + + const doc: FirebaseCompositionDocument = { + ...props, + createdAt: Timestamp.now(), + }; + + setSubmitStatus("submitting"); + setLastSubmission(undefined); + addDoc(getGalleryCollection(appCtx), doc) + .then((docRef) => { + const comp = docToComp(doc, docRef.id); + setSubmitStatus("idle"); + setCompositions([comp, ...compositions]); + setLastSubmission(comp); + onSuccess(docRef.id); + }) + .catch((e) => { + setSubmitStatus("error"); + console.log(e); + }); + }, refresh: useCallback(() => { if (!(appCtx && !isLoading)) return false; setError(undefined); setIsLoading(true); - getDocs(getGalleryCollection(appCtx)) + getDocs(query(getGalleryCollection(appCtx), orderBy("createdAt", "desc"))) .then((snapshot) => { setLastRefresh(Date.now()); setIsLoading(false); setCompositions( - snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })) + snapshot.docs.map((doc) => docToComp(doc.data(), doc.id)) ); }) - .catch(handleError); + .catch((e) => { + setIsLoading(false); + setError(e.message); + }); return true; }, [appCtx, isLoading]), }; diff --git a/lib/gallery-context.tsx b/lib/gallery-context.tsx index 24b3158..641844f 100644 --- a/lib/gallery-context.tsx +++ b/lib/gallery-context.tsx @@ -1,11 +1,15 @@ import React from "react"; +export type GalleryCompositionKind = "creature" | "mandala"; + +export type GallerySubmitStatus = "idle" | "submitting" | "error"; + export type GalleryComposition = { /** A unique identifier/primary key for the composition. */ id: string; /** The type of composition. */ - kind: "creature" | "mandala"; + kind: GalleryCompositionKind; /** * The serialized value of the composition. This @@ -24,6 +28,9 @@ export type GalleryComposition = { /** The title of the composition. */ title: string; + + /** When the composition was submitted to the gallery. */ + createdAt: Date; }; /** @@ -36,6 +43,25 @@ export interface GalleryContext { */ compositions: GalleryComposition[]; + /** The status of the most recent submission to the gallery. */ + submitStatus: GallerySubmitStatus; + + /** + * Submit a composition to the gallery. On success, calls the + * given callback, passing it the newly-assigned id of the + * composition. + * + * If already in the process of submitting a composition, this + * will do nothing. + */ + submit( + composition: Omit, + onSuccess: (id: string) => void + ): void; + + /** The most recent submission made via `submit()`, if any. */ + lastSubmission?: GalleryComposition; + /** Whether we're currently loading the gallery from the network. */ isLoading: boolean; @@ -62,5 +88,7 @@ export const GalleryContext = React.createContext({ compositions: [], isLoading: false, refresh: () => true, + submitStatus: "idle", + submit: () => {}, lastRefresh: 0, }); diff --git a/lib/gallery-widget.tsx b/lib/gallery-widget.tsx new file mode 100644 index 0000000..87d74e6 --- /dev/null +++ b/lib/gallery-widget.tsx @@ -0,0 +1,132 @@ +import { assertNotNull } from "@justfixnyc/util/commonjs"; +import React, { useContext, useState } from "react"; +import { AuthContext } from "./auth-context"; +import { GalleryCompositionKind, GalleryContext } from "./gallery-context"; + +export type GalleryWidgetProps = { + kind: GalleryCompositionKind; + serializeValue: () => string; +}; + +const AuthWidget: React.FC<{}> = () => { + const ctx = useContext(AuthContext); + + if (!ctx.providerName) { + return null; + } + + const button = ctx.loggedInUser ? ( + + ) : ( + + ); + + const error = ctx.error ?

{ctx.error}

: null; + + return ( + <> + {button} + {error} + + ); +}; + +const LoginWidget: React.FC<{}> = () => { + return ( + <> +

+ To publish your composition to our gallery, you will first need to + login. +

+ + + ); +}; + +const PublishWidget: React.FC = (props) => { + const authCtx = useContext(AuthContext); + const user = assertNotNull(authCtx.loggedInUser, "User must be logged in"); + const galleryCtx = useContext(GalleryContext); + const [title, setTitle] = useState(""); + const [publishedId, setPublishedId] = useState(""); + const handlePublish = () => { + galleryCtx.submit( + { + title, + kind: props.kind, + serializedValue: props.serializeValue(), + owner: user.id, + ownerName: user.name, + }, + setPublishedId + ); + }; + const isSubmitting = galleryCtx.submitStatus === "submitting"; + + if (galleryCtx.lastSubmission?.id === publishedId) { + return ( + <> +

Your composition "{title}" has been published!

+ + + ); + } + + return ( + <> +

+ Here you can publish your composition to our publicly-viewable gallery. +

+
{ + e.preventDefault(); + handlePublish(); + }} + > +
+ + setTitle(e.target.value)} + disabled={isSubmitting} + required + /> +
+ {" "} + {!isSubmitting && } + {galleryCtx.submitStatus === "error" && ( +

+ Sorry, an error occurred while submitting your composition. Please + try again later. +

+ )} + + + ); +}; + +export const GalleryWidget: React.FC = (props) => { + const authCtx = useContext(AuthContext); + + return ( +
+ Publish + {authCtx.loggedInUser ? : } +
+ ); +}; diff --git a/lib/pages/creature-page/core.tsx b/lib/pages/creature-page/core.tsx index 6f4a376..51e8174 100644 --- a/lib/pages/creature-page/core.tsx +++ b/lib/pages/creature-page/core.tsx @@ -43,6 +43,8 @@ import { createDistribution } from "../../distribution"; import { ComponentWithShareableStateProps } from "../../page-with-shareable-state"; import { useDebouncedEffect } from "../../use-debounced-effect"; import { useRememberedState } from "../../use-remembered-state"; +import { GalleryWidget } from "../../gallery-widget"; +import { serializeCreatureDesign } from "./serialization"; /** * The minimum number of attachment points that any symbol used as the main body @@ -344,6 +346,10 @@ export const CreaturePageWithDefaults: React.FC< /> + serializeCreatureDesign(design)} + />
= () => { ); }; -const AuthWidget: React.FC<{}> = () => { - const ctx = useContext(AuthContext); - - if (!ctx.providerName) { - return null; - } - - const button = ctx.loggedInUser ? ( - - ) : ( - - ); - - const error = ctx.error ?

{ctx.error}

: null; - - return ( -
- {button} - {error} -
- ); -}; - export const DebugPage: React.FC<{}> = () => { const [symbolCtx, setSymbolCtx] = useState(createSvgSymbolContext()); const defaultCtx = useContext(CreatureContext); @@ -132,7 +108,6 @@ export const DebugPage: React.FC<{}> = () => {

Random color sampling

-
diff --git a/lib/pages/mandala-page/core.tsx b/lib/pages/mandala-page/core.tsx index c839921..2970a71 100644 --- a/lib/pages/mandala-page/core.tsx +++ b/lib/pages/mandala-page/core.tsx @@ -23,6 +23,8 @@ import { MandalaCircle, MandalaCircleParams } from "../../mandala-circle"; import { useAnimationPct } from "../../animation"; import { RandomizerWidget } from "../../randomizer-widget"; import { useDebouncedEffect } from "../../use-debounced-effect"; +import { GalleryWidget } from "../../gallery-widget"; +import { serializeMandalaDesign } from "./serialization"; export type ExtendedMandalaCircleParams = MandalaCircleParams & { scaling: number; @@ -398,6 +400,10 @@ export const MandalaPageWithDefaults: React.FC<{ setCircle2({ ...circle2, ...getRandomCircleParams(rng) }); }} /> + serializeMandalaDesign(design)} + />
=8" } }, + "node_modules/@justfixnyc/util": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@justfixnyc/util/-/util-0.3.0.tgz", + "integrity": "sha512-Iuj6x5PPhtaHFNLYfZoIOFIwV4ysPGt7EapgfO8hYlQprHRcZfxBXb51ZHmWx3I7Ak5VZLgOLc8/HLFkZjWKlw==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -13468,6 +13474,11 @@ } } }, + "@justfixnyc/util": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@justfixnyc/util/-/util-0.3.0.tgz", + "integrity": "sha512-Iuj6x5PPhtaHFNLYfZoIOFIwV4ysPGt7EapgfO8hYlQprHRcZfxBXb51ZHmWx3I7Ak5VZLgOLc8/HLFkZjWKlw==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 8d6ef75..3b73a26 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@babel/preset-react": "^7.13.13", "@babel/preset-typescript": "^7.12.7", "@babel/register": "^7.12.10", + "@justfixnyc/util": "^0.3.0", "@types/cheerio": "^0.22.23", "@types/jest": "^26.0.20", "@types/node": "^14.14.22",