Add support for images
This commit is contained in:
parent
6a154558e1
commit
be4d4e3168
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
12
src/back/image-router.ts
Normal 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,
|
||||||
|
});
|
6
src/back/jdd-components/image-demo/image-demo.css
Normal file
6
src/back/jdd-components/image-demo/image-demo.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.image-demo {
|
||||||
|
.image-grid > * {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
}
|
63
src/back/jdd-components/image-demo/image-demo.jdd.tsx
Normal file
63
src/back/jdd-components/image-demo/image-demo.jdd.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
35
src/back/routes/component-preview/component-input-enum.tsx
Normal file
35
src/back/routes/component-preview/component-input-enum.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
54
src/back/routes/component-preview/component-input-image.tsx
Normal file
54
src/back/routes/component-preview/component-input-image.tsx
Normal 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('"', """)
|
||||||
|
.replaceAll("\n", "\\n")}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
58
src/back/routes/component-preview/component-input-list.tsx
Normal file
58
src/back/routes/component-preview/component-input-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
85
src/back/routes/component-preview/component-input.tsx
Normal file
85
src/back/routes/component-preview/component-input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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)) || {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
3
src/back/routes/component-preview/print-arg-path.ts
Normal file
3
src/back/routes/component-preview/print-arg-path.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function printArgPath(path: string[]): string {
|
||||||
|
return path.map((e) => `[${e}]`).join("");
|
||||||
|
}
|
16
src/back/routes/jdd-context.ts
Normal file
16
src/back/routes/jdd-context.ts
Normal 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;
|
21
src/front/controllers/input-image-preview.ts
Normal file
21
src/front/controllers/input-image-preview.ts
Normal 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
0
uploaded-images/keepme
Normal file
Loading…
x
Reference in New Issue
Block a user