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
rodzic
6bfd84add6
commit
6277fc9157
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Ładowanie…
Reference in New Issue