manifold/og-image/web/index.ts
Austin Chen 4b2412c49c
Set up a custom OpenGraph image generator for social media previews (#20)
* Copy in og-image code

* Add "yarn start" command in lieu of vercel

* Don't require that images be sourced from vercel

* Load in Major Mono and Readex fonts

* Fix vercel config (?)

* Replace default image with Manifold's

* Add some brief instructions on getting started

* In the UI, use the default image

* Fix typescript errors

* More typescript fixing
2022-01-07 12:07:38 -08:00

484 lines
13 KiB
TypeScript

import { ParsedRequest, Theme, FileType } from "../api/_lib/types";
const { H, R, copee } = window as any;
let timeout = -1;
interface ImagePreviewProps {
src: string;
onclick: () => void;
onload: () => void;
onerror: () => void;
loading: boolean;
}
const ImagePreview = ({
src,
onclick,
onload,
onerror,
loading,
}: ImagePreviewProps) => {
const style = {
filter: loading ? "blur(5px)" : "",
opacity: loading ? 0.1 : 1,
};
const title = "Click to copy image URL to clipboard";
return H(
"a",
{ className: "image-wrapper", href: src, onclick },
H("img", { src, onload, onerror, style, title })
);
};
interface DropdownOption {
text: string;
value: string;
}
interface DropdownProps {
options: DropdownOption[];
value: string;
onchange: (val: string) => void;
small: boolean;
}
const Dropdown = ({ options, value, onchange, small }: DropdownProps) => {
const wrapper = small ? "select-wrapper small" : "select-wrapper";
const arrow = small ? "select-arrow small" : "select-arrow";
return H(
"div",
{ className: wrapper },
H(
"select",
{ onchange: (e: any) => onchange(e.target.value) },
options.map((o) =>
H("option", { value: o.value, selected: value === o.value }, o.text)
)
),
H("div", { className: arrow }, "▼")
);
};
interface TextInputProps {
value: string;
oninput: (val: string) => void;
}
const TextInput = ({ value, oninput }: TextInputProps) => {
return H(
"div",
{ className: "input-outer-wrapper" },
H(
"div",
{ className: "input-inner-wrapper" },
H("input", {
type: "text",
value,
oninput: (e: any) => oninput(e.target.value),
})
)
);
};
interface ButtonProps {
label: string;
onclick: () => void;
}
const Button = ({ label, onclick }: ButtonProps) => {
return H("button", { onclick }, label);
};
interface FieldProps {
label: string;
input: any;
}
const Field = ({ label, input }: FieldProps) => {
return H(
"div",
{ className: "field" },
H(
"label",
H("div", { className: "field-label" }, label),
H("div", { className: "field-value" }, input)
)
);
};
interface ToastProps {
show: boolean;
message: string;
}
const Toast = ({ show, message }: ToastProps) => {
const style = { transform: show ? "translate3d(0,-0px,-0px) scale(1)" : "" };
return H(
"div",
{ className: "toast-area" },
H(
"div",
{ className: "toast-outer", style },
H(
"div",
{ className: "toast-inner" },
H("div", { className: "toast-message" }, message)
)
)
);
};
const themeOptions: DropdownOption[] = [
{ text: "Light", value: "light" },
{ text: "Dark", value: "dark" },
];
const fileTypeOptions: DropdownOption[] = [
{ text: "PNG", value: "png" },
{ text: "JPEG", value: "jpeg" },
];
const fontSizeOptions: DropdownOption[] = Array.from({ length: 10 })
.map((_, i) => i * 25)
.filter((n) => n > 0)
.map((n) => ({ text: n + "px", value: n + "px" }));
const markdownOptions: DropdownOption[] = [
{ text: "Plain Text", value: "0" },
{ text: "Markdown", value: "1" },
];
const imageLightOptions: DropdownOption[] = [
{
text: "Manifold",
value: "https://manifold.markets/logo.png",
},
{
text: "Vercel",
value:
"https://assets.vercel.com/image/upload/front/assets/design/vercel-triangle-black.svg",
},
{
text: "Next.js",
value:
"https://assets.vercel.com/image/upload/front/assets/design/nextjs-black-logo.svg",
},
{
text: "Hyper",
value:
"https://assets.vercel.com/image/upload/front/assets/design/hyper-color-logo.svg",
},
];
const imageDarkOptions: DropdownOption[] = [
{
text: "Manifold",
value: "https://manifold.markets/logo.png",
},
{
text: "Vercel",
value:
"https://assets.vercel.com/image/upload/front/assets/design/vercel-triangle-white.svg",
},
{
text: "Next.js",
value:
"https://assets.vercel.com/image/upload/front/assets/design/nextjs-white-logo.svg",
},
{
text: "Hyper",
value:
"https://assets.vercel.com/image/upload/front/assets/design/hyper-bw-logo.svg",
},
];
const widthOptions = [
{ text: "width", value: "auto" },
{ text: "50", value: "50" },
{ text: "100", value: "100" },
{ text: "150", value: "150" },
{ text: "200", value: "200" },
{ text: "250", value: "250" },
{ text: "300", value: "300" },
{ text: "350", value: "350" },
];
const heightOptions = [
{ text: "height", value: "auto" },
{ text: "50", value: "50" },
{ text: "100", value: "100" },
{ text: "150", value: "150" },
{ text: "200", value: "200" },
{ text: "250", value: "250" },
{ text: "300", value: "300" },
{ text: "350", value: "350" },
];
interface AppState extends ParsedRequest {
loading: boolean;
showToast: boolean;
messageToast: string;
selectedImageIndex: number;
widths: string[];
heights: string[];
overrideUrl: URL | null;
}
type SetState = (state: Partial<AppState>) => void;
const App = (_: any, state: AppState, setState: SetState) => {
const setLoadingState = (newState: Partial<AppState>) => {
window.clearTimeout(timeout);
if (state.overrideUrl && state.overrideUrl !== newState.overrideUrl) {
newState.overrideUrl = state.overrideUrl;
}
if (newState.overrideUrl) {
timeout = window.setTimeout(() => setState({ overrideUrl: null }), 200);
}
setState({ ...newState, loading: true });
};
const {
fileType = "png",
fontSize = "100px",
theme = "light",
md = true,
text = "**Hello** World",
images = [imageLightOptions[0].value],
widths = [],
heights = [],
showToast = false,
messageToast = "",
loading = true,
selectedImageIndex = 0,
overrideUrl = null,
} = state;
const mdValue = md ? "1" : "0";
const imageOptions = theme === "light" ? imageLightOptions : imageDarkOptions;
const url = new URL(window.location.origin);
url.pathname = `${encodeURIComponent(text)}.${fileType}`;
url.searchParams.append("theme", theme);
url.searchParams.append("md", mdValue);
url.searchParams.append("fontSize", fontSize);
for (let image of images) {
url.searchParams.append("images", image);
}
for (let width of widths) {
url.searchParams.append("widths", width);
}
for (let height of heights) {
url.searchParams.append("heights", height);
}
return H(
"div",
{ className: "split" },
H(
"div",
{ className: "pull-left" },
H(
"div",
H(Field, {
label: "Theme",
input: H(Dropdown, {
options: themeOptions,
value: theme,
onchange: (val: Theme) => {
const options =
val === "light" ? imageLightOptions : imageDarkOptions;
let clone = [...images];
clone[0] = options[selectedImageIndex].value;
setLoadingState({ theme: val, images: clone });
},
}),
}),
H(Field, {
label: "File Type",
input: H(Dropdown, {
options: fileTypeOptions,
value: fileType,
onchange: (val: FileType) => setLoadingState({ fileType: val }),
}),
}),
H(Field, {
label: "Font Size",
input: H(Dropdown, {
options: fontSizeOptions,
value: fontSize,
onchange: (val: string) => setLoadingState({ fontSize: val }),
}),
}),
H(Field, {
label: "Text Type",
input: H(Dropdown, {
options: markdownOptions,
value: mdValue,
onchange: (val: string) => setLoadingState({ md: val === "1" }),
}),
}),
H(Field, {
label: "Text Input",
input: H(TextInput, {
value: text,
oninput: (val: string) => {
console.log("oninput " + val);
setLoadingState({ text: val, overrideUrl: url });
},
}),
}),
H(Field, {
label: "Image 1",
input: H(
"div",
H(Dropdown, {
options: imageOptions,
value: imageOptions[selectedImageIndex].value,
onchange: (val: string) => {
let clone = [...images];
clone[0] = val;
const selected = imageOptions.map((o) => o.value).indexOf(val);
setLoadingState({
images: clone,
selectedImageIndex: selected,
});
},
}),
H(
"div",
{ className: "field-flex" },
H(Dropdown, {
options: widthOptions,
value: widths[0],
small: true,
onchange: (val: string) => {
let clone = [...widths];
clone[0] = val;
setLoadingState({ widths: clone });
},
}),
H(Dropdown, {
options: heightOptions,
value: heights[0],
small: true,
onchange: (val: string) => {
let clone = [...heights];
clone[0] = val;
setLoadingState({ heights: clone });
},
})
)
),
}),
...images.slice(1).map((image, i) =>
H(Field, {
label: `Image ${i + 2}`,
input: H(
"div",
H(TextInput, {
value: image,
oninput: (val: string) => {
let clone = [...images];
clone[i + 1] = val;
setLoadingState({ images: clone, overrideUrl: url });
},
}),
H(
"div",
{ className: "field-flex" },
H(Dropdown, {
options: widthOptions,
value: widths[i + 1],
small: true,
onchange: (val: string) => {
let clone = [...widths];
clone[i + 1] = val;
setLoadingState({ widths: clone });
},
}),
H(Dropdown, {
options: heightOptions,
value: heights[i + 1],
small: true,
onchange: (val: string) => {
let clone = [...heights];
clone[i + 1] = val;
setLoadingState({ heights: clone });
},
})
),
H(
"div",
{ className: "field-flex" },
H(Button, {
label: `Remove Image ${i + 2}`,
onclick: (e: MouseEvent) => {
e.preventDefault();
const filter = (arr: any[]) =>
[...arr].filter((_, n) => n !== i + 1);
const imagesClone = filter(images);
const widthsClone = filter(widths);
const heightsClone = filter(heights);
setLoadingState({
images: imagesClone,
widths: widthsClone,
heights: heightsClone,
});
},
})
)
),
})
),
H(Field, {
label: `Image ${images.length + 1}`,
input: H(Button, {
label: `Add Image ${images.length + 1}`,
onclick: () => {
const nextImage =
images.length === 1
? "https://cdn.jsdelivr.net/gh/remojansen/logo.ts@master/ts.svg"
: "";
setLoadingState({ images: [...images, nextImage] });
},
}),
})
)
),
H(
"div",
{ className: "pull-right" },
H(ImagePreview, {
src: overrideUrl ? overrideUrl.href : url.href,
loading: loading,
onload: () => setState({ loading: false }),
onerror: () => {
setState({
showToast: true,
messageToast: "Oops, an error occurred",
});
setTimeout(() => setState({ showToast: false }), 2000);
},
onclick: (e: Event) => {
e.preventDefault();
const success = copee.toClipboard(url.href);
if (success) {
setState({
showToast: true,
messageToast: "Copied image URL to clipboard",
});
setTimeout(() => setState({ showToast: false }), 3000);
} else {
window.open(url.href, "_blank");
}
return false;
},
})
),
H(Toast, {
message: messageToast,
show: showToast,
})
);
};
R(H(App), document.getElementById("app"));