Add support for images

This commit is contained in:
Kuba Orlik 2024-03-20 23:17:24 +01:00
parent 6a154558e1
commit be4d4e3168
14 changed files with 465 additions and 0 deletions

5
.gitignore vendored
View File

@ -30,3 +30,8 @@ public/dist
.env .env
.npm_cache/ .npm_cache/
.mozilla/ .mozilla/
cache/*
!cache/images/.keepme
!cache/smartcrop/.keepme
uploaded-images/*
!uploaded-images/keepme

12
src/back/image-router.ts Normal file
View File

@ -0,0 +1,12 @@
import { KoaResponsiveImageRouter } from "koa-responsive-image-router";
import { IMAGE_CACHE_FS_DIR, SMARTCROP_CACHE_FS_DIR } from "./config.js";
export const RESPONSIVE_IMAGES_URL_PATH = "/images";
export const imageRouter = new KoaResponsiveImageRouter({
staticPath: RESPONSIVE_IMAGES_URL_PATH,
thumbnailSize: 20,
cacheManagerResolutionThreshold: 50,
imageStoragePath: IMAGE_CACHE_FS_DIR,
smartCropStoragePath: SMARTCROP_CACHE_FS_DIR,
});

View File

@ -0,0 +1,6 @@
.image-demo {
.image-grid > * {
width: 200px;
height: 200px;
}
}

View File

@ -0,0 +1,63 @@
import { FlatTemplatable, TempstreamJSX } from "tempstream";
import {
Component,
ComponentArguments,
ExtractStructuredComponentArgumentsValues,
JDDContext,
} from "@sealcode/jdd";
const component_arguments = {
image_with_alt: new ComponentArguments.Structured({
image: new ComponentArguments.Image(),
alt: new ComponentArguments.ShortText(),
}),
multiple_images: new ComponentArguments.List(new ComponentArguments.Image()),
} as const;
export class ImageDemo extends Component<typeof component_arguments> {
getArguments() {
return component_arguments;
}
toHTML(
{
image_with_alt,
multiple_images,
}: ExtractStructuredComponentArgumentsValues<typeof component_arguments>,
{ render_image }: JDDContext
): FlatTemplatable {
return (
<div class="image-demo">
<h2>Image with alt text</h2>
{" " ||
render_image(image_with_alt.image, {
container: { width: 200, height: 200 },
alt: image_with_alt.alt,
})}
<h2>Multiple images</h2>
<div class="image-grid">
{multiple_images.map((image) =>
render_image(image, {
container: { width: 200, height: 200, objectFit: "cover" },
})
)}
</div>
<h2>The same images but cropped with smartcrop</h2>
<div class="image-grid">
{multiple_images.map((image) =>
render_image(image, {
container: {
width: 200,
height: 200,
objectFit: "cover",
},
crop: { width: 200, height: 200 },
})
)}
</div>
</div>
);
}
}

View File

@ -0,0 +1,35 @@
import { Enum } from "@sealcode/jdd";
import { TempstreamJSX } from "tempstream";
import { printArgPath } from "./print-arg-path.js";
export function ComponentInputEnum<State, T extends Enum<any>>({
state,
arg_path,
arg,
value,
onchange,
}: {
state: State;
arg_path: string[];
arg: T;
value: string;
onchange?: string;
}) {
return (
<div>
<label>
{arg_path.at(-1) || ""}
<select
name={`$.component_args${printArgPath(arg_path)}`}
onchange={onchange}
>
{arg.values.map((v) => (
<option value={v} selected={value == v}>
{v}
</option>
))}
</select>
</label>
</div>
);
}

View File

@ -0,0 +1,54 @@
import { Image } from "@sealcode/jdd";
import { StatefulPage } from "@sealcode/sealgen";
import { TempstreamJSX } from "tempstream";
import { ComponentPreviewState } from "../components.sreact.js";
import { jdd_context } from "../jdd-context.js";
import { ComponentPreviewActions } from "./component-preview-actions.js";
import { printArgPath } from "./print-arg-path.js";
export function ComponentInputImage<State extends ComponentPreviewState>({
arg_path,
arg,
value,
}: {
state: State;
arg_path: string[];
arg: Image;
value: string;
page: StatefulPage<ComponentPreviewState, typeof ComponentPreviewActions>;
}) {
return (
<div style="margin-bottom: 10px">
<label
style="display: flex; align-items: center; column-gap: 10px;"
data-controller="input-image-preview"
>
{arg_path.at(-1) || ""}
{value &&
jdd_context.render_image(value, {
container: { width: 40, height: 40, objectFit: "cover" },
crop: { width: 40, height: 40 },
style: "height: 40px; width: 40px;",
})}
<input
type="file"
name={`$.component_args${printArgPath(arg_path)}.new`}
value=""
autocomplete="off"
data-action="change->input-image-preview#handleChange"
multiple={arg.hasParent("list")}
/>
</label>
<div>
<input
type="hidden"
name={`$.component_args${printArgPath(arg_path)}.old`}
value={(value || "")
.replaceAll('"', "&quot;")
.replaceAll("\n", "\\n")}
autocomplete="off"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
import { ComponentArgument, List } from "@sealcode/jdd";
import { StatefulPage } from "@sealcode/sealgen";
import { TempstreamJSX } from "tempstream";
import { ComponentPreviewState } from "../components.sreact.js";
import { jdd_context } from "../jdd-context.js";
import { ComponentInput } from "./component-input.js";
import type { ComponentPreviewActions } from "./component-preview-actions.js";
export async function ComponentInputList<State extends ComponentPreviewState, T>({
state,
arg_path,
arg,
value,
page,
}: {
state: State;
arg_path: string[];
arg: List<ComponentArgument<T>>;
value: T[];
page: StatefulPage<ComponentPreviewState, typeof ComponentPreviewActions>;
}) {
if (!value) {
value = [];
}
return (
<fieldset>
<legend>{arg_path.at(-1)}</legend>
{value.map((value, i) => (
<div style="display: flex">
<ComponentInput
{...{
state,
arg_path: [...arg_path, i.toString()],
arg: arg.item_type,
value,
page,
}}
/>
{page.makeActionButton(
state,
{ action: "remove_array_item", label: "❌" },
arg_path,
i
)}
</div>
))}
{page.makeActionButton(
state,
{
action: "add_array_item",
label: "",
},
arg_path,
await arg.item_type.getExampleValue(jdd_context)
)}
</fieldset>
);
}

View File

@ -0,0 +1,46 @@
import { ComponentArgument, Structured } from "@sealcode/jdd";
import { StatefulPage } from "@sealcode/sealgen";
import { TempstreamJSX } from "tempstream";
import { ComponentPreviewState } from "../components.sreact.js";
import { ComponentInput } from "./component-input.js";
import { ComponentPreviewActions } from "./component-preview-actions.js";
import { printArgPath } from "./print-arg-path.js";
export function ComponentInputStructured<
State,
T extends Structured<Record<string, ComponentArgument<unknown>>>
>({
state,
arg_path,
arg,
value,
rerender_callback,
page,
}: {
state: ComponentPreviewState;
arg_path: string[];
arg: T;
value: Record<string, unknown>;
rerender_callback?: string;
page: StatefulPage<ComponentPreviewState, typeof ComponentPreviewActions>;
}) {
return (
<fieldset>
<legend>{arg_path.at(-1)}</legend>
{Object.entries(arg.structure).map(([arg_name, arg]) => (
<div>
<ComponentInput
{...{
state,
arg_path: [...arg_path, arg_name],
arg,
value: value[arg_name],
rerender_callback,
page,
}}
/>
</div>
))}
</fieldset>
);
}

View File

@ -0,0 +1,85 @@
import { Enum, Image, List, Structured } from "@sealcode/jdd";
import { ComponentArgument } from "@sealcode/jdd";
import { StatefulPage } from "@sealcode/sealgen";
import { TempstreamJSX } from "tempstream";
import { ComponentPreviewState } from "../components.sreact.js";
import { ComponentInputEnum } from "./component-input-enum.js";
import { ComponentInputImage } from "./component-input-image.js";
import { ComponentInputList } from "./component-input-list.js";
import { ComponentInputStructured } from "./component-input-structured.js";
import { printArgPath } from "./print-arg-path.js";
export function ComponentInput<State extends ComponentPreviewState, T>({
state,
arg_path,
arg,
value,
page,
}: {
state: State;
arg_path: string[];
arg: ComponentArgument<T>;
value: T;
page: StatefulPage<any, any>;
}) {
if (value === undefined) {
value = arg.getEmptyValue();
}
if (arg instanceof List) {
return ComponentInputList({ state, arg_path, arg, value: value as T[], page });
}
if (arg instanceof Structured) {
return ComponentInputStructured({
state,
arg_path,
arg,
value: value as Record<string, unknown>,
page,
});
}
if (arg instanceof Enum) {
return ComponentInputEnum({
state,
arg_path,
arg,
value: value as string,
onchange: page.rerender(),
});
}
if (arg instanceof Image) {
return ComponentInputImage({
state,
arg_path,
arg,
value: value as string,
page,
});
}
return (
<div>
<label>
{arg_path.at(-1) || ""}
{arg.getTypeName() == "markdown" ? (
<textarea
name={`$.component_args${printArgPath(arg_path)}`}
onblur={page.rerender()}
cols="70"
>
{value as string}
</textarea>
) : (
<input
type="text"
name={`$.component_args${printArgPath(arg_path)}`}
value={value as string}
size="70"
/>
)}
</label>
</div>
);
}

View File

@ -0,0 +1,61 @@
import objectPath from "object-path";
import { registry } from "../../jdd-components/components.js";
import type { ComponentPreviewState } from "../components.sreact.js";
import { jdd_context } from "../jdd-context.js";
export const ComponentPreviewActions = <const>{
add_array_item: (
state: ComponentPreviewState,
_: Record<string, string>,
arg_path: string[],
empty_value: unknown
) => {
const component_args = state.component_args;
objectPath.insert(
component_args,
arg_path,
empty_value,
((objectPath.get(component_args, arg_path) as unknown[]) || []).length
);
return {
...state,
component_args,
};
},
remove_array_item: (
state: ComponentPreviewState,
_: Record<string, string>,
arg_path: string[],
index_to_remove: number
) => {
const component_args = state.component_args;
objectPath.del(component_args, [...arg_path, index_to_remove]);
return {
...state,
component_args,
};
},
change_component: async (
state: ComponentPreviewState,
inputs: Record<string, string>
) => {
const component_name = inputs.component;
const component = registry.get(component_name);
return {
...state,
component: component_name,
component_args: (await component?.getExampleValues(jdd_context)) || {},
};
},
randomize_args: async (
state: ComponentPreviewState,
inputs: Record<string, string>
) => {
const component_name = inputs.component;
const component = registry.get(component_name);
return {
...state,
component_args: (await component?.getExampleValues(jdd_context)) || {},
};
},
};

View File

@ -0,0 +1,3 @@
export function printArgPath(path: string[]): string {
return path.map((e) => `[${e}]`).join("");
}

View File

@ -0,0 +1,16 @@
import { simpleJDDContext, JDDContext } from "@sealcode/jdd";
import { imageRouter } from "../image-router.js";
export const jdd_context = {
...simpleJDDContext,
render_image: async (image_id, args) => {
if (!image_id) {
return "";
}
const image_pointer = await simpleJDDContext.decode_file(image_id);
if (!image_pointer) {
return "";
}
return imageRouter.image(image_pointer.path, args);
},
} as JDDContext;

View File

@ -0,0 +1,21 @@
import { Controller } from "stimulus";
export default class InputImagePreview extends Controller {
id: string;
handleChange(event) {
const img = this.element.querySelector("img");
console.log({ img });
window.URL.revokeObjectURL(img.src);
const new_url = window.URL.createObjectURL(
this.element.querySelector("input").files[0]
);
console.log({ new_url });
img.src = new_url;
img.parentNode;
img.parentElement
.querySelectorAll("source")
.forEach((e) => e.parentNode.removeChild(e));
img.style.aspectRatio = "1";
}
}

0
uploaded-images/keepme Normal file
View File