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
Atul Varma 2021-08-22 17:11:18 -04:00 zatwierdzone przez GitHub
rodzic be3f1abab0
commit ab3b976746
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
10 zmienionych plików z 261 dodań i 46 usunięć

Wyświetl plik

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

Wyświetl plik

@ -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",

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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)}

Wyświetl plik

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

Wyświetl plik

@ -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)}

11
package-lock.json wygenerowano
Wyświetl plik

@ -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",

Wyświetl plik

@ -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",