Add gallery submission (#218)
This adds a "Publish" UI to the creature and mandala pages which allows compositions to be published to the gallery.pull/223/head
rodzic
be3f1abab0
commit
ab3b976746
11
README.md
11
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 <AuthContext.Provider value={context} children={children} />;
|
||||
};
|
||||
|
||||
type FirebaseCompositionDocument = Omit<GalleryComposition, "id">;
|
||||
type FirebaseCompositionDocument = Omit<
|
||||
GalleryComposition,
|
||||
"id" | "createdAt"
|
||||
> & {
|
||||
createdAt: Timestamp;
|
||||
};
|
||||
|
||||
function getGalleryCollection(appCtx: FirebaseAppContext) {
|
||||
return collection(
|
||||
|
@ -112,40 +128,76 @@ function getGalleryCollection(appCtx: FirebaseAppContext) {
|
|||
) as CollectionReference<FirebaseCompositionDocument>;
|
||||
}
|
||||
|
||||
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<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 [submitStatus, setSubmitStatus] = useState<GallerySubmitStatus>("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]),
|
||||
};
|
||||
|
|
|
@ -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<GalleryComposition, "id" | "createdAt">,
|
||||
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<GalleryContext>({
|
|||
compositions: [],
|
||||
isLoading: false,
|
||||
refresh: () => true,
|
||||
submitStatus: "idle",
|
||||
submit: () => {},
|
||||
lastRefresh: 0,
|
||||
});
|
||||
|
|
|
@ -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 ? (
|
||||
<button type="button" onClick={ctx.logout}>
|
||||
Logout {ctx.loggedInUser.name}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={ctx.login}>
|
||||
Login with {ctx.providerName}
|
||||
</button>
|
||||
);
|
||||
|
||||
const error = ctx.error ? <p className="error">{ctx.error}</p> : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
{error}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LoginWidget: React.FC<{}> = () => {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
To publish your composition to our gallery, you will first need to
|
||||
login.
|
||||
</p>
|
||||
<AuthWidget />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PublishWidget: React.FC<GalleryWidgetProps> = (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 (
|
||||
<>
|
||||
<p>Your composition "{title}" has been published!</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPublishedId("");
|
||||
setTitle("");
|
||||
}}
|
||||
>
|
||||
I want to publish more!
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
Here you can publish your composition to our publicly-viewable gallery.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handlePublish();
|
||||
}}
|
||||
>
|
||||
<div className="flex-widget thingy">
|
||||
<label htmlFor="gallery-title">Composition title:</label>
|
||||
<input
|
||||
id="gallery-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
Publish to gallery
|
||||
</button>{" "}
|
||||
{!isSubmitting && <AuthWidget />}
|
||||
{galleryCtx.submitStatus === "error" && (
|
||||
<p className="error">
|
||||
Sorry, an error occurred while submitting your composition. Please
|
||||
try again later.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const GalleryWidget: React.FC<GalleryWidgetProps> = (props) => {
|
||||
const authCtx = useContext(AuthContext);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Publish</legend>
|
||||
{authCtx.loggedInUser ? <PublishWidget {...props} /> : <LoginWidget />}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
|
@ -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<
|
|||
/>
|
||||
</div>
|
||||
</RandomizerWidget>
|
||||
<GalleryWidget
|
||||
kind="creature"
|
||||
serializeValue={() => serializeCreatureDesign(design)}
|
||||
/>
|
||||
<div className="thingy">
|
||||
<ExportWidget
|
||||
basename={getDownloadBasename(creature.data.name)}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import { AuthContext } from "../auth-context";
|
||||
import { AutoSizingSvg } from "../auto-sizing-svg";
|
||||
import { CreatureContext, CreatureContextType } from "../creature-symbol";
|
||||
import { createCreatureSymbolFactory } from "../creature-symbol-factory";
|
||||
|
@ -94,29 +93,6 @@ const RandomColorSampling: React.FC<{}> = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const AuthWidget: React.FC<{}> = () => {
|
||||
const ctx = useContext(AuthContext);
|
||||
|
||||
if (!ctx.providerName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const button = ctx.loggedInUser ? (
|
||||
<button onClick={ctx.logout}>Logout {ctx.loggedInUser}</button>
|
||||
) : (
|
||||
<button onClick={ctx.login}>Login with {ctx.providerName}</button>
|
||||
);
|
||||
|
||||
const error = ctx.error ? <p className="error">{ctx.error}</p> : null;
|
||||
|
||||
return (
|
||||
<div className="thingy">
|
||||
{button}
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DebugPage: React.FC<{}> = () => {
|
||||
const [symbolCtx, setSymbolCtx] = useState(createSvgSymbolContext());
|
||||
const defaultCtx = useContext(CreatureContext);
|
||||
|
@ -132,7 +108,6 @@ export const DebugPage: React.FC<{}> = () => {
|
|||
<SymbolContextWidget ctx={symbolCtx} onChange={setSymbolCtx} />
|
||||
<h2>Random color sampling</h2>
|
||||
<RandomColorSampling />
|
||||
<AuthWidget />
|
||||
</div>
|
||||
<div className="canvas">
|
||||
<CreatureContext.Provider value={ctx}>
|
||||
|
|
|
@ -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) });
|
||||
}}
|
||||
/>
|
||||
<GalleryWidget
|
||||
kind="mandala"
|
||||
serializeValue={() => serializeMandalaDesign(design)}
|
||||
/>
|
||||
<div className="thingy">
|
||||
<ExportWidget
|
||||
basename={getBasename(design)}
|
||||
|
|
|
@ -14,6 +14,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",
|
||||
|
@ -2904,6 +2905,11 @@
|
|||
"node": ">=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",
|
||||
|
|
|
@ -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",
|
||||
|
|
Ładowanie…
Reference in New Issue