Gallery thumbnails (#220)

This adds thumbnails to the gallery (#26).  Mandalas are not currently animated (I'm worried that doing this for lots of mandalas would severely impact performance).  It also adds a loading throbber for the gallery page.

If a gallery item can't be deserialized, a sad face is shown in place of a thumbnail, with a more detailed explanation on mouseover.

Currently the implementation is not as clean as I'd like.  I'll clean it up in a separate PR, as it will involve factoring out logic from the creature and mandala pages (similar in concept to #189 / #190), which would add lots of noise to this diff.
pull/223/head
Atul Varma 2021-09-01 09:47:40 -04:00 zatwierdzone przez GitHub
rodzic 6bfd84add6
commit 6277fc9157
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
5 zmienionych plików z 228 dodań i 26 usunięć

Wyświetl plik

@ -0,0 +1,13 @@
.loading-indicator {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.loading-indicator svg {
width: 64px;
height: 64px;
fill: lightgray;
}

Wyświetl plik

@ -0,0 +1,39 @@
import React from "react";
import "./loading-indicator.css";
/**
* Loading indicator taken from:
*
* https://commons.wikimedia.org/wiki/File:Chromiumthrobber.svg
*/
export const LoadingIndicator: React.FC = () => (
<div className="loading-indicator">
<svg
width="16"
height="16"
viewBox="0 0 300 300"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
>
<path
d="M 150,0
a 150,150 0 0,1 106.066,256.066
l -35.355,-35.355
a -100,-100 0 0,0 -70.711,-170.711 z"
>
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 150 150"
to="360 150 150"
begin="0s"
dur="1s"
fill="freeze"
repeatCount="indefinite"
/>
</path>
</svg>
</div>
);

Wyświetl plik

@ -0,0 +1,28 @@
.gallery-thumbnail {
width: 320px;
height: 240px;
}
.gallery-item > a {
text-decoration: none;
}
.gallery-thumbnail.is-empty {
background-color: lightgray;
}
.gallery-thumbnail.is-empty > span {
color: darkgray;
font-size: 48px;
}
.gallery-item {
display: inline-block;
border: 1px solid black;
margin-right: 1em;
margin-bottom: 1em;
}
.gallery-item p {
margin: 1em;
}

Wyświetl plik

@ -1,7 +1,28 @@
import React, { useContext, useEffect } from "react"; import React, { useContext, useEffect, useRef } from "react";
import { GalleryComposition, GalleryContext } from "../gallery-context"; import { AutoSizingSvg } from "../auto-sizing-svg";
import {
CreatureContext,
CreatureContextType,
CreatureSymbol,
} from "../creature-symbol";
import {
GalleryComposition,
GalleryCompositionKind,
GalleryContext,
} from "../gallery-context";
import { LoadingIndicator } from "../loading-indicator";
import { Page } from "../page"; import { Page } from "../page";
import { createPageWithStateSearchParams } from "../page-with-shareable-state"; import { createPageWithStateSearchParams } from "../page-with-shareable-state";
import { svgScale, SvgTransform } from "../svg-transform";
import { CreatureDesign } from "./creature-page/core";
import { deserializeCreatureDesign } from "./creature-page/serialization";
import "./gallery-page.css";
import {
createMandalaAnimationRenderer,
MandalaDesign,
} from "./mandala-page/core";
import { deserializeMandalaDesign } from "./mandala-page/serialization";
function compositionRemixUrl(comp: GalleryComposition): string { function compositionRemixUrl(comp: GalleryComposition): string {
return ( return (
@ -10,14 +31,106 @@ function compositionRemixUrl(comp: GalleryComposition): string {
); );
} }
const GalleryCompositionView: React.FC<GalleryComposition> = (props) => { const THUMBNAIL_CLASS = "gallery-thumbnail canvas";
const THUMBNAIL_SCALE = 0.2;
const ErrorThumbnail: React.FC<{ title?: string }> = ({ title }) => (
<div className={THUMBNAIL_CLASS + " is-empty"} title={title}>
<span></span>
</div>
);
const CreatureThumbnail: React.FC<{ design: CreatureDesign }> = (props) => {
const svgRef = useRef<SVGSVGElement>(null);
const ctx: CreatureContextType = {
...useContext(CreatureContext),
...props.design.compCtx,
};
const { background } = props.design.compCtx;
return ( return (
<p> <div className={THUMBNAIL_CLASS} style={{ backgroundColor: background }}>
<a href={compositionRemixUrl(props)} target="_blank"> <CreatureContext.Provider value={ctx}>
{props.title} <AutoSizingSvg padding={10} ref={svgRef} bgColor={background}>
</a>{" "} <SvgTransform transform={svgScale(THUMBNAIL_SCALE)}>
{props.kind} by {props.ownerName} <CreatureSymbol {...props.design.creature} />
</p> </SvgTransform>
</AutoSizingSvg>
</CreatureContext.Provider>
</div>
);
};
const MandalaThumbnail: React.FC<{ design: MandalaDesign }> = (props) => {
const render = createMandalaAnimationRenderer(props.design, THUMBNAIL_SCALE);
const { background } = props.design.baseCompCtx;
const svgRef = useRef<SVGSVGElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
return (
<div
className={THUMBNAIL_CLASS}
style={{ backgroundColor: background }}
ref={canvasRef}
>
<AutoSizingSvg
ref={svgRef}
bgColor={background}
sizeToElement={canvasRef}
>
{render(0)}
</AutoSizingSvg>
</div>
);
};
const THUMBNAILERS: {
[key in GalleryCompositionKind]: (gc: GalleryComposition) => JSX.Element;
} = {
creature: (gc) => (
<CreatureThumbnail design={deserializeCreatureDesign(gc.serializedValue)} />
),
mandala: (gc) => (
<MandalaThumbnail design={deserializeMandalaDesign(gc.serializedValue)} />
),
};
function getThumbnail(gc: GalleryComposition): JSX.Element {
let errorTitle: string;
if (gc.kind in THUMBNAILERS) {
try {
return THUMBNAILERS[gc.kind](gc);
} catch (e) {
errorTitle = `Could not deserialize ${gc.kind} "${gc.title}": ${e.message}`;
console.error(e);
}
} else {
errorTitle = `Found unknown gallery composition kind "${gc.kind}".`;
}
console.log(errorTitle);
return <ErrorThumbnail title={errorTitle} />;
}
const GalleryCompositionView: React.FC<GalleryComposition> = (props) => {
const thumbnail = getThumbnail(props);
const url = compositionRemixUrl(props);
return (
<div className="gallery-item">
<a href={url} target="_blank">
{thumbnail}
</a>
<p>
<a href={url} target="_blank">
{props.title}
</a>{" "}
{props.kind} by {props.ownerName}
</p>
</div>
); );
}; };
@ -33,10 +146,9 @@ export const GalleryPage: React.FC<{}> = () => {
return ( return (
<Page title="Gallery!"> <Page title="Gallery!">
<div className="sidebar"> <div className="sidebar">
<p> <p style={{ marginTop: "0" }}>
This gallery is a work in progress! You can publish to it via the You can publish to this gallery via the sidebar on other pages of this
sidebar on the creature and mandala pages. We don't have thumbnails site.
yet, though. It will improve over time.
</p> </p>
<button onClick={ctx.refresh} disabled={ctx.isLoading}> <button onClick={ctx.refresh} disabled={ctx.isLoading}>
{ctx.isLoading ? "Loading\u2026" : "Refresh"} {ctx.isLoading ? "Loading\u2026" : "Refresh"}
@ -44,9 +156,13 @@ export const GalleryPage: React.FC<{}> = () => {
{ctx.error && <p className="error">{ctx.error}</p>} {ctx.error && <p className="error">{ctx.error}</p>}
</div> </div>
<div className="canvas scrollable"> <div className="canvas scrollable">
{ctx.compositions.map((comp) => ( {ctx.isLoading ? (
<GalleryCompositionView key={comp.id} {...comp} /> <LoadingIndicator />
))} ) : (
ctx.compositions.map((comp) => (
<GalleryCompositionView key={comp.id} {...comp} />
))
)}
</div> </div>
</Page> </Page>
); );

Wyświetl plik

@ -226,14 +226,17 @@ function isDesignAnimated(design: MandalaDesign): boolean {
return getCirclesFromDesign(design).some((c) => c.animateSymbolRotation); return getCirclesFromDesign(design).some((c) => c.animateSymbolRotation);
} }
function createAnimationRenderer({ export function createMandalaAnimationRenderer(
baseCompCtx, {
invertCircle2, baseCompCtx,
circle1, invertCircle2,
circle2, circle1,
useTwoCircles, circle2,
firstBehind, useTwoCircles,
}: MandalaDesign): AnimationRenderer { firstBehind,
}: MandalaDesign,
scale = 0.5
): AnimationRenderer {
const symbolCtx = noFillIfShowingSpecs(baseCompCtx); const symbolCtx = noFillIfShowingSpecs(baseCompCtx);
const circle2SymbolCtx = invertCircle2 ? swapColors(symbolCtx) : symbolCtx; const circle2SymbolCtx = invertCircle2 ? swapColors(symbolCtx) : symbolCtx;
@ -259,7 +262,7 @@ function createAnimationRenderer({
} }
} }
return <SvgTransform transform={svgScale(0.5)}>{circles}</SvgTransform>; return <SvgTransform transform={svgScale(scale)}>{circles}</SvgTransform>;
}; };
} }
@ -336,7 +339,10 @@ export const MandalaPageWithDefaults: React.FC<{
] ]
); );
const isAnimated = isDesignAnimated(design); const isAnimated = isDesignAnimated(design);
const render = useMemo(() => createAnimationRenderer(design), [design]); const render = useMemo(
() => createMandalaAnimationRenderer(design),
[design]
);
useDebouncedEffect( useDebouncedEffect(
250, 250,