diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5a5ca5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build +build/ +build/* diff --git a/README.md b/README.md index 3ea9c44..6460834 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# zontent +# zchema -A simple but flexible CMS \ No newline at end of file +A simple but flexible CMS diff --git a/_build_frontend.ts b/_build_frontend.ts new file mode 100644 index 0000000..c3ee66f --- /dev/null +++ b/_build_frontend.ts @@ -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.2.4/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) => { + 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); diff --git a/backend/api.ts b/backend/api.ts new file mode 100644 index 0000000..8bdc1ae --- /dev/null +++ b/backend/api.ts @@ -0,0 +1,11 @@ +import { Api, replacer } from "../common/api.ts"; +import { MakeHandlers, makeServe } from "../common/deps/yaypi.ts"; + +const handlers: MakeHandlers = { + v1: { + listSchemas: async (limit) => [], + saveSchema: async (schema) => ({ ...schema, id: 0 }), + }, +}; + +export const serveApi = makeServe(Api, handlers, "/api", replacer); diff --git a/backend/deps/shirt.ts b/backend/deps/shirt.ts new file mode 100644 index 0000000..c14a7c5 --- /dev/null +++ b/backend/deps/shirt.ts @@ -0,0 +1 @@ +export * from "../../../shirt/mod.ts"; diff --git a/backend/deps/std.ts b/backend/deps/std.ts new file mode 100644 index 0000000..5d6506e --- /dev/null +++ b/backend/deps/std.ts @@ -0,0 +1 @@ +export { serve } from "https://deno.land/std@0.182.0/http/server.ts"; diff --git a/backend/main.ts b/backend/main.ts new file mode 100644 index 0000000..c04d9ae --- /dev/null +++ b/backend/main.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 ` + + + `; + } + + return ` + + + `; +}; + +const BODY = ` + + + +zchema +${renderHeadResourceLinks()} + + + + + +`; + +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, + }); + }, +}); diff --git a/build_and_serve.sh b/build_and_serve.sh new file mode 100755 index 0000000..1b8472e --- /dev/null +++ b/build_and_serve.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +deno run -A _build_frontend.ts +deno run -A backend/main.ts diff --git a/common/api.ts b/common/api.ts new file mode 100644 index 0000000..3f1f4b3 --- /dev/null +++ b/common/api.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; +}; diff --git a/common/deps/yaypi.ts b/common/deps/yaypi.ts new file mode 100644 index 0000000..6fa950a --- /dev/null +++ b/common/deps/yaypi.ts @@ -0,0 +1 @@ +export * from "../../../yaypi/mod.ts"; diff --git a/common/deps/zod.ts b/common/deps/zod.ts new file mode 100644 index 0000000..a20060f --- /dev/null +++ b/common/deps/zod.ts @@ -0,0 +1 @@ +export * as z from "https://deno.land/x/zod@v3.21.4/mod.ts"; diff --git a/common/persisted.ts b/common/persisted.ts new file mode 100644 index 0000000..fb6516c --- /dev/null +++ b/common/persisted.ts @@ -0,0 +1,2 @@ +export type MaybePersisted = Omit & + Partial>; diff --git a/common/schema.ts b/common/schema.ts new file mode 100644 index 0000000..bc69155 --- /dev/null +++ b/common/schema.ts @@ -0,0 +1,78 @@ +import { z } from "./deps/zod.ts"; +import { zUnionLiterals } from "./utils.ts"; +import { ValidationErrors } from "./validation.ts"; + +export const FieldKinds = ["string" as const, "textarea" as const] as const; + +export const FieldKind = zUnionLiterals(FieldKinds); +export type FieldKind = z.infer; + +export const FieldShape = z.object({ + name: z.string(), + kind: FieldKind, + required: z.boolean(), +}); +export type FieldShape = z.infer; + +const SchemaBase = { + id: z.number(), + fields: z.array(FieldShape), + name: z.string(), +}; +export const Schema = z.object(SchemaBase); + +export type ID = T & { + readonly [P in Brand]: never; +}; +export type RefineID = Omit & { + id: IDT; +}; + +export type SchemaID = ID<"__schema_id">; +export type Schema = RefineID, SchemaID>; + +export const MaybePersistedSchema = z.object({ + ...SchemaBase, + id: z.number().optional(), +}); +export type MaybePersistedSchema = z.infer; + +export type ValidationErrors_ = ValidationErrors>; +export const validateSchema = ( + schema: MaybePersistedSchema, +): ValidationErrors_ => { + const errors: ReturnType = {}; + + const fieldNameMap = new Map(); + 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"]; + } + } + + if (schema.name == "") { + errors.name = ["Name cannot be empty"]; + } + + // TODO: validate that schemas are named uniquely + + return errors; +}; diff --git a/common/utils.ts b/common/utils.ts new file mode 100644 index 0000000..b37c604 --- /dev/null +++ b/common/utils.ts @@ -0,0 +1,23 @@ +import { z } from "./deps/zod.ts"; + +export type ZMapLiteral = { + [K in keyof T]: z.ZodLiteral; +}; + +export const zMapLiteral = ( + items: T, +): ZMapLiteral => { + return items.map((i) => z.literal(i)) as ZMapLiteral; +}; + +export const zUnionLiterals = < + T extends readonly [string, string, ...string[]], +>( + items: T, +): z.ZodUnion> => { + return z.union(zMapLiteral(items)); +}; + +export type Const = { + readonly [K in keyof T]: Const; +}; diff --git a/common/validation.ts b/common/validation.ts new file mode 100644 index 0000000..cf0c676 --- /dev/null +++ b/common/validation.ts @@ -0,0 +1,19 @@ +type Join = K extends string | number + ? P extends string | number + ? `${K}.${P}` + : never + : never; + +type Paths = T extends object + ? { + [K in keyof T]-?: K extends string | number + ? T[K] extends object + ? `${K}` | Join> + : `${K}` + : never; + }[keyof T] + : never; + +export type ValidationErrors> = Partial< + Record, string[]> +>; diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/deno.json @@ -0,0 +1,2 @@ +{ +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..7665656 --- /dev/null +++ b/deno.lock @@ -0,0 +1,108 @@ +{ + "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/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.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/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://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" + } +} diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..b8e369a --- /dev/null +++ b/dev.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +fd . . | rg -v build | entr -cr ./build_and_serve.sh diff --git a/frontend/deps/swel.ts b/frontend/deps/swel.ts new file mode 100644 index 0000000..042532a --- /dev/null +++ b/frontend/deps/swel.ts @@ -0,0 +1 @@ +export * from "../../../swel/mod.ts"; diff --git a/frontend/main.ts b/frontend/main.ts new file mode 100644 index 0000000..54bbb9e --- /dev/null +++ b/frontend/main.ts @@ -0,0 +1,72 @@ +import { makeClient, assert } from "../common/deps/yaypi.ts"; +import { Api, replacer, reviver } from "../common/api.ts"; +import { swel } from "./deps/swel.ts"; +import { SchemaEditorView } from "./schema_editor.ts"; + +const client = makeClient(Api, "/api", replacer, reviver); +export type Client = typeof client; + +type ShowView = (node: HTMLElement) => void; + +const Home = async (p: { showView: ShowView; client: Client }) => { + const schemas = assert(await client.v1.listSchemas(null)); + + const container = swel("div", { className: "home" }, [ + swel("header", "Schemas"), + swel( + "ul", + schemas.map((s) => swel("li", `${s.name}`)), + ), + swel( + "button", + { + on: { click: () => p.showView(SchemaEditorView(p)) }, + }, + "New Schema", + ), + ]); + + return container; +}; + +const init = async () => { + let viewIdx = -1; + const viewStack: HTMLElement[] = []; + + self.addEventListener("popstate", (ev) => { + const state = ev.state as typeof viewIdx; + viewIdx = state; + + const view = viewStack[viewIdx]; + if (!view) { + return; + } + + main.replaceChildren(view); + }); + + const showView = (view: HTMLElement) => { + if (viewIdx == -1) { + history.replaceState(++viewIdx, "", null); + } else { + history.pushState(++viewIdx, "", null); + } + viewStack[viewIdx] = view; + + main.replaceChildren(view); + }; + + const chrome = swel("nav", { className: "nav-chrome" }, [ + swel( + "a", + { on: { click: async () => showView(await Home({ showView, client })) } }, + "Home", + ), + ]); + const main = swel("div", { className: "main" }); + + showView(await Home({ showView, client })); + document.body.replaceChildren(chrome, main); +}; + +document.addEventListener("DOMContentLoaded", init); diff --git a/frontend/schema_editor.ts b/frontend/schema_editor.ts new file mode 100644 index 0000000..a08d3fb --- /dev/null +++ b/frontend/schema_editor.ts @@ -0,0 +1,222 @@ +import { swel } from "./deps/swel.ts"; +import { + type MaybePersistedSchema, + validateSchema, + FieldShape, +} from "../common/schema.ts"; +import { Client } from "./main.ts"; +import { Const } from "../common/utils.ts"; +import { Update } from "./utils.ts"; + +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, + paint: (e?: string) => { + if (e) { + errorMsg.innerText = e; + wrapper.classList.add("error"); + } else { + wrapper.classList.remove("error"); + } + }, + }; +}; + +const FieldElement = () => { + const nameInput = swel("input", { + className: "name", + }); + const nameInputWithValidationError = WithValidationError({ c: nameInput }); + + const deleteButton = swel("button", { className: "delete" }, "Delete"); + + const el = swel("div", { className: "field-element" }, [ + nameInputWithValidationError.el, + deleteButton, + ]); + + const paint = (p: { + field: Const; + errors: Const<{ [K in keyof FieldShape]?: string[] }>; + updateField: Update; + deleteField: () => void; + }) => { + nameInput.value = p.field.name; + nameInput.oninput = () => { + p.updateField((f) => (f.name = nameInput.value)); + }; + deleteButton.onclick = p.deleteField; + nameInputWithValidationError.paint(p.errors.name?.[0]); + }; + + return { + el, + paint, + }; +}; + +const FieldElements = (p: { updateFields: Update }) => { + const elements: ReturnType[] = []; + + const { updateFields } = p; + + let prevLength = 0; + const paint = (p: { + fields: Const; + errors: Const>; + }) => { + const lenDiff = p.fields.length - prevLength; + + if (lenDiff > 0) { + for (let i = prevLength; i < p.fields.length; ++i) { + const fe = FieldElement(); + elements[i] = fe; + el.appendChild(fe.el); + } + } else if (lenDiff < 0) { + for (let i = prevLength - 1; i >= p.fields.length; --i) { + el.removeChild(elements[i].el); + delete elements[i]; + } + } + + for (let i = 0; i < p.fields.length; ++i) { + const field = p.fields[i]; + const element = elements[i]; + const updateField: Update = (fn) => { + updateFields((fs) => { + fn?.(fs[i]); + }); + }; + const deleteField = () => { + updateFields((fs) => { + fs.splice(i, 1); + }); + }; + + element.paint({ + field, + updateField, + deleteField, + errors: { + name: p.errors[`fields.${i}.name`], + kind: p.errors[`fields.${i}.kind`], + required: p.errors[`fields.${i}.required`], + }, + }); + } + + prevLength = p.fields.length; + }; + + const el = swel("div", { className: "field-elements" }); + + return { + el, + paint, + }; +}; + +export const SchemaEditor = (p: { + updateSchema: Update; +}) => { + const nameInput = swel("input", { + className: "name", + on: { + input: () => { + p.updateSchema((sc) => { + sc.name = nameInput.value; + }); + }, + }, + }); + const nameInputWithValidationError = WithValidationError({ c: nameInput }); + + const fieldElements = FieldElements({ + updateFields: (fn) => p.updateSchema((sc) => fn?.(sc.fields)), + }); + const addFieldButton = swel( + "button", + { + className: "add-field", + on: { + click: () => { + p.updateSchema((sc) => { + sc.fields.push({ + kind: "string", + name: "", + required: true, + }); + }); + }, + }, + }, + "Add field", + ); + + const saveButton = swel( + "button", + { + className: "save", + on: { click: () => console.log("save clicked") }, + }, + "Save", + ); + + const controls = swel("div", { className: "controls" }, [ + addFieldButton, + saveButton, + ]); + + const el = swel("div", { className: "schema-editor" }, [ + nameInputWithValidationError.el, + fieldElements.el, + controls, + ]); + + const paint = (p: { + schema: Const; + errors: Const>; + }) => { + nameInput.value = p.schema.name; + saveButton.disabled = Object.keys(p.errors).length > 0; + nameInputWithValidationError.paint(p.errors.name?.[0]); + fieldElements.paint({ fields: p.schema.fields, errors: p.errors }); + }; + + return { paint, el }; +}; + +export const SchemaEditorView = (p: { + maybeSchema?: MaybePersistedSchema; + client: Client; +}) => { + const schema = + p.maybeSchema ?? + ({ + fields: [], + name: "New Schema", + } satisfies MaybePersistedSchema); + + const errors = validateSchema(schema); + + const updateSchema: Update = (fn) => { + fn?.(schema); + const errors = validateSchema(schema); + + paint({ schema, errors }); + }; + + const { paint, el } = SchemaEditor({ + updateSchema, + }); + paint({ schema, errors }); + + return el; +}; diff --git a/frontend/style.scss b/frontend/style.scss new file mode 100644 index 0000000..54b4a2b --- /dev/null +++ b/frontend/style.scss @@ -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; + } +} + +.with-validation-error { + .error-message { + color: red; + visibility: hidden; + } + + &.error { + .error-message { + visibility: visible; + } + + input { + box-shadow: 0px 0px 2px 3px red; + } + } +} diff --git a/frontend/utils.ts b/frontend/utils.ts new file mode 100644 index 0000000..e923d73 --- /dev/null +++ b/frontend/utils.ts @@ -0,0 +1 @@ +export type Update = (fn?: (arg: T) => void) => void; diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 0000000..13a984f --- /dev/null +++ b/prettier.config.js @@ -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", +};