Compare commits
3 Commits
2fbb27f6b5
...
8079a1bd79
Author | SHA1 | Date |
---|---|---|
idylls | 8079a1bd79 | 1 year ago |
idylls | 9bff98525a | 1 year ago |
idylls | 80ce5bda66 | 1 year ago |
@ -0,0 +1,3 @@
|
||||
build
|
||||
build/
|
||||
build/*
|
@ -1,3 +1,3 @@
|
||||
# zontent
|
||||
# zchema
|
||||
|
||||
A simple but flexible CMS
|
||||
A simple but flexible CMS
|
||||
|
@ -0,0 +1,48 @@
|
||||
import { emptyDir } from "https://deno.land/std@0.162.0/fs/mod.ts";
|
||||
import sass from "https://deno.land/x/denosass@1.0.6/mod.ts";
|
||||
import { build, stop } from "https://deno.land/x/esbuild@v0.17.5/mod.js";
|
||||
import { httpImports } from "https://deno.land/x/esbuild_plugin_http_imports@v1.3.0/index.ts";
|
||||
|
||||
await emptyDir("./build");
|
||||
|
||||
const compiler = sass(["./frontend/style.scss"]);
|
||||
|
||||
compiler.to_file({
|
||||
destDir: "./build/",
|
||||
destFile: "style",
|
||||
format: "compressed",
|
||||
});
|
||||
|
||||
const res = await build({
|
||||
entryPoints: ["./frontend/main.ts"],
|
||||
write: true,
|
||||
bundle: true,
|
||||
outfile: "./build/app.js",
|
||||
sourcemap: "inline",
|
||||
plugins: [httpImports()],
|
||||
});
|
||||
console.debug(res);
|
||||
|
||||
stop();
|
||||
|
||||
const bundleText = await Deno.readTextFile("./build/app.js");
|
||||
const styleText = await Deno.readTextFile("./build/style.min.css");
|
||||
|
||||
const buildModuleText = (identsToValues: Record<string, string>) => {
|
||||
let out = ``;
|
||||
for (const k in identsToValues) {
|
||||
const v = identsToValues[k];
|
||||
|
||||
out += `export const ${k} = atob("${btoa(v)}");`;
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const moduleText = buildModuleText({
|
||||
bundleText,
|
||||
styleText,
|
||||
mode: "dev",
|
||||
});
|
||||
|
||||
await Deno.writeTextFile("./build/mod.ts", moduleText);
|
@ -0,0 +1,27 @@
|
||||
import { Api, replacer } from "../common/api.ts";
|
||||
import { MakeHandlers, makeServe } from "../common/deps/yaypi.ts";
|
||||
|
||||
const handlers: MakeHandlers<typeof Api> = {
|
||||
v1: {
|
||||
listSchemas: async (limit) => [
|
||||
{
|
||||
id: 0,
|
||||
name: "Test",
|
||||
fields: [
|
||||
{
|
||||
kind: "string",
|
||||
name: "test",
|
||||
required: false,
|
||||
options: {
|
||||
displayAs: "input",
|
||||
minLength: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
saveSchema: async (schema) => ({ ...schema, id: 0 }),
|
||||
},
|
||||
};
|
||||
|
||||
export const serveApi = makeServe(Api, handlers, "/api", replacer);
|
@ -0,0 +1 @@
|
||||
export * from "../../../shirt/mod.ts";
|
@ -0,0 +1 @@
|
||||
export { serve } from "https://deno.land/std@0.182.0/http/server.ts";
|
@ -0,0 +1,96 @@
|
||||
import { serve } from "./deps/std.ts";
|
||||
import {
|
||||
HTTPHandler,
|
||||
catching,
|
||||
compressResponse,
|
||||
filter,
|
||||
log,
|
||||
orElse,
|
||||
routePaths,
|
||||
setHeaders,
|
||||
staticString,
|
||||
} from "./deps/shirt.ts";
|
||||
import { serveApi } from "./api.ts";
|
||||
|
||||
import { styleText, bundleText, mode } from "../build/mod.ts";
|
||||
|
||||
const apiHandler = filter(
|
||||
(req) => new URL(req.url).pathname.split("/")[1] === "api",
|
||||
setHeaders([["content-type", "application/json"]], serveApi),
|
||||
);
|
||||
|
||||
const notFound = (_req: Request) => {
|
||||
return new Response("Not found", { status: 404 });
|
||||
};
|
||||
|
||||
const internalServerError = (_req: Request, e: unknown) => {
|
||||
console.error(e);
|
||||
|
||||
return new Response("Something went wrong", { status: 500 });
|
||||
};
|
||||
|
||||
const renderHeadResourceLinks = () => {
|
||||
if (mode === "dev") {
|
||||
return `
|
||||
<script src="/app.js"></script>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<style>${styleText}</style>
|
||||
<script>${bundleText}</script>
|
||||
`;
|
||||
};
|
||||
|
||||
const BODY = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>zchema</title>
|
||||
${renderHeadResourceLinks()}
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You must enable JavaScript</noscript>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const serveRoot = staticString(BODY, "text/html");
|
||||
|
||||
const serveDevResources = filter(
|
||||
(_req) => mode === "dev",
|
||||
routePaths({
|
||||
"/app.js": staticString(bundleText, "text/javascript"),
|
||||
"/app.css": staticString(styleText, "text/css"),
|
||||
}),
|
||||
);
|
||||
|
||||
const handle = log(
|
||||
compressResponse(
|
||||
catching(
|
||||
orElse(
|
||||
apiHandler,
|
||||
orElse(serveDevResources, orElse(serveRoot, notFound)),
|
||||
),
|
||||
internalServerError,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const handle_ = async (req: Request) => {
|
||||
const res = await handle(req);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
serve(handle_, {
|
||||
port: 8998,
|
||||
onError: (e) => {
|
||||
console.debug(e);
|
||||
|
||||
return new Response("An internal error has occurred", {
|
||||
status: 500,
|
||||
});
|
||||
},
|
||||
});
|
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
deno run -A _build_frontend.ts
|
||||
deno run -A backend/main.ts
|
@ -0,0 +1,36 @@
|
||||
import { z } from "./deps/zod.ts";
|
||||
import { ReplacerFn, ReviverFn, sig } from "./deps/yaypi.ts";
|
||||
|
||||
import { MaybePersistedSchema, Schema } from "./schema.ts";
|
||||
|
||||
export const Api = {
|
||||
v1: {
|
||||
listSchemas: sig(z.number().nullable(), z.array(Schema)),
|
||||
saveSchema: sig(MaybePersistedSchema, Schema),
|
||||
},
|
||||
};
|
||||
|
||||
export const reviver: ReviverFn = (key, value) => {
|
||||
if (typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if ("__map" in value) {
|
||||
delete value["__map"];
|
||||
|
||||
return new Map(Object.entries(value));
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const replacer: ReplacerFn = (key: string, value: any) => {
|
||||
if (value instanceof Map) {
|
||||
const res = Object.fromEntries(value.entries());
|
||||
res["__map"] = 0;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from "../../../yaypi/mod.ts";
|
@ -0,0 +1 @@
|
||||
export * as z from "https://deno.land/x/zod@v3.21.4/mod.ts";
|
@ -0,0 +1,84 @@
|
||||
export type AutoProxyObj<T extends Record<string, any>> = {
|
||||
[K in keyof T]: AutoProxy<T[K]>;
|
||||
};
|
||||
|
||||
export type AutoProxyArr<T> = {
|
||||
[key: number]: AutoProxy<T>;
|
||||
};
|
||||
|
||||
export type AutoProxy<T> = T extends Record<string, any>
|
||||
? AutoProxyObj<Required<T>>
|
||||
: T extends (infer U)[]
|
||||
? AutoProxyArr<Required<U>>
|
||||
: T;
|
||||
|
||||
export const autoProxy = <T>(initialValue?: T): AutoProxy<T> => {
|
||||
let self: undefined | Record<string, any> | any[] = initialValue as any;
|
||||
|
||||
const p = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, prop, __) => {
|
||||
if (prop == "___self") {
|
||||
return self;
|
||||
}
|
||||
|
||||
if (self == undefined) {
|
||||
if (Number.isNaN(Number(prop))) {
|
||||
self = {};
|
||||
} else {
|
||||
self = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(self, prop)) {
|
||||
// @ts-ignore:
|
||||
self[prop] = autoProxy();
|
||||
}
|
||||
|
||||
// @ts-ignore:
|
||||
return self[prop];
|
||||
},
|
||||
|
||||
set: (_, prop, value, __) => {
|
||||
if (self == undefined) {
|
||||
if (Number.isNaN(prop)) {
|
||||
self = {};
|
||||
} else {
|
||||
self = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(self, prop)) {
|
||||
// @ts-ignore:
|
||||
self[prop] = autoProxy();
|
||||
}
|
||||
|
||||
// @ts-ignore:
|
||||
self[prop] = value;
|
||||
|
||||
return true;
|
||||
},
|
||||
ownKeys: () => Object.keys(self || {}),
|
||||
},
|
||||
);
|
||||
|
||||
return p as any;
|
||||
};
|
||||
|
||||
export const unproxy = <T>(proxy: AutoProxy<T>): T => {
|
||||
const self = (proxy as any).___self;
|
||||
if (!self) {
|
||||
return undefined as any;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(self).map(([k, v]) => {
|
||||
if ((v as any)["___self"]) {
|
||||
return [k, unproxy(v)];
|
||||
}
|
||||
|
||||
return [k, v];
|
||||
}),
|
||||
) as T;
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export const unreachable = (): never => {
|
||||
throw new Error("unreachable");
|
||||
};
|
@ -0,0 +1,23 @@
|
||||
import { Const } from "../utils.ts";
|
||||
|
||||
export const setIdx = <T>(
|
||||
arr: Const<T[]>,
|
||||
index: number,
|
||||
newValue: Const<T>,
|
||||
): Const<T[]> => {
|
||||
const newArr = [...arr] as Const<T>[];
|
||||
newArr[index] = newValue;
|
||||
|
||||
return newArr;
|
||||
};
|
||||
|
||||
export const push = <T>(arr: Const<T[]>, newValue: T) => [...arr, newValue];
|
||||
|
||||
export const setProp = <T, K extends keyof T>(
|
||||
obj: Const<T>,
|
||||
key: K,
|
||||
newValue: Const<T[K]>,
|
||||
): Const<T> => ({
|
||||
...obj,
|
||||
[key]: newValue,
|
||||
});
|
@ -0,0 +1,2 @@
|
||||
export type MaybePersisted<T extends { id: unknown }> = Omit<T, "id"> &
|
||||
Partial<Pick<T, "id">>;
|
@ -0,0 +1,153 @@
|
||||
import { z } from "./deps/zod.ts";
|
||||
import { autoProxy, unproxy } from "./lib/autoproxy.ts";
|
||||
import { MaybePersisted } from "./persisted.ts";
|
||||
import { Const } from "./utils.ts";
|
||||
import { ValidationErrors } from "./validation.ts";
|
||||
|
||||
const fDef = <OptionsSchema extends z.ZodTypeAny>(p: {
|
||||
optionsSchema: OptionsSchema;
|
||||
defaultOptions: z.infer<OptionsSchema>;
|
||||
}) => p;
|
||||
export const Fields = {
|
||||
string: fDef({
|
||||
optionsSchema: z.object({
|
||||
displayAs: z.union([z.literal("input"), z.literal("textarea")]),
|
||||
minLength: z.number().min(0),
|
||||
maxLength: z.number().optional(),
|
||||
}),
|
||||
defaultOptions: {
|
||||
displayAs: "input",
|
||||
minLength: 0,
|
||||
},
|
||||
}),
|
||||
number: fDef({
|
||||
optionsSchema: z.object({
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
}),
|
||||
defaultOptions: {},
|
||||
}),
|
||||
};
|
||||
export type FieldOptions = {
|
||||
[Kind in FieldKind]: z.infer<(typeof Fields)[Kind]["optionsSchema"]>;
|
||||
};
|
||||
|
||||
export type FieldKind = keyof typeof Fields;
|
||||
export const FieldKinds: readonly FieldKind[] = Object.keys(
|
||||
Fields,
|
||||
) as readonly FieldKind[];
|
||||
|
||||
export const defaultOptionsForFieldKind = <K extends FieldKind>(
|
||||
kind: K,
|
||||
): (typeof Fields)[K]["defaultOptions"] => Fields[kind].defaultOptions;
|
||||
|
||||
type ZodField<Kind extends FieldKind> = z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
required: z.ZodBoolean;
|
||||
kind: z.ZodLiteral<Kind>;
|
||||
options: (typeof Fields)[Kind]["optionsSchema"];
|
||||
}>;
|
||||
|
||||
type AtLeastTwo<V> = [V, V, ...V[]];
|
||||
type ZodFields = {
|
||||
[Kind in FieldKind]: ZodField<Kind>;
|
||||
}[FieldKind];
|
||||
|
||||
const makeZodFields = (): AtLeastTwo<ZodFields> => {
|
||||
const keys = Object.keys(Fields) as FieldKind[];
|
||||
|
||||
return keys.map((k) => {
|
||||
const zf: ZodField<typeof k> = z.object({
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
kind: z.literal(k),
|
||||
options: Fields[k].optionsSchema,
|
||||
});
|
||||
|
||||
return zf;
|
||||
}) as any;
|
||||
};
|
||||
|
||||
export const Field = z.union(makeZodFields());
|
||||
export type Field = z.infer<typeof Field>;
|
||||
|
||||
const SchemaBase = {
|
||||
id: z.number(),
|
||||
fields: z.array(Field),
|
||||
name: z.string(),
|
||||
};
|
||||
export const Schema = z.object(SchemaBase);
|
||||
|
||||
export type ID<Brand extends string, T = number> = T & {
|
||||
readonly [P in Brand]: never;
|
||||
};
|
||||
export type RefineID<T, IDT> = Omit<T, "id"> & {
|
||||
id: IDT;
|
||||
};
|
||||
|
||||
export type SchemaID = ID<"__schema_id">;
|
||||
export type Schema = RefineID<z.infer<typeof Schema>, SchemaID>;
|
||||
|
||||
export const MaybePersistedSchema = z.object({
|
||||
...SchemaBase,
|
||||
id: z.number().optional(),
|
||||
});
|
||||
export type MaybePersistedSchema = z.infer<typeof MaybePersistedSchema>;
|
||||
|
||||
export type ValidationErrors_<T> = ValidationErrors<Omit<T, "id">>;
|
||||
export const validateSchema = (
|
||||
schema: Const<MaybePersistedSchema>,
|
||||
): ValidationErrors_<MaybePersistedSchema> => {
|
||||
const errors = autoProxy<ValidateSchemaResult>({});
|
||||
|
||||
const fieldNameMap = new Map<string, { idx: number; count: number }>();
|
||||
for (let i = 0; i < schema.fields.length; ++i) {
|
||||
const f = schema.fields[i];
|
||||
if (!fieldNameMap.has(f.name)) {
|
||||
fieldNameMap.set(f.name, { idx: i, count: 0 });
|
||||
}
|
||||
|
||||
const ent = fieldNameMap.get(f.name)!;
|
||||
ent.count++;
|
||||
ent.idx = i;
|
||||
}
|
||||
|
||||
for (const [, v] of fieldNameMap) {
|
||||
if (v.count > 1) {
|
||||
errors.fields[v.idx].name = ["Duplicate field name"];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < schema.fields.length; ++i) {
|
||||
const field = schema.fields[i];
|
||||
if (field.name == "") {
|
||||
errors.fields[i].name = ["Field name cannot be empty"];
|
||||
}
|
||||
|
||||
const kind = field.kind;
|
||||
if (kind == "number") {
|
||||
const options = field.options;
|
||||
if (options.max && options.min && options.min >= options.max) {
|
||||
(errors.fields[i].options as any).min = [
|
||||
"Min must be less than or equal to max",
|
||||
];
|
||||
}
|
||||
} else if (kind == "string") {
|
||||
const options = field.options;
|
||||
if (options.maxLength && options.maxLength < options.minLength) {
|
||||
(errors.fields[i].options as any).maxLength = [
|
||||
"Max length must be greater than or equal to min",
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.name == "") {
|
||||
errors.name = ["Name cannot be empty"];
|
||||
}
|
||||
|
||||
// TODO: validate that schemas are named uniquely
|
||||
|
||||
return unproxy(errors);
|
||||
};
|
||||
export type ValidateSchemaResult = ReturnType<typeof validateSchema>;
|
@ -0,0 +1,29 @@
|
||||
import { z } from "./deps/zod.ts";
|
||||
|
||||
export type ZMapLiteral<T extends readonly string[]> = {
|
||||
[K in keyof T]: z.ZodLiteral<T[K]>;
|
||||
};
|
||||
|
||||
export const zMapLiteral = <T extends readonly string[]>(
|
||||
items: T,
|
||||
): ZMapLiteral<T> => {
|
||||
return items.map((i) => z.literal(i)) as ZMapLiteral<T>;
|
||||
};
|
||||
|
||||
export const zUnionLiterals = <
|
||||
T extends readonly [string, string, ...string[]],
|
||||
>(
|
||||
items: T,
|
||||
): z.ZodUnion<ZMapLiteral<T>> => {
|
||||
return z.union(zMapLiteral(items));
|
||||
};
|
||||
|
||||
export type Const<T> = {
|
||||
readonly [K in keyof T]: Const<T[K]>;
|
||||
};
|
||||
export type Unconst<T> = [T] extends [Const<infer U>] ? U : never;
|
||||
|
||||
export type ReturnTypes<T extends ((...args: any) => any)[]> = {
|
||||
[K in keyof T]: ReturnType<T[K]>;
|
||||
}[number];
|
||||
type A = ReturnTypes<[() => number, () => string]>;
|
@ -0,0 +1,29 @@
|
||||
type Join<K, P> = K extends string | number
|
||||
? P extends string | number
|
||||
? `${K}.${P}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
type Paths<T> = T extends object
|
||||
? {
|
||||
[K in keyof T]-?: K extends string | number
|
||||
? T[K] extends object
|
||||
? `${K}` | Join<K, Paths<T[K]>>
|
||||
: `${K}`
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
export type ValidationErrorsObj<T extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof T]: ValidationErrors<T[K]>;
|
||||
}>;
|
||||
|
||||
export type ValidationErrorsArray<T extends unknown[]> = Partial<{
|
||||
[K in keyof T]: ValidationErrors<T[K]>;
|
||||
}>;
|
||||
|
||||
export type ValidationErrors<T> = T extends Record<string, unknown>
|
||||
? ValidationErrorsObj<T>
|
||||
: T extends unknown[]
|
||||
? ValidationErrorsArray<T>
|
||||
: string[];
|
@ -0,0 +1,120 @@
|
||||
{
|
||||
"version": "2",
|
||||
"remote": {
|
||||
"https://deno.land/std@0.131.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
|
||||
"https://deno.land/std@0.131.0/_util/os.ts": "49b92edea1e82ba295ec946de8ffd956ed123e2948d9bd1d3e901b04e4307617",
|
||||
"https://deno.land/std@0.131.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3",
|
||||
"https://deno.land/std@0.131.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09",
|
||||
"https://deno.land/std@0.131.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b",
|
||||
"https://deno.land/std@0.131.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633",
|
||||
"https://deno.land/std@0.131.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee",
|
||||
"https://deno.land/std@0.131.0/path/mod.ts": "4275129bb766f0e475ecc5246aa35689eeade419d72a48355203f31802640be7",
|
||||
"https://deno.land/std@0.131.0/path/posix.ts": "663e4a6fe30a145f56aa41a22d95114c4c5582d8b57d2d7c9ed27ad2c47636bb",
|
||||
"https://deno.land/std@0.131.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9",
|
||||
"https://deno.land/std@0.131.0/path/win32.ts": "e7bdf63e8d9982b4d8a01ef5689425c93310ece950e517476e22af10f41a136e",
|
||||
"https://deno.land/std@0.149.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
|
||||
"https://deno.land/std@0.149.0/_util/os.ts": "3b4c6e27febd119d36a416d7a97bd3b0251b77c88942c8f16ee5953ea13e2e49",
|
||||
"https://deno.land/std@0.149.0/media_types/_util.ts": "ce9b4fc4ba1c447dafab619055e20fd88236ca6bdd7834a21f98bd193c3fbfa1",
|
||||
"https://deno.land/std@0.149.0/media_types/mod.ts": "2d4b6f32a087029272dc59e0a55ae3cc4d1b27b794ccf528e94b1925795b3118",
|
||||
"https://deno.land/std@0.149.0/media_types/vendor/mime-db.v1.52.0.ts": "724cee25fa40f1a52d3937d6b4fbbfdd7791ff55e1b7ac08d9319d5632c7f5af",
|
||||
"https://deno.land/std@0.149.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3",
|
||||
"https://deno.land/std@0.149.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09",
|
||||
"https://deno.land/std@0.149.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b",
|
||||
"https://deno.land/std@0.149.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633",
|
||||
"https://deno.land/std@0.149.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee",
|
||||
"https://deno.land/std@0.149.0/path/mod.ts": "4945b430b759b0b3d98f2a278542cbcf95e0ad2bd8eaaed3c67322b306b2b346",
|
||||
"https://deno.land/std@0.149.0/path/posix.ts": "c1f7afe274290ea0b51da07ee205653b2964bd74909a82deb07b69a6cc383aaa",
|
||||
"https://deno.land/std@0.149.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9",
|
||||
"https://deno.land/std@0.149.0/path/win32.ts": "bd7549042e37879c68ff2f8576a25950abbfca1d696d41d82c7bca0b7e6f452c",
|
||||
"https://deno.land/std@0.162.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
|
||||
"https://deno.land/std@0.162.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934",
|
||||
"https://deno.land/std@0.162.0/fmt/colors.ts": "9e36a716611dcd2e4865adea9c4bec916b5c60caad4cdcdc630d4974e6bb8bd4",
|
||||
"https://deno.land/std@0.162.0/fs/_util.ts": "fdc156f897197f261a1c096dcf8ff9267ed0ff42bd5b31f55053a4763a4bae3b",
|
||||
"https://deno.land/std@0.162.0/fs/copy.ts": "73bdf24f4322648d9bc38ef983b818637ba368351d17aa03644209d3ce3eac31",
|
||||
"https://deno.land/std@0.162.0/fs/empty_dir.ts": "c15a0aaaf40f8c21cca902aa1e01a789ad0c2fd1b7e2eecf4957053c5dbf707f",
|
||||
"https://deno.land/std@0.162.0/fs/ensure_dir.ts": "76395fc1c989ca8d2de3aedfa8240eb8f5225cde20f926de957995b063135b80",
|
||||
"https://deno.land/std@0.162.0/fs/ensure_file.ts": "b8e32ea63aa21221d0219760ba3f741f682d7f7d68d0d24a3ec067c338568152",
|
||||
"https://deno.land/std@0.162.0/fs/ensure_link.ts": "5cc1c04f18487d7d1edf4c5469705f30b61390ffd24ad7db6df85e7209b32bb2",
|
||||
"https://deno.land/std@0.162.0/fs/ensure_symlink.ts": "5273557b8c50be69477aa9cb003b54ff2240a336db52a40851c97abce76b96ab",
|
||||
"https://deno.land/std@0.162.0/fs/eol.ts": "65b1e27320c3eec6fb653b27e20056ee3d015d3e91db388cfefa41616ebc7cb3",
|
||||
"https://deno.land/std@0.162.0/fs/exists.ts": "6a447912e49eb79cc640adacfbf4b0baf8e17ede6d5bed057062ce33c4fa0d68",
|
||||
"https://deno.land/std@0.162.0/fs/expand_glob.ts": "d3f62aefc7718d878904d60d91e8e6dbbf86c696d32b6cbbc333637acf7f8571",
|
||||
"https://deno.land/std@0.162.0/fs/mod.ts": "354a6f972ef4e00c4dd1f1339a8828ef0764c1c23d3c0010af3fcc025d8655b0",
|
||||
"https://deno.land/std@0.162.0/fs/move.ts": "6d7fa9da60dbc7a32dd7fdbc2ff812b745861213c8e92ba96dace0669b0c378c",
|
||||
"https://deno.land/std@0.162.0/fs/walk.ts": "d96d4e5b6a3552e8304f28a0fd0b317b812298298449044f8de4932c869388a5",
|
||||
"https://deno.land/std@0.162.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3",
|
||||
"https://deno.land/std@0.162.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09",
|
||||
"https://deno.land/std@0.162.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677",
|
||||
"https://deno.land/std@0.162.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633",
|
||||
"https://deno.land/std@0.162.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee",
|
||||
"https://deno.land/std@0.162.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac",
|
||||
"https://deno.land/std@0.162.0/path/posix.ts": "6b63de7097e68c8663c84ccedc0fd977656eb134432d818ecd3a4e122638ac24",
|
||||
"https://deno.land/std@0.162.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9",
|
||||
"https://deno.land/std@0.162.0/path/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d",
|
||||
"https://deno.land/std@0.162.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c",
|
||||
"https://deno.land/std@0.162.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832",
|
||||
"https://deno.land/std@0.162.0/testing/asserts.ts": "1e340c589853e82e0807629ba31a43c84ebdcdeca910c4a9705715dfdb0f5ce8",
|
||||
"https://deno.land/std@0.182.0/async/abortable.ts": "fd682fa46f3b7b16b4606a5ab52a7ce309434b76f820d3221bdfb862719a15d7",
|
||||
"https://deno.land/std@0.182.0/async/deadline.ts": "c5facb0b404eede83e38bd2717ea8ab34faa2ffb20ef87fd261fcba32ba307aa",
|
||||
"https://deno.land/std@0.182.0/async/debounce.ts": "adab11d04ca38d699444ac8a9d9856b4155e8dda2afd07ce78276c01ea5a4332",
|
||||
"https://deno.land/std@0.182.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8",
|
||||
"https://deno.land/std@0.182.0/async/delay.ts": "73aa04cec034c84fc748c7be49bb15cac3dd43a57174bfdb7a4aec22c248f0dd",
|
||||
"https://deno.land/std@0.182.0/async/mod.ts": "f04344fa21738e5ad6bea37a6bfffd57c617c2d372bb9f9dcfd118a1b622e576",
|
||||
"https://deno.land/std@0.182.0/async/mux_async_iterator.ts": "70c7f2ee4e9466161350473ad61cac0b9f115cff4c552eaa7ef9d50c4cbb4cc9",
|
||||
"https://deno.land/std@0.182.0/async/pool.ts": "fd082bd4aaf26445909889435a5c74334c017847842ec035739b4ae637ae8260",
|
||||
"https://deno.land/std@0.182.0/async/retry.ts": "dd19d93033d8eaddbfcb7654c0366e9d3b0a21448bdb06eba4a7d8a8cf936a92",
|
||||
"https://deno.land/std@0.182.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757",
|
||||
"https://deno.land/std@0.182.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e",
|
||||
"https://deno.land/std@0.182.0/http/server.ts": "cbb17b594651215ba95c01a395700684e569c165a567e4e04bba327f41197433",
|
||||
"https://deno.land/x/denoflate@1.2.1/mod.ts": "f5628e44b80b3d80ed525afa2ba0f12408e3849db817d47a883b801f9ce69dd6",
|
||||
"https://deno.land/x/denoflate@1.2.1/pkg/denoflate.js": "b9f9ad9457d3f12f28b1fb35c555f57443427f74decb403113d67364e4f2caf4",
|
||||
"https://deno.land/x/denoflate@1.2.1/pkg/denoflate_bg.wasm.js": "d581956245407a2115a3d7e8d85a9641c032940a8e810acbd59ca86afd34d44d",
|
||||
"https://deno.land/x/denosass@1.0.6/mod.ts": "5e9c142055d658f3acb2b370d0b412c783ed4b27db830f387525fb7f69a7ab3d",
|
||||
"https://deno.land/x/denosass@1.0.6/src/deps.ts": "cb5fa11799e3def8b593be3b5939d2755a2c7f1f4987f3af1bc4ad90922d3715",
|
||||
"https://deno.land/x/denosass@1.0.6/src/mod.ts": "d2b63172f98238f77831995a5d6c8a06af5252ad8fbe7b9ec40b60eae86f2164",
|
||||
"https://deno.land/x/denosass@1.0.6/src/types/module.types.ts": "7a5027482ded1d2967fbe690ef8f928446c5de8811c3333f9b09ae6e8122f9ba",
|
||||
"https://deno.land/x/denosass@1.0.6/src/wasm/grass.deno.js": "a72432ce8d6b8f9c31e31c71415fdca03fe36aa22417e414bc81e2e21a8a687b",
|
||||
"https://deno.land/x/esbuild@v0.17.15/mod.d.ts": "dc279a3a46f084484453e617c0cabcd5b8bd1920c0e562e4ea02dfc828c8f968",
|
||||
"https://deno.land/x/esbuild@v0.17.17/mod.d.ts": "dc279a3a46f084484453e617c0cabcd5b8bd1920c0e562e4ea02dfc828c8f968",
|
||||
"https://deno.land/x/esbuild@v0.17.5/mod.d.ts": "dc279a3a46f084484453e617c0cabcd5b8bd1920c0e562e4ea02dfc828c8f968",
|
||||
"https://deno.land/x/esbuild@v0.17.5/mod.js": "dc1fca58bbb66e7e87d2234d6518a718ef54019525c2523506cbb6aa619eaa98",
|
||||
"https://deno.land/x/esbuild_plugin_http_imports@v1.2.4/index.ts": "a71e0483757a0c838bd6799c101cfe7a25513fd4a4905870b0d1d35d2ed96af3",
|
||||
"https://deno.land/x/esbuild_plugin_http_imports@v1.3.0/index.ts": "85a9ca7359b59d51a3324e54422b35a223cc490656a0c266944583bf51580d3a",
|
||||
"https://deno.land/x/esbuild_serve@1.2.3/features/httpImports.ts": "c91205899f4d0019db5355c7e8cdf872491a286e87702e378e7ca8c3353d0eab",
|
||||
"https://deno.land/x/zod@v3.17.10/ZodError.ts": "8a5272bdd5e7ac8194a3ddb5a12ab21e036275e28905d9469b16965335f4af19",
|
||||
"https://deno.land/x/zod@v3.17.10/external.ts": "6f79b9f9cd6a8ba8dce13bce680e2875995dff3849b6ed1c184cf53ef26df954",
|
||||
"https://deno.land/x/zod@v3.17.10/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c",
|
||||
"https://deno.land/x/zod@v3.17.10/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7",
|
||||
"https://deno.land/x/zod@v3.17.10/helpers/parseUtil.ts": "bc678327682dbd98a989fdf7dd14905791c7bbc0e7794e13140bc308a3c47387",
|
||||
"https://deno.land/x/zod@v3.17.10/helpers/partialUtil.ts": "8dc921a02b47384cf52217c7e539268daf619f89319b75bdf13ea178815725df",
|
||||
"https://deno.land/x/zod@v3.17.10/helpers/typeAliases.ts": "a1a8d039eb98925f242f5ea1e21e6d3cabd7f05e9747680165c914695c979b4f",
|
||||
"https://deno.land/x/zod@v3.17.10/helpers/util.ts": "0337f18e0d3a05d4101ad46bd759ac21a6884ea49abdcd1548ea1a80eeea35d0",
|
||||
"https://deno.land/x/zod@v3.17.10/index.ts": "035a7422d9f2be54daa0fe464254b69225b443000673e4794095d672471e8792",
|
||||
"https://deno.land/x/zod@v3.17.10/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4",
|
||||
"https://deno.land/x/zod@v3.17.10/types.ts": "e365eed55953d0deaa40b29e3003247c866ccf58bbd8fc2f87c42d3cd620d075",
|
||||
"https://deno.land/x/zod@v3.21.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea",
|
||||
"https://deno.land/x/zod@v3.21.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef",
|
||||
"https://deno.land/x/zod@v3.21.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe",
|
||||
"https://deno.land/x/zod@v3.21.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c",
|
||||
"https://deno.land/x/zod@v3.21.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7",
|
||||
"https://deno.land/x/zod@v3.21.4/helpers/parseUtil.ts": "51a76c126ee212be86013d53a9d07f87e9ae04bb1496f2558e61b62cb74a6aa8",
|
||||
"https://deno.land/x/zod@v3.21.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7",
|
||||
"https://deno.land/x/zod@v3.21.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e",
|
||||
"https://deno.land/x/zod@v3.21.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774",
|
||||
"https://deno.land/x/zod@v3.21.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268",
|
||||
"https://deno.land/x/zod@v3.21.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c",
|
||||
"https://deno.land/x/zod@v3.21.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4",
|
||||
"https://deno.land/x/zod@v3.21.4/types.ts": "b5d061babea250de14fc63764df5b3afa24f2b088a1d797fc060ba49a0ddff28",
|
||||
"https://esm.sh/preact@10.13.2": "5ebf0838bbc3c32fc6e78a4cd9dd672f2c36386d0595815d721a6b0941278488",
|
||||
"https://esm.sh/preact@10.13.2/hooks": "884334b1560448cf16b4f14841fffdb8707615373a3c76c676a6f9e5c77e43b2",
|
||||
"https://esm.sh/stable/preact@10.13.2/deno/hooks.js": "c7a8e703bcbc6a05949f329b618c33d5d1ea5fee113ddcea44ff0f527af8556f",
|
||||
"https://esm.sh/stable/preact@10.13.2/deno/preact.mjs": "365fab897381f4f403f859c5d12939084560545567108cc90dae901bbe892578",
|
||||
"https://esm.sh/v118/preact@10.13.2/hooks/src/index.d.ts": "5c29febb624fc25d71cb0e125848c9b711e233337a08f7eacfade38fd4c14cc3",
|
||||
"https://esm.sh/v118/preact@10.13.2/src/index.d.ts": "65398710de6aa0a07412da79784e05e6e96763f51c7c91b77344d2d0af06385c",
|
||||
"https://esm.sh/v118/preact@10.13.2/src/jsx.d.ts": "9ac9b82c199fa7b04748807d750eba1a106c0be52041b8617416f88d6fc0a257",
|
||||
"https://git.idylls.net/idylls/shirt/raw/tag/2022.07.27/deps/mediaTypes.ts": "a962ebd6aaa5b2f908bf8aca7f926aedd42cda09f59b928d1a5dbc27dd5a9f99",
|
||||
"https://git.idylls.net/idylls/shirt/raw/tag/2022.07.27/deps/path.ts": "1181eaa5350db0c5461678e53ed796122bcc4a1e7fd43d49fcc50be80e2fff88",
|
||||
"https://git.idylls.net/idylls/shirt/raw/tag/2022.07.27/mod.ts": "0a9bd75a2d7982526df49d800224ee6bf06aee7cb16f9a8d217220c6bdbbbfdb",
|
||||
"https://git.idylls.net/idylls/yaypi/raw/tag/2022.07.27/mod.ts": "5ed6326b9f440e7a36266dbeb255d1f32d24a65d7fd2454e2d10355bd96e54c5"
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
fd . . | rg -v build | entr -cr ./build_and_serve.sh
|
@ -0,0 +1,33 @@
|
||||
import { unreachable } from "../../../common/lib/dev.ts";
|
||||
import { Reduce } from "../../state/lib/store.ts";
|
||||
import { Action as HomeAction, reduce as reduceHome } from "../Home/actions.ts";
|
||||
import {
|
||||
Action as SchemaEditorAction,
|
||||
reduce as reduceSchemaEditor,
|
||||
} from "../SchemaEditor/actions.ts";
|
||||
import { AppState } from "./view.ts";
|
||||
|
||||
export type Action = HomeAction | SchemaEditorAction;
|
||||
|
||||
export const reduce: Reduce<AppState, Action> = (state, action) => {
|
||||
if (state.view.kind == "Home") {
|
||||
return {
|
||||
view: reduceHome(state.view.state, action as HomeAction),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.view.kind == "SchemaEditor") {
|
||||
return {
|
||||
...state,
|
||||
view: {
|
||||
...state.view,
|
||||
state: reduceSchemaEditor(
|
||||
state.view.state,
|
||||
action as SchemaEditorAction,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return unreachable();
|
||||
};
|
@ -0,0 +1,57 @@
|
||||
import { Const } from "../../../common/utils.ts";
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import { Dispatch } from "../../state/lib/store.ts";
|
||||
import { Home, State as HomeState } from "../Home/view.ts";
|
||||
import {
|
||||
SchemaEditor,
|
||||
State as SchemaEditorState,
|
||||
} from "../SchemaEditor/view.ts";
|
||||
import { CmpPaintComponent, Paint } from "../component.ts";
|
||||
import { Action } from "./actions.ts";
|
||||
|
||||
type ViewState_<Kind extends string, T> = { kind: Kind; state: T };
|
||||
|
||||
export type ViewState =
|
||||
| ViewState_<"Home", HomeState>
|
||||
| ViewState_<"SchemaEditor", SchemaEditorState>;
|
||||
export type ViewStateKind = ViewState["kind"];
|
||||
|
||||
export type AppState = {
|
||||
view: ViewState;
|
||||
};
|
||||
|
||||
type ViewRegistry_ = {
|
||||
[K in ViewStateKind]: ViewState & { kind: K };
|
||||
};
|
||||
type ViewRegistry = {
|
||||
// @ts-ignore: Weird
|
||||
[K in keyof ViewRegistry_]: CmpPaintComponent<ViewRegistry_[K]["state"]>;
|
||||
};
|
||||
|
||||
export class App extends CmpPaintComponent<AppState> {
|
||||
private viewRegistry: ViewRegistry;
|
||||
|
||||
constructor(private dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.viewRegistry = {
|
||||
SchemaEditor: new SchemaEditor(this.dispatch),
|
||||
Home: new Home(this.dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
private container = swel("div", { className: "app" });
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<AppState> = (cur) => {
|
||||
const view = this.viewRegistry[cur.view.kind];
|
||||
if (!this.previousModel || cur.view.kind != this.previousModel.view.kind) {
|
||||
this.container.replaceChildren(view.el);
|
||||
}
|
||||
|
||||
view.paint(cur.view.state as any as never);
|
||||
};
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { unreachable } from "../../../common/lib/dev.ts";
|
||||
import { Const, ReturnTypes } from "../../../common/utils.ts";
|
||||
import { ViewState } from "../App/view.ts";
|
||||
import { State } from "./view.ts";
|
||||
|
||||
export const newSchema = () => ({ kind: "newSchema" as const });
|
||||
export type Action = ReturnTypes<[typeof newSchema]>;
|
||||
|
||||
export const reduce = (
|
||||
state: Const<State>,
|
||||
action: Action,
|
||||
): Const<ViewState> => {
|
||||
if (action.kind == "newSchema") {
|
||||
return {
|
||||
kind: "SchemaEditor",
|
||||
state: {
|
||||
schema: {
|
||||
fields: [],
|
||||
name: "New schema",
|
||||
},
|
||||
errors: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return unreachable();
|
||||
};
|
@ -0,0 +1,30 @@
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import { Dispatch } from "../../state/lib/store.ts";
|
||||
import { CmpPaintComponent, Paint } from "../component.ts";
|
||||
import { Action, newSchema } from "./actions.ts";
|
||||
|
||||
export type State = undefined;
|
||||
|
||||
export class Home extends CmpPaintComponent<State> {
|
||||
private newSchemaButton = swel(
|
||||
"button",
|
||||
{
|
||||
on: { click: () => this.dispatch(newSchema()) },
|
||||
},
|
||||
"New schema",
|
||||
);
|
||||
|
||||
private container = swel("div", { className: "home" }, [
|
||||
this.newSchemaButton,
|
||||
]);
|
||||
|
||||
constructor(private dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<State> = (cur) => {};
|
||||
}
|
@ -0,0 +1,324 @@
|
||||
import {
|
||||
FieldKind,
|
||||
Field as FieldModel,
|
||||
FieldOptions as FieldOptionsModels,
|
||||
} from "../../../common/schema.ts";
|
||||
import { ValidationErrors } from "../../../common/validation.ts";
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import { Dispatch, OnChange } from "../../state/lib/store.ts";
|
||||
import {
|
||||
CmpPaintComponent,
|
||||
ComponentModel,
|
||||
Paint,
|
||||
ShouldPaint,
|
||||
} from "../component.ts";
|
||||
import { TextInput } from "../lib/input/TextInput.ts";
|
||||
import { Validated } from "../lib/input/Validated.ts";
|
||||
import { Action, setFieldOptionProp, setFieldProp } from "./actions.ts";
|
||||
import { RecyclerList } from "../lib/RecyclerList.ts";
|
||||
import { Select } from "../lib/input/Select.ts";
|
||||
import { NumberInput } from "../lib/input/NumberInput.ts";
|
||||
import { Callback } from "../../utils.ts";
|
||||
import { LabeledCheckbox } from "../lib/input/LabeledCheckbox.ts";
|
||||
import { ShowHide, ShowHideModel } from "../lib/ShowHide.ts";
|
||||
import { Labeled } from "../lib/Labeled.ts";
|
||||
|
||||
class ToggleField<
|
||||
Child extends CmpPaintComponent<any>,
|
||||
> extends CmpPaintComponent<ShowHideModel<ComponentModel<Child>>> {
|
||||
private container: HTMLDivElement;
|
||||
private toggle: LabeledCheckbox;
|
||||
private field: ShowHide<Child>;
|
||||
|
||||
constructor(
|
||||
public child: Child,
|
||||
p: { label: string; onToggle: Callback<[boolean]> },
|
||||
) {
|
||||
super();
|
||||
|
||||
this.toggle = new LabeledCheckbox(p.label, p);
|
||||
this.field = new ShowHide(child);
|
||||
|
||||
this.container = swel("div", { className: "toggle-field" }, [
|
||||
this.toggle.el,
|
||||
this.field.el,
|
||||
]);
|
||||
}
|
||||
|
||||
protected paint_: Paint<ShowHideModel<ComponentModel<Child>>> = (cur) => {
|
||||
this.field.paint(cur);
|
||||
this.toggle.paint(cur.show);
|
||||
};
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
}
|
||||
|
||||
type ValidatedFieldOptions<Kind extends FieldKind> = {
|
||||
kind: Kind;
|
||||
options: FieldOptionsModels[Kind];
|
||||
errors?: ValidationErrors<FieldOptionsModels[Kind]>;
|
||||
};
|
||||
|
||||
class NumberFieldOptions extends CmpPaintComponent<
|
||||
ValidatedFieldOptions<"number">
|
||||
> {
|
||||
private container: HTMLDivElement;
|
||||
private minInput: Validated<ToggleField<NumberInput>>;
|
||||
private maxInput: Validated<ToggleField<NumberInput>>;
|
||||
|
||||
constructor(fieldIndex: number, dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.minInput = new Validated(
|
||||
new ToggleField(
|
||||
new NumberInput({
|
||||
onChange: (n) => {
|
||||
dispatch(setFieldOptionProp(fieldIndex, "min", n));
|
||||
},
|
||||
}),
|
||||
{
|
||||
label: "Min?",
|
||||
onToggle: (b) => {
|
||||
dispatch(setFieldOptionProp(fieldIndex, "min", b ? 0 : undefined));
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.maxInput = new Validated(
|
||||
new ToggleField(
|
||||
new NumberInput({
|
||||
onChange: (n) => {
|
||||
dispatch(setFieldOptionProp(fieldIndex, "max", n));
|
||||
},
|
||||
}),
|
||||
{
|
||||
label: "Max?",
|
||||
onToggle: (b) => {
|
||||
dispatch(setFieldOptionProp(fieldIndex, "max", b ? 0 : undefined));
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.container = swel("div", { className: "number-field-options" }, [
|
||||
this.minInput.el,
|
||||
this.maxInput.el,
|
||||
]);
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected paint_: Paint<ValidatedFieldOptions<"number">> = (cur) => {
|
||||
this.maxInput.paint({
|
||||
model: {
|
||||
show: cur.options.max != undefined,
|
||||
model: cur.options.max,
|
||||
},
|
||||
error: cur.errors?.max?.[0],
|
||||
});
|
||||
|
||||
this.minInput.paint({
|
||||
model: {
|
||||
show: cur.options.min != undefined,
|
||||
model: cur.options.min,
|
||||
},
|
||||
error: cur.errors?.min?.[0],
|
||||
});
|
||||
|
||||
if (cur.options.max) {
|
||||
this.maxInput.child.child.setMin(cur.options.min);
|
||||
}
|
||||
|
||||
if (cur.options.min) {
|
||||
this.minInput.child.child.setMax(cur.options.max);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class StringFieldOptions extends CmpPaintComponent<
|
||||
ValidatedFieldOptions<"string">
|
||||
> {
|
||||
private container: HTMLDivElement;
|
||||
private displayAs: Select<FieldOptionsModels["string"]["displayAs"]>;
|
||||
private minLength: Validated<Labeled<NumberInput>>;
|
||||
private maxLength: Validated<ToggleField<NumberInput>>;
|
||||
|
||||
constructor(fieldIndex: number, dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.displayAs = new Select(
|
||||
{
|
||||
input: "input",
|
||||
textarea: "textarea",
|
||||
},
|
||||
(v) => dispatch(setFieldOptionProp(fieldIndex, "displayAs", v)),
|
||||
);
|
||||
|
||||
this.minLength = new Validated(
|
||||
new Labeled(
|
||||
"Min length",
|
||||
new NumberInput({
|
||||
onChange: (v) =>
|
||||
dispatch(setFieldOptionProp(fieldIndex, "minLength", v)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.minLength.child.child.setMin(0);
|
||||
|
||||
this.maxLength = new Validated(
|
||||
new ToggleField(
|
||||
new NumberInput({
|
||||
onChange: (n) =>
|
||||
dispatch(setFieldOptionProp(fieldIndex, "maxLength", n)),
|
||||
}),
|
||||
{
|
||||
label: "Max length?",
|
||||
onToggle: (b) =>
|
||||
dispatch(
|
||||
setFieldOptionProp(fieldIndex, "maxLength", b ? 0 : undefined),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.container = swel("div", { className: "string-field-options" }, [
|
||||
this.displayAs.el,
|
||||
this.minLength.el,
|
||||
this.maxLength.el,
|
||||
]);
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected paint_: Paint<ValidatedFieldOptions<"string">> = (cur) => {
|
||||
this.displayAs.paint(cur.options.displayAs);
|
||||
this.minLength.paint({
|
||||
model: cur.options.minLength,
|
||||
error: cur.errors?.minLength?.[0],
|
||||
});
|
||||
this.minLength.child.child.setMax(cur.options.maxLength);
|
||||
|
||||
this.maxLength.paint({
|
||||
model: {
|
||||
show: cur.options.maxLength != undefined,
|
||||
model: cur.options.maxLength,
|
||||
},
|
||||
error: cur.errors?.maxLength?.[0],
|
||||
});
|
||||
this.maxLength.child.child.setMin(cur.options.minLength);
|
||||
};
|
||||
}
|
||||
|
||||
type FieldOptionComponentRegistry = {
|
||||
[K in FieldKind]: CmpPaintComponent<ValidatedFieldOptions<K>>;
|
||||
};
|
||||
|
||||
class FieldOptions extends CmpPaintComponent<ValidatedFieldOptions<FieldKind>> {
|
||||
private fieldOptionComponents: FieldOptionComponentRegistry;
|
||||
private container = swel("div", { className: "field-options" });
|
||||
|
||||
constructor(fieldIndex: number, dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.fieldOptionComponents = {
|
||||
number: new NumberFieldOptions(fieldIndex, dispatch),
|
||||
string: new StringFieldOptions(fieldIndex, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected paint_: Paint<ValidatedFieldOptions<FieldKind>> = (cur) => {
|
||||
const view = this.fieldOptionComponents[cur.kind];
|
||||
if (!this.previousModel || this.previousModel.kind != cur.kind) {
|
||||
this.container.replaceChildren(view.el);
|
||||
}
|
||||
|
||||
view.paint(cur as any);
|
||||
};
|
||||
}
|
||||
|
||||
export type ValidatedField = {
|
||||
field: FieldModel;
|
||||
errors?: ValidationErrors<FieldModel>;
|
||||
};
|
||||
|
||||
class Field extends CmpPaintComponent<ValidatedField> {
|
||||
private container: HTMLDivElement;
|
||||
private nameInput: Validated<TextInput>;
|
||||
private kindSelect: Select<FieldKind>;
|
||||
private fieldOptions: FieldOptions;
|
||||
|
||||
constructor(fieldIndex: number, dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.nameInput = new Validated(
|
||||
new TextInput({
|
||||
onInput: (s) => dispatch(setFieldProp(fieldIndex, "name", s)),
|
||||
}),
|
||||
);
|
||||
|
||||
this.kindSelect = new Select(
|
||||
{ string: "string", number: "number" },
|
||||
(v) => {
|
||||
dispatch(setFieldProp(fieldIndex, "kind", v));
|
||||
},
|
||||
);
|
||||
|
||||
this.fieldOptions = new FieldOptions(fieldIndex, dispatch);
|
||||
|
||||
this.container = swel("div", { className: "field" }, [
|
||||
this.nameInput.el,
|
||||
this.kindSelect.el,
|
||||
this.fieldOptions.el,
|
||||
]);
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<ValidatedField> = (cur) => {
|
||||
this.nameInput.paint({
|
||||
model: cur.field.name,
|
||||
error: cur.errors?.["name"]?.[0],
|
||||
});
|
||||
|
||||
this.kindSelect.paint(cur.field.kind);
|
||||
|
||||
this.fieldOptions.paint({
|
||||
kind: cur.field.kind,
|
||||
options: cur.field.options,
|
||||
errors: cur.errors?.options,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class Fields extends CmpPaintComponent<ValidatedField[]> {
|
||||
private container: HTMLDivElement;
|
||||
private list: RecyclerList<ValidatedField>;
|
||||
|
||||
constructor(dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.list = new RecyclerList((i) => new Field(i, dispatch));
|
||||
this.container = swel("div", { className: "fields" }, [this.list.el]);
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<ValidatedField[]> = (cur) => {
|
||||
this.list.paint(cur);
|
||||
};
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
import { unreachable } from "../../../common/lib/dev.ts";
|
||||
import { push, setIdx, setProp } from "../../../common/lib/imm.ts";
|
||||
import {
|
||||
Field,
|
||||
FieldKind,
|
||||
FieldOptions,
|
||||
MaybePersistedSchema,
|
||||
defaultOptionsForFieldKind,
|
||||
validateSchema,
|
||||
} from "../../../common/schema.ts";
|
||||
import { Const, ReturnTypes } from "../../../common/utils.ts";
|
||||
import { Reduce } from "../../state/lib/store.ts";
|
||||
import { State } from "./view.ts";
|
||||
|
||||
export const setName = (name: string) => ({ setName: name });
|
||||
export const addField = () => "addField" as const;
|
||||
export const setFieldProp = <P extends keyof Field>(
|
||||
index: number,
|
||||
prop: P,
|
||||
value: Field[P],
|
||||
) => ({
|
||||
index,
|
||||
prop,
|
||||
value,
|
||||
});
|
||||
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
||||
k: infer I,
|
||||
) => void
|
||||
? I
|
||||
: never;
|
||||
export type AllOptions = UnionToIntersection<FieldOptions[FieldKind]>;
|
||||
export const setFieldOptionProp = <P extends keyof AllOptions>(
|
||||
index: number,
|
||||
optionProp: P,
|
||||
value: Required<AllOptions[P]>,
|
||||
) => ({
|
||||
index,
|
||||
optionProp,
|
||||
value,
|
||||
});
|
||||
export type Action = ReturnTypes<
|
||||
[
|
||||
typeof setName,
|
||||
typeof addField,
|
||||
typeof setFieldProp,
|
||||
typeof setFieldOptionProp,
|
||||
]
|
||||
>;
|
||||
|
||||
const reduceSchema = (
|
||||
schema: Const<MaybePersistedSchema>,
|
||||
action: Action,
|
||||
): Const<MaybePersistedSchema> => {
|
||||
console.debug(action);
|
||||
if (action == "addField") {
|
||||
return setProp(
|
||||
schema,
|
||||
"fields",
|
||||
push(schema.fields, {
|
||||
kind: "string",
|
||||
name: "New field",
|
||||
options: defaultOptionsForFieldKind("string"),
|
||||
required: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("setName" in action) {
|
||||
return setProp(schema, "name", action.setName);
|
||||
}
|
||||
|
||||
if ("prop" in action) {
|
||||
let field = setProp(schema.fields[action.index], action.prop, action.value);
|
||||
|
||||
if (action.prop == "kind") {
|
||||
field = setProp(field, "options", {
|
||||
...defaultOptionsForFieldKind(action.value as Field["kind"]),
|
||||
...field.options,
|
||||
});
|
||||
}
|
||||
|
||||
return setProp(
|
||||
schema,
|
||||
"fields",
|
||||
setIdx(schema.fields, action.index, field),
|
||||
);
|
||||
}
|
||||
|
||||
if ("optionProp" in action) {
|
||||
return setProp(
|
||||
schema,
|
||||
"fields",
|
||||
setIdx(
|
||||
schema.fields,
|
||||
action.index,
|
||||
setProp(
|
||||
schema.fields[action.index],
|
||||
"options",
|
||||
setProp(
|
||||
schema.fields[action.index].options as any,
|
||||
action.optionProp as never,
|
||||
action.value as never,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return unreachable();
|
||||
};
|
||||
|
||||
export const reduce: Reduce<State, Action> = (state, action) => {
|
||||
const schema = reduceSchema(state.schema, action);
|
||||
const errors = validateSchema(schema);
|
||||
|
||||
return { schema, errors };
|
||||
};
|
@ -0,0 +1,71 @@
|
||||
import {
|
||||
MaybePersistedSchema,
|
||||
ValidateSchemaResult,
|
||||
validateSchema,
|
||||
} from "../../../common/schema.ts";
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import { Dispatch } from "../../state/lib/store.ts";
|
||||
import { CmpPaintComponent, Paint } from "../component.ts";
|
||||
import { TextInput } from "../lib/input/TextInput.ts";
|
||||
import { Validated } from "../lib/input/Validated.ts";
|
||||
import { Fields } from "./Fields.ts";
|
||||
import { Action, setName, addField } from "./actions.ts";
|
||||
|
||||
export type State = {
|
||||
schema: MaybePersistedSchema;
|
||||
errors: ValidateSchemaResult;
|
||||
};
|
||||
|
||||
export class SchemaEditor extends CmpPaintComponent<State> {
|
||||
private nameInput: Validated<string, TextInput>;
|
||||
private fields: Fields;
|
||||
private container: HTMLDivElement;
|
||||
|
||||
constructor(dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.nameInput = new Validated(
|
||||
new TextInput({
|
||||
onInput: (s) => dispatch(setName(s)),
|
||||
}),
|
||||
);
|
||||
|
||||
const addFieldBtn = swel(
|
||||
"button",
|
||||
{
|
||||
on: {
|
||||
click: () => {
|
||||
dispatch(addField());
|
||||
},
|
||||
},
|
||||
},
|
||||
"Add field",
|
||||
);
|
||||
|
||||
this.fields = new Fields(dispatch);
|
||||
|
||||
this.container = swel("div", { className: "schema-editor" }, [
|
||||
this.nameInput.el,
|
||||
this.fields.el,
|
||||
addFieldBtn,
|
||||
]);
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<State> = (cur) => {
|
||||
this.nameInput.paint({
|
||||
model: cur.schema.name,
|
||||
error: cur.errors.name?.[0],
|
||||
});
|
||||
|
||||
const fields = cur.schema.fields.map((f, i) => ({
|
||||
field: f,
|
||||
errors: cur.errors.fields?.[i],
|
||||
}));
|
||||
|
||||
this.fields.paint(fields);
|
||||
};
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import { Const } from "../../common/utils.ts";
|
||||
|
||||
export type ShouldPaint<Model> = (model: Const<Model>) => boolean;
|
||||
export type Paint<Model> = (model: Const<Model>) => void;
|
||||
|
||||
export abstract class Component<El = HTMLElement> {
|
||||
abstract get el(): El;
|
||||
}
|
||||
|
||||
export abstract class ModelComponent<
|
||||
Model = never,
|
||||
El = HTMLElement,
|
||||
> extends Component<El> {}
|
||||
|
||||
export abstract class PaintComponent<
|
||||
Model = never,
|
||||
El = HTMLElement,
|
||||
> extends ModelComponent<El> {
|
||||
abstract paint: Paint<Model>;
|
||||
}
|
||||
|
||||
export abstract class CmpPaintComponent<
|
||||
Model = never,
|
||||
El = HTMLElement,
|
||||
> extends PaintComponent<Const<Model>, El> {
|
||||
protected previousModel: Model | undefined;
|
||||
|
||||
paint: Paint<Model> = (model) => {
|
||||
if (this.shouldPaint(model)) {
|
||||
this.paint_(model);
|
||||
}
|
||||
|
||||
this.previousModel = model as any;
|
||||
};
|
||||
|
||||
protected shouldPaint: ShouldPaint<Model> = (cur) => {
|
||||
return cur != this.previousModel;
|
||||
};
|
||||
|
||||
protected abstract paint_: Paint<Model>;
|
||||
}
|
||||
|
||||
export type ComponentModel<T extends ModelComponent<any>> =
|
||||
T extends ModelComponent<infer M> ? Const<M> : never;
|
@ -0,0 +1,92 @@
|
||||
import { swel } from "../deps/swel.ts";
|
||||
import { Callback } from "../utils.ts";
|
||||
|
||||
export class TextInputWithValidation {
|
||||
private input = swel("input");
|
||||
private errorText = swel("div", { className: "error-message" }, " ");
|
||||
public el = swel("div", { className: "validated-input" }, [
|
||||
this.input,
|
||||
this.errorText,
|
||||
]);
|
||||
|
||||
set placeholder(s: string) {
|
||||
this.input.placeholder = s;
|
||||
}
|
||||
|
||||
set onInput(cb: Callback<[string]>) {
|
||||
this.input.oninput = () => cb(this.input.value);
|
||||
}
|
||||
|
||||
set error(msg: string | undefined) {
|
||||
if (msg) {
|
||||
this.el.classList.add("error");
|
||||
this.errorText.replaceChildren(msg);
|
||||
} else {
|
||||
this.el.classList.remove("error");
|
||||
this.errorText.replaceChildren(" ");
|
||||
}
|
||||
}
|
||||
|
||||
set value(v: string) {
|
||||
this.input.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
export class LabeledCheckbox {
|
||||
private checkbox = swel("input", { type: "checkbox" });
|
||||
private labelText: ReturnType<typeof document.createTextNode>;
|
||||
private label: HTMLLabelElement;
|
||||
|
||||
constructor(p: { labelText: string; checked?: boolean }) {
|
||||
this.checkbox.checked = p.checked ?? false;
|
||||
this.labelText = document.createTextNode(p.labelText);
|
||||
this.label = swel("label", [this.labelText, this.checkbox]);
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.label;
|
||||
}
|
||||
|
||||
set checked(c: boolean) {
|
||||
this.checkbox.checked = c;
|
||||
}
|
||||
|
||||
set onChange(cb: Callback<[boolean]>) {
|
||||
this.checkbox.onchange = () => cb(this.checkbox.checked);
|
||||
}
|
||||
}
|
||||
|
||||
export type Choice<V> = {
|
||||
value: V;
|
||||
display: string;
|
||||
};
|
||||
|
||||
export class LabeledChoices<V> {
|
||||
private label: HTMLLabelElement;
|
||||
private select: HTMLSelectElement;
|
||||
private _choices: Choice<V>[] = [];
|
||||
|
||||
constructor(labelText: string) {
|
||||
this.select = swel("select");
|
||||
this.label = swel("label", [labelText, this.select]);
|
||||
}
|
||||
|
||||
set choices(choices: typeof this._choices) {
|
||||
this._choices = choices;
|
||||
this.select.replaceChildren(
|
||||
...this._choices.map((c, i) =>
|
||||
swel("option", { value: `${i}` }, c.display),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.label;
|
||||
}
|
||||
|
||||
set onChange(cb: Callback<[V]>) {
|
||||
this.select.onchange = () => {
|
||||
cb(this._choices[Number(this.select.value)].value);
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import {
|
||||
CmpPaintComponent,
|
||||
ComponentModel,
|
||||
Paint,
|
||||
PaintComponent,
|
||||
} from "../component.ts";
|
||||
|
||||
export class Labeled<
|
||||
Child extends PaintComponent<any>,
|
||||
> extends CmpPaintComponent<ComponentModel<Child>> {
|
||||
private label: HTMLLabelElement;
|
||||
|
||||
constructor(text: string, public child: Child) {
|
||||
super();
|
||||
|
||||
this.label = swel("label", {}, [text, this.child.el]);
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.label;
|
||||
}
|
||||
|
||||
paint_: Paint<ComponentModel<Child>> = (c) => {
|
||||
this.child.paint(c);
|
||||
};
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import { CmpPaintComponent, Paint } from "../component.ts";
|
||||
|
||||
export class RecyclerList<T> extends CmpPaintComponent<T[]> {
|
||||
private views: CmpPaintComponent<T>[] = [];
|
||||
|
||||
constructor(
|
||||
private makeChild: (index: number) => CmpPaintComponent<T>,
|
||||
private container = swel("div"),
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<T[]> = (cur) => {
|
||||
const prev = this.previousModel;
|
||||
|
||||
if (!prev) {
|
||||
this.views = cur.map((c, i) => {
|
||||
const view = this.makeChild(i);
|
||||
view.paint(c);
|
||||
this.container.appendChild(view.el);
|
||||
|
||||
return view;
|
||||
});
|
||||
} else if (cur.length > prev.length) {
|
||||
let i = 0;
|
||||
for (; i < prev.length; ++i) {
|
||||
const c = cur[i];
|
||||
|
||||
this.views[i].paint(c);
|
||||
}
|
||||
|
||||
for (; i < cur.length; ++i) {
|
||||
const c = cur[i];
|
||||
|
||||
this.views[i] ??= this.makeChild(i);
|
||||
const view = this.views[i];
|
||||
view.paint(c);
|
||||
this.container.appendChild(view.el);
|
||||
}
|
||||
} else if (cur.length <= prev.length) {
|
||||
let i = 0;
|
||||
for (; i < cur.length; ++i) {
|
||||
const c = cur[i];
|
||||
|
||||
const view = this.views[i];
|
||||
view.paint(c);
|
||||
}
|
||||
|
||||
for (; i < prev.length; ++i) {
|
||||
this.container.children[i].remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import { ComponentModel, Paint } from "../component.ts";
|
||||
import { CmpPaintComponent } from "../component.ts";
|
||||
|
||||
export type ShowHideModel<T> = {
|
||||
show: boolean;
|
||||
model: T;
|
||||
};
|
||||
|
||||
export class ShowHide<
|
||||
Child extends CmpPaintComponent<any>,
|
||||
> extends CmpPaintComponent<ShowHideModel<ComponentModel<Child>>> {
|
||||
private container: HTMLDivElement;
|
||||
|
||||
constructor(private child: Child) {
|
||||
super();
|
||||
|
||||
this.container = swel("div", {}, this.child.el);
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected paint_: Paint<ShowHideModel<ComponentModel<Child>>> = (cur) => {
|
||||
if (cur.show) {
|
||||
this.child.paint(cur.model);
|
||||
if (!this.previousModel?.show) {
|
||||
this.container.replaceChildren(this.child.el);
|
||||
}
|
||||
} else {
|
||||
this.container.replaceChildren("");
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
import { Callback } from "../../../utils.ts";
|
||||
import { CmpPaintComponent, Paint } from "../../component.ts";
|
||||
|
||||
export class LabeledCheckbox extends CmpPaintComponent<boolean> {
|
||||
private label: HTMLLabelElement;
|
||||
private input: HTMLInputElement;
|
||||
|
||||
constructor(label: string, p: { onToggle: Callback<[boolean]> }) {
|
||||
super();
|
||||
|
||||
this.input = swel("input", {
|
||||
type: "checkbox",
|
||||
on: { change: () => p.onToggle(this.input.checked) },
|
||||
});
|
||||
|
||||
this.label = swel("label", {}, [label, this.input]);
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.label;
|
||||
}
|
||||
|
||||
protected paint_: Paint<boolean> = (cur) => {
|
||||
this.input.checked = cur;
|
||||
};
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
import { Callback } from "../../../utils.ts";
|
||||
import { CmpPaintComponent, Paint } from "../../component.ts";
|
||||
|
||||
export class NumberInput extends CmpPaintComponent<number> {
|
||||
private input: HTMLInputElement = swel("input", { type: "number" });
|
||||
|
||||
constructor(p?: Partial<{ onChange: Callback<[number]> }>) {
|
||||
super();
|
||||
|
||||
if (p?.onChange) {
|
||||
this.input.addEventListener("input", () =>
|
||||
p.onChange!(Number(this.input.value)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.input;
|
||||
}
|
||||
|
||||
setMin = (n?: number) => {
|
||||
this.input.min = `${n ?? ""}`;
|
||||
};
|
||||
|
||||
setMax = (n?: number) => {
|
||||
this.input.max = `${n ?? ""}`;
|
||||
};
|
||||
|
||||
paint_: Paint<number> = (cur) => {
|
||||
this.input.value = `${cur}`;
|
||||
};
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
import { Callback } from "../../../utils.ts";
|
||||
import { CmpPaintComponent, Paint } from "../../component.ts";
|
||||
|
||||
export type Options<Values extends string> = Record<Values, string>;
|
||||
|
||||
export class Select<Values extends string> extends CmpPaintComponent<Values> {
|
||||
private select: HTMLSelectElement;
|
||||
|
||||
constructor(options: Options<Values>, onChange: Callback<[Values]>) {
|
||||
super();
|
||||
|
||||
this.select = swel(
|
||||
"select",
|
||||
{
|
||||
on: {
|
||||
change: () => onChange(this.select.value as Values),
|
||||
},
|
||||
},
|
||||
(Object.keys(options) as (keyof typeof options)[]).map((o) =>
|
||||
swel("option", { value: `${o}` }, options[o]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.select;
|
||||
}
|
||||
|
||||
protected paint_: Paint<Values> = (cur) => {
|
||||
this.select.value = cur as string;
|
||||
};
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
import { Callback } from "../../../utils.ts";
|
||||
import { CmpPaintComponent, Paint } from "../../component.ts";
|
||||
|
||||
export class TextInput extends CmpPaintComponent<string> {
|
||||
private input: HTMLInputElement = swel("input");
|
||||
|
||||
constructor(p?: Partial<{ onInput: Callback<[string]> }>) {
|
||||
super();
|
||||
|
||||
if (p?.onInput) {
|
||||
this.input.addEventListener("input", () => p.onInput!(this.input.value));
|
||||
}
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.input;
|
||||
}
|
||||
|
||||
paint_: Paint<string> = (cur) => {
|
||||
this.input.value = cur;
|
||||
};
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
import { CmpPaintComponent, ComponentModel, Paint } from "../../component.ts";
|
||||
|
||||
export type ValidatedModel<T> = {
|
||||
model: T;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class Validated<
|
||||
Child extends CmpPaintComponent<any>,
|
||||
> extends CmpPaintComponent<ValidatedModel<ComponentModel<Child>>> {
|
||||
private container: HTMLDivElement;
|
||||
private errorMessage = swel("div", { className: "error-message" });
|
||||
|
||||
constructor(public child: Child) {
|
||||
super();
|
||||
|
||||
this.container = swel("div", { className: "validated" }, [
|
||||
(this.child as any).el,
|
||||
this.errorMessage,
|
||||
]);
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<ValidatedModel<ComponentModel<Child>>> = ({ model, error }) => {
|
||||
if (error) {
|
||||
this.container.classList.add("error");
|
||||
this.errorMessage.replaceChildren(error);
|
||||
} else {
|
||||
this.container.classList.remove("error");
|
||||
this.errorMessage.replaceChildren(" ");
|
||||
}
|
||||
|
||||
(this.child as any).paint(model);
|
||||
};
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { swel } from "../deps/swel.ts";
|
||||
|
||||
export const Loader = (p: Promise<HTMLElement>) => {
|
||||
const el = swel("div", { className: "loader" }, "Loading...");
|
||||
p.then((e) => el.replaceWith(e));
|
||||
|
||||
return el;
|
||||
};
|
@ -0,0 +1,42 @@
|
||||
import { Opts, swel } from "../deps/swel.ts";
|
||||
|
||||
export const WithValidationError = (p: { c: HTMLElement }) => {
|
||||
const errorMsg = swel("div", { className: "error-message" }, "Space");
|
||||
const wrapper = swel("div", { className: "with-validation-error" }, [
|
||||
p.c,
|
||||
errorMsg,
|
||||
]);
|
||||
|
||||
return {
|
||||
el: wrapper,
|
||||
updateError: (e?: string) => {
|
||||
if (e) {
|
||||
errorMsg.innerText = e;
|
||||
wrapper.classList.add("error");
|
||||
} else {
|
||||
wrapper.classList.remove("error");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const InputWithValidationError = (opts: Opts<"input">) => {
|
||||
const input = swel("input", opts);
|
||||
const withError = WithValidationError({ c: input });
|
||||
|
||||
return {
|
||||
...withError,
|
||||
input,
|
||||
};
|
||||
};
|
||||
|
||||
export const LabeledCheckbox = (p: {
|
||||
labelText: string;
|
||||
labelOpts?: Opts<"label">;
|
||||
inputOpts?: Omit<Opts<"input">, "type">;
|
||||
}) => {
|
||||
const input = swel("input", { ...p.inputOpts, type: "checkbox" });
|
||||
const label = swel("label", p.labelOpts, [input, p.labelText]);
|
||||
|
||||
return { label, input };
|
||||
};
|
@ -0,0 +1,50 @@
|
||||
import { swel } from "../deps/swel.ts";
|
||||
|
||||
export class RecyclerList<View, Model> {
|
||||
private activeViews = 0;
|
||||
private _views: View[] = [];
|
||||
private container: HTMLElement;
|
||||
|
||||
constructor(
|
||||
private createView: () => View,
|
||||
private getViewDOMElement: (view: View) => HTMLElement,
|
||||
private bindModel: (view: View, model: Model, index: number) => void,
|
||||
container?: HTMLElement,
|
||||
) {
|
||||
this.container = container ?? swel("div", { className: "recycler-list" });
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
changed(models: Model[]) {
|
||||
if (this.activeViews < models.length) {
|
||||
let i = 0;
|
||||
for (; i < this.activeViews; ++i) {
|
||||
const view = this._views[i];
|
||||
this.bindModel(view, models[i], i);
|
||||
}
|
||||
|
||||
for (; i < models.length; ++i) {
|
||||
const view = this.createView();
|
||||
this._views.push(view);
|
||||
this.bindModel(view, models[i], i);
|
||||
|
||||
this.container.appendChild(this.getViewDOMElement(view));
|
||||
}
|
||||
} else {
|
||||
let i = 0;
|
||||
for (; i < models.length; ++i) {
|
||||
const view = this._views[i];
|
||||
this.bindModel(view, models[i], i);
|
||||
}
|
||||
|
||||
for (; i < this.activeViews; ++i) {
|
||||
this.container.removeChild(this.container.childNodes.item(i));
|
||||
}
|
||||
}
|
||||
|
||||
this.activeViews = models.length;
|
||||
}
|
||||
}
|
@ -0,0 +1,272 @@
|
||||
import {
|
||||
Field,
|
||||
FieldKind,
|
||||
FieldKinds,
|
||||
FieldOptions,
|
||||
MaybePersistedSchema,
|
||||
ValidateSchemaResult,
|
||||
defaultOptionsForFieldKind,
|
||||
validateSchema,
|
||||
} from "../../common/schema.ts";
|
||||
import { swel } from "../deps/swel.ts";
|
||||
import { Callback, Update } from "../utils.ts";
|
||||
import {
|
||||
LabeledChoices,
|
||||
LabeledCheckbox,
|
||||
TextInputWithValidation,
|
||||
} from "./input.ts";
|
||||
import { RecyclerList } from "./recycler_list.ts";
|
||||
|
||||
class StringOptionsEditor {
|
||||
private displayAs = new LabeledChoices<FieldOptions["string"]["displayAs"]>(
|
||||
"Display as",
|
||||
);
|
||||
private container = swel("div", { className: "string-options" }, [
|
||||
this.displayAs.el,
|
||||
]);
|
||||
|
||||
private _updateOptions: Update<FieldOptions["string"]> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.displayAs.choices = [
|
||||
{
|
||||
value: "input",
|
||||
display: "input",
|
||||
},
|
||||
{
|
||||
value: "textarea",
|
||||
display: "textarea",
|
||||
},
|
||||
];
|
||||
|
||||
this.displayAs.onChange = (s) =>
|
||||
this._updateOptions?.((o) => (o.displayAs = s));
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
set updateOptions(u: typeof this._updateOptions) {
|
||||
this._updateOptions = u;
|
||||
}
|
||||
|
||||
optionsChanged(options: FieldOptions["string"]) {}
|
||||
}
|
||||
class NumberOptionsEditor {
|
||||
private container = swel("div", { className: "number-options" });
|
||||
|
||||
private _updateOptions: Update<FieldOptions["string"]> | null = null;
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
set updateOptions(u: typeof this._updateOptions) {
|
||||
this._updateOptions = u;
|
||||
}
|
||||
|
||||
optionsChanged(options: FieldOptions["number"]) {}
|
||||
}
|
||||
|
||||
class FieldOptionsEditor {
|
||||
private fieldKindToView = {
|
||||
string: new StringOptionsEditor(),
|
||||
number: new NumberOptionsEditor(),
|
||||
};
|
||||
|
||||
private container = swel("div", { className: "options-editor" });
|
||||
|
||||
private _updateField: Update<Field> | null = null;
|
||||
set updateField(update: Update<Field>) {
|
||||
this._updateField = update;
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
Object.values(this.fieldKindToView).forEach((v) => {
|
||||
v.updateOptions = (cb) =>
|
||||
this._updateField?.((v) => cb(v.options as any));
|
||||
});
|
||||
}
|
||||
|
||||
fieldChanged(field: Field) {
|
||||
const view = this.fieldKindToView[field.kind];
|
||||
view.optionsChanged(field.options as any);
|
||||
|
||||
this.container.replaceChildren(view.el);
|
||||
}
|
||||
}
|
||||
|
||||
class FieldEditor {
|
||||
private nameInput = new TextInputWithValidation();
|
||||
private kind = new LabeledChoices<FieldKind>("Kind");
|
||||
private required = new LabeledCheckbox({ labelText: "Required" });
|
||||
private optionsEditor = new FieldOptionsEditor();
|
||||
|
||||
private container = swel("div", { className: "field" }, [
|
||||
this.nameInput.el,
|
||||
this.kind.el,
|
||||
this.optionsEditor.el,
|
||||
this.required.el,
|
||||
]);
|
||||
private _updateField: Update<Field> | null = null;
|
||||
set updateField(update: Update<Field>) {
|
||||
this._updateField = update;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.nameInput.placeholder = "Field name";
|
||||
|
||||
this.kind.choices = FieldKinds.map((k) => ({
|
||||
display: k,
|
||||
value: k,
|
||||
}));
|
||||
|
||||
this.nameInput.onInput = (s) => this._updateField?.((f) => (f.name = s));
|
||||
this.required.onChange = (s) =>
|
||||
this._updateField?.((f) => (f.required = s));
|
||||
this.kind.onChange = (k) =>
|
||||
this._updateField?.((f) => {
|
||||
f.kind = k;
|
||||
f.options = defaultOptionsForFieldKind(k);
|
||||
});
|
||||
|
||||
this.optionsEditor.updateField = (cb) => this._updateField?.((f) => cb(f));
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
set checked(c: boolean) {
|
||||
this.required.checked = c;
|
||||
}
|
||||
|
||||
fieldChanged(field: Field, error: string | undefined) {
|
||||
this.nameInput.value = field.name;
|
||||
this.nameInput.error = error;
|
||||
|
||||
this.optionsEditor.fieldChanged(field);
|
||||
}
|
||||
}
|
||||
|
||||
class FieldEditors {
|
||||
private _updateField: ((index: number) => Update<Field>) | null = null;
|
||||
|
||||
private list = new RecyclerList(
|
||||
() => new FieldEditor(),
|
||||
(view) => view.el,
|
||||
(view, model: [Field, string | undefined], index) => {
|
||||
const [field, error] = model;
|
||||
|
||||
view.fieldChanged(field, error);
|
||||
view.updateField = (field) => this._updateField?.(index)(field);
|
||||
},
|
||||
);
|
||||
|
||||
private addFieldButton = swel(
|
||||
"button",
|
||||
{
|
||||
className: "add",
|
||||
},
|
||||
"Add field",
|
||||
);
|
||||
private container = swel("div", { className: "fields" }, [
|
||||
this.list.el,
|
||||
this.addFieldButton,
|
||||
]);
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
set updateField(fn: Exclude<typeof this._updateField, null>) {
|
||||
this._updateField = fn;
|
||||
}
|
||||
|
||||
set onAdd(cb: Callback<[Field]>) {
|
||||
this.addFieldButton.onclick = () =>
|
||||
cb({
|
||||
kind: "string",
|
||||
options: defaultOptionsForFieldKind("string"),
|
||||
name: "",
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fieldsChanged(fields: Field[], errors: ValidateSchemaResult) {
|
||||
this.list.changed(
|
||||
fields.map((f, i) => [f, errors[`fields.${i}.name`]?.[0]]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Editor {
|
||||
private nameInput = new TextInputWithValidation();
|
||||
private fields = new FieldEditors();
|
||||
private container = swel("div", { className: "schema-editor" }, [
|
||||
this.nameInput.el,
|
||||
this.fields.el,
|
||||
]);
|
||||
private _updateSchema: Update<MaybePersistedSchema> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.nameInput.placeholder = "Schema name";
|
||||
|
||||
this.nameInput.onInput = (s) => {
|
||||
this._updateSchema?.((sc) => (sc.name = s));
|
||||
};
|
||||
|
||||
this.fields.onAdd = (f) => {
|
||||
this._updateSchema?.((sc) => sc.fields.push(f));
|
||||
};
|
||||
|
||||
this.fields.updateField = (idx) => (cb) => {
|
||||
this._updateSchema?.((sc) => cb(sc.fields[idx]));
|
||||
};
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
set updateSchema(cb: Update<MaybePersistedSchema>) {
|
||||
this._updateSchema = cb;
|
||||
}
|
||||
|
||||
schemaChanged(schema: MaybePersistedSchema, errors: ValidateSchemaResult) {
|
||||
this.nameInput.value = schema.name;
|
||||
this.nameInput.error = errors.name?.[0];
|
||||
|
||||
this.fields.fieldsChanged(schema.fields, errors);
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaEditor {
|
||||
private editor = new Editor();
|
||||
|
||||
get el() {
|
||||
return this.editor.el;
|
||||
}
|
||||
|
||||
editSchema(schema: MaybePersistedSchema) {
|
||||
const schemaChanged = () => {
|
||||
const errors = validateSchema(schema);
|
||||
|
||||
this.editor.schemaChanged(schema, errors);
|
||||
};
|
||||
|
||||
const updateSchema = (cb: Callback<[typeof schema]>) => {
|
||||
cb(schema);
|
||||
console.debug({ schema });
|
||||
schemaChanged();
|
||||
};
|
||||
|
||||
this.editor.updateSchema = updateSchema;
|
||||
schemaChanged();
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from "../../../swel/mod.ts";
|
@ -0,0 +1,16 @@
|
||||
import { App } from "./components/App/view.ts";
|
||||
import { LOCAL_STORAGE_KEY, initStore } from "./state/store.ts";
|
||||
|
||||
const init = () => {
|
||||
let app: App | null = null;
|
||||
const { dispatch } = initStore((cur, prev) => app?.paint(cur));
|
||||
app = new App(dispatch);
|
||||
|
||||
document.body.replaceChildren(app.el);
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
(window as any)["resetApp"] = () => {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||
location.reload();
|
||||
};
|
@ -0,0 +1 @@
|
||||
export type Actions = null;
|
@ -0,0 +1,45 @@
|
||||
import { Const } from "../../../common/utils.ts";
|
||||
|
||||
export type OnChange<State, Returning = void> = (
|
||||
current: Const<State>,
|
||||
prev?: Const<State>,
|
||||
) => Returning;
|
||||
export type Reduce<State, Actions> = (
|
||||
current: Const<State>,
|
||||
action: Actions,
|
||||
) => Const<State>;
|
||||
export type Dispatch<Actions> = (action: Actions) => void;
|
||||
|
||||
export type Store<Actions> = {
|
||||
dispatch: Dispatch<Actions>;
|
||||
};
|
||||
|
||||
export const createStore = <State, Actions>(
|
||||
initialState: Const<State>,
|
||||
reduce: Reduce<State, Actions>,
|
||||
onChange: OnChange<State>,
|
||||
): Store<Actions> => {
|
||||
let dirty = true;
|
||||
let prevState: Const<State> | undefined = undefined;
|
||||
let state = initialState;
|
||||
|
||||
const doChange = () => {
|
||||
console.assert(dirty);
|
||||
dirty = false;
|
||||
|
||||
onChange(state, prevState);
|
||||
prevState = state;
|
||||
};
|
||||
requestAnimationFrame(doChange);
|
||||
|
||||
const dispatch = (action: Actions) => {
|
||||
if (!dirty) {
|
||||
dirty = true;
|
||||
requestAnimationFrame(doChange);
|
||||
}
|
||||
|
||||
state = reduce(state, action);
|
||||
};
|
||||
|
||||
return { dispatch };
|
||||
};
|
@ -0,0 +1 @@
|
||||
import { AppState } from "./state.ts";
|
@ -0,0 +1,34 @@
|
||||
import { Const } from "../../common/utils.ts";
|
||||
import { OnChange, createStore } from "./lib/store.ts";
|
||||
import { Action, reduce } from "../components/App/actions.ts";
|
||||
import { AppState } from "../components/App/view.ts";
|
||||
|
||||
const defaultAppState: AppState = {
|
||||
view: { kind: "Home", state: undefined },
|
||||
};
|
||||
|
||||
export const LOCAL_STORAGE_KEY = "zchemaAppState";
|
||||
const saveStateToLocalStorage = (state: Const<AppState>) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
|
||||
};
|
||||
|
||||
const loadStateFromLocalStorage = (): AppState | null => {
|
||||
const r = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (r == null) {
|
||||
return r;
|
||||
}
|
||||
|
||||
return JSON.parse(r);
|
||||
};
|
||||
|
||||
export const initStore = (paint: OnChange<AppState>) => {
|
||||
const state = loadStateFromLocalStorage() ?? defaultAppState;
|
||||
|
||||
const store = createStore<AppState, Action>(state, reduce, (cur, prev) => {
|
||||
console.debug({ state: cur, prev });
|
||||
saveStateToLocalStorage(cur);
|
||||
paint(cur, prev);
|
||||
});
|
||||
|
||||
return store;
|
||||
};
|
@ -0,0 +1,56 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: sans-serif;
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
// Browser Elements
|
||||
input, button, select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
// Page Layout
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// Components
|
||||
.nav-chrome {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.schema-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
|
||||
.name {
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.validated {
|
||||
.error-message {
|
||||
color: red;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.error {
|
||||
.error-message {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
input {
|
||||
box-shadow: 0px 0px 2px 3px red;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
import { Const } from "../common/utils.ts";
|
||||
|
||||
export type Callback<T extends unknown[]> = (...args: T) => unknown;
|
||||
export type Update<T> = (cb: (value: T) => void) => void;
|
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
printWidth: 80,
|
||||
useTabs: true,
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
quoteProps: "as-needed",
|
||||
trailingComma: "all",
|
||||
bracketSpacing: true,
|
||||
arrowParens: "always",
|
||||
parser: "typescript",
|
||||
};
|
Loading…
Reference in New Issue