From 5ac1f1be961823bb8456eb139b2b64f651d688f4 Mon Sep 17 00:00:00 2001 From: Kuba Orlik Date: Mon, 17 Feb 2025 17:04:05 +0100 Subject: [PATCH] Add url for editing page --- .../admin/pages/[id]/content.jdd-editor.tsx | 31 +++ .../routes/admin/pages/[id]/delete.page.tsx | 25 +++ src/back/routes/admin/pages/[id]/edit.form.ts | 117 +++++++++++ src/back/routes/admin/pages/create.form.ts | 69 +++++++ src/back/routes/admin/pages/index.list.tsx | 185 ++++++++++++++++++ src/back/routes/admin/pages/shared.ts | 36 ++++ src/back/routes/common/navbar.ts | 9 +- 7 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 src/back/routes/admin/pages/[id]/content.jdd-editor.tsx create mode 100644 src/back/routes/admin/pages/[id]/delete.page.tsx create mode 100644 src/back/routes/admin/pages/[id]/edit.form.ts create mode 100644 src/back/routes/admin/pages/create.form.ts create mode 100644 src/back/routes/admin/pages/index.list.tsx create mode 100644 src/back/routes/admin/pages/shared.ts diff --git a/src/back/routes/admin/pages/[id]/content.jdd-editor.tsx b/src/back/routes/admin/pages/[id]/content.jdd-editor.tsx new file mode 100644 index 0000000..145018e --- /dev/null +++ b/src/back/routes/admin/pages/[id]/content.jdd-editor.tsx @@ -0,0 +1,31 @@ +import type { Context } from "koa"; +import type { FieldNames } from "sealious"; +import { TempstreamJSX } from "tempstream"; +import type Pages from "src/back/collections/pages.js"; +import { EditJDDField } from "@sealcode/jdd-editor"; +import html from "src/back/html.js"; +import { registry } from "src/back/jdd-components/registry.js"; +import { makeJDDContext } from "src/back/jdd-context.js"; +import { defaultHead } from "src/back/defaultHead.js"; + +export const actionName = "EditPageContent"; + +export default new (class JDDCreatePreviewPage extends EditJDDField { + getCollection(ctx: Context) { + return ctx.$app.collections["pages"]; + } + + getJDDFieldName(): FieldNames { + return "content"; + } + + async renderPreParameterButtons(ctx: Context) { + const item = await this.getItem(ctx); + return ( +
+

Edit pages: {item.id}

{" "} +
+ ); + } +})({ html, registry, makeJDDContext, defaultHead }); + diff --git a/src/back/routes/admin/pages/[id]/delete.page.tsx b/src/back/routes/admin/pages/[id]/delete.page.tsx new file mode 100644 index 0000000..69de776 --- /dev/null +++ b/src/back/routes/admin/pages/[id]/delete.page.tsx @@ -0,0 +1,25 @@ +import type { Context } from "koa"; +import { Mountable } from "@sealcode/sealgen"; +import type Router from "@koa/router"; + +import { Pages } from "../../../../collections/collections.js"; + +import { PagesCRUDListURL } from "../../../urls.js"; + +export const actionName = "PagesCRUDDelete"; + +export default new (class PagesCRUDDeleteRedirect extends Mountable { + canAccess = async (ctx: Context) => { + const policy = Pages.getPolicy("edit"); + const response = await policy.check(ctx.$context); + return { canAccess: response?.allowed || false, message: response?.reason || "" }; + }; + + mount(router: Router, path: string) { + router.post(path, async (ctx) => { + await ctx.$app.collections["pages"].removeByID(ctx.$context, ctx.params.id!); + ctx.status = 302; + ctx.redirect(PagesCRUDListURL); + }); + } +})(); diff --git a/src/back/routes/admin/pages/[id]/edit.form.ts b/src/back/routes/admin/pages/[id]/edit.form.ts new file mode 100644 index 0000000..cce5c7a --- /dev/null +++ b/src/back/routes/admin/pages/[id]/edit.form.ts @@ -0,0 +1,117 @@ +import type { Context } from "koa"; +import type { FormData } from "@sealcode/sealgen"; +import { Form, Controls, fieldsToShape } from "@sealcode/sealgen"; +import html from "../../../../html.js"; + +import { PagesFormFields, PagesFormControls } from "../shared.js"; +import { Pages } from "../../../../collections/collections.js"; +import { PagesCRUDListURL } from "../../../urls.js"; +import { tempstream } from "tempstream"; + +import { withFallback } from "@sealcode/sealgen"; + +export const actionName = "PagesCRUDEdit"; + +const fields = { + ...PagesFormFields, +}; + +export const PagesCRUDEditShape = fieldsToShape(fields); + +export default new (class PagesCRUDEditForm extends Form { + defaultSuccessMessage = "Formularz wypełniony poprawnie"; + fields = fields; + + controls = [new Controls.FormHeader("Edit Pages"), ...PagesFormControls]; + + async getID(ctx: Context): Promise { + const param_name = "id"; + const id = ctx.params[param_name]; + if (!id) { + throw new Error("Missing URL parameter: " + param_name); + } + return id; + } + + async getInitialValues(ctx: Context) { + const id = await this.getID(ctx); + + const { + items: [item], + } = await ctx.$app.collections["pages"] + .list(ctx.$context) + .ids([id]) + .attach({}) + .fetch(); + + if (!item) { + throw new Error("Item with given id not found: " + id); + } + + return { + url: item.get("url"), + domain: withFallback(item.get("domain"), ""), + title: withFallback(item.get("title"), ""), + heading: withFallback(item.get("heading"), ""), + description: withFallback(item.get("description"), ""), + imageForMetadata: { old: item.get("imageForMetadata") }, + hideNavigation: withFallback( + item.get("hideNavigation"), + String(item.get("hideNavigation")), + "false" + ), + }; + } + + async onSubmit(ctx: Context) { + const data = await this.getParsedValues(ctx); + const id = await this.getID(ctx); + const { + items: [item], + } = await ctx.$app.collections["pages"].list(ctx.$context).ids([id]).fetch(); + + if (!item) { + throw new Error("Unknown id: " + id); + } + + if (!data) { + throw new Error("Error when parsing the form values"); + } + + const preparedImageForMetadata = + data.imageForMetadata.new || data.imageForMetadata.old; + if (!preparedImageForMetadata) { + throw new Error("Missing field: imageForMetadata"); + } + + item.setMultiple({ + url: data["url"], + content: [], + domain: data["domain"] != null ? data["domain"] : "", + title: data["title"] != null ? data["title"] : "", + heading: data["heading"] != null ? data["heading"] : "", + description: data["description"] != null ? data["description"] : "", + imageForMetadata: preparedImageForMetadata, + hideNavigation: !!data.hideNavigation != null ? !!data.hideNavigation : false, + }); + await item.save(ctx.$context); + } + + canAccess = async (ctx: Context) => { + const policy = Pages.getPolicy("edit"); + const response = await policy.check(ctx.$context); + return { canAccess: response?.allowed || false, message: response?.reason || "" }; + }; + + async render(ctx: Context, data: FormData, show_field_errors: boolean) { + return html({ + ctx, + title: "Edit Pages", + body: tempstream/* HTML */ `
+ ← Back to pages list + ${await super.render(ctx, data, show_field_errors)} +
`, + description: "", + }); + } +})(); diff --git a/src/back/routes/admin/pages/create.form.ts b/src/back/routes/admin/pages/create.form.ts new file mode 100644 index 0000000..01de0fa --- /dev/null +++ b/src/back/routes/admin/pages/create.form.ts @@ -0,0 +1,69 @@ +import type { Context } from "koa"; +import type { FormData } from "@sealcode/sealgen"; +import { Form, Controls, fieldsToShape } from "@sealcode/sealgen"; +import html from "../../../html.js"; + +import { PagesFormFields, PagesFormControls } from "./shared.js"; + +import { Pages } from "../../../collections/collections.js"; + +import { PagesCRUDListURL } from "../../urls.js"; + +import { tempstream } from "tempstream"; + +export const actionName = "PagesCRUDCreate"; + +const fields = { + ...PagesFormFields, +}; + +export const PagesCRUDCreateShape = fieldsToShape(fields); + +export default new (class PagesCRUDCreateForm extends Form { + defaultSuccessMessage = "Formularz wypełniony poprawnie"; + fields = fields; + + controls = [new Controls.FormHeader("Create Pages"), ...PagesFormControls]; + + async onSubmit(ctx: Context) { + const data = await this.getParsedValues(ctx); + if (!data) { + throw new Error("Error when parsing the form values"); + } + + const preparedImageForMetadata = + data.imageForMetadata.new || data.imageForMetadata.old; + if (!preparedImageForMetadata) { + throw new Error("Missing field: imageForMetadata"); + } + + await ctx.$app.collections["pages"].create(ctx.$context, { + url: data["url"], + content: [], + domain: data["domain"] != null ? data["domain"] : "", + title: data["title"] != null ? data["title"] : "", + heading: data["heading"] != null ? data["heading"] : "", + description: data["description"] != null ? data["description"] : "", + imageForMetadata: preparedImageForMetadata, + hideNavigation: !!data.hideNavigation != null ? !!data.hideNavigation : false, + }); + } + + canAccess = async (ctx: Context) => { + const policy = Pages.getPolicy("create"); + const response = await policy.check(ctx.$context); + return { canAccess: response?.allowed || false, message: response?.reason || "" }; + }; + + async render(ctx: Context, data: FormData, show_field_errors: boolean) { + return html({ + ctx, + title: "Create pages", + body: tempstream/* HTML */ `
+ ← Back to pages list + ${await super.render(ctx, data, show_field_errors)} +
`, + description: "", + }); + } +})(); diff --git a/src/back/routes/admin/pages/index.list.tsx b/src/back/routes/admin/pages/index.list.tsx new file mode 100644 index 0000000..501de8f --- /dev/null +++ b/src/back/routes/admin/pages/index.list.tsx @@ -0,0 +1,185 @@ +import type { Context } from "koa"; +import type { CollectionItem } from "sealious"; +import type { FlatTemplatable, Templatable } from "tempstream"; +import { TempstreamJSX, tempstream } from "tempstream"; +import { Pages } from "src/back/collections/collections.js"; +import html from "src/back/html.js"; +import type { ListFilterRender } from "@sealcode/sealgen"; +import { + SealiousItemListPage, + BaseListPageFields, + DefaultListFilters, +} from "@sealcode/sealgen"; +import qs from "qs"; + +import { + PagesCRUDCreateURL, + PagesCRUDEditURL, + PagesCRUDDeleteURL, + EditPageContentURL, +} from "../../urls.js"; + +import type { FilePointer } from "@sealcode/file-manager"; +import { imageRouter } from "src/back/image-router.js"; + +export const actionName = "PagesCRUDList"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const filterFields = [ + { field: "url", ...DefaultListFilters["text"] }, + { field: "content", ...DefaultListFilters.fallback }, + { field: "domain", ...DefaultListFilters["text"] }, + { field: "title", ...DefaultListFilters["text"] }, + { field: "heading", ...DefaultListFilters["text"] }, + { field: "description", ...DefaultListFilters["text"] }, + { field: "imageForMetadata", ...DefaultListFilters.fallback }, + { field: "hideNavigation", ...DefaultListFilters["boolean"] }, +] as { + field: keyof (typeof Pages)["fields"]; + render?: ListFilterRender; + prepareValue?: (filter_value: unknown) => unknown; // set this function to change what filter value is passed to Sealious +}[]; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const displayFields = [ + { field: "url", label: "url" }, + { field: "content", label: "content" }, + { field: "domain", label: "domain" }, + { field: "title", label: "title" }, + { field: "heading", label: "heading" }, + { field: "description", label: "description" }, + { + field: "imageForMetadata", + label: "imageForMetadata", + format: async (value: FilePointer) => { + return imageRouter.image(await value.getPath(), { + container: { width: 45, height: 45 }, + crop: { width: 45, height: 45 }, + alt: "", + }); + }, + }, + { + field: "hideNavigation", + label: "hideNavigation", + format: (v: boolean) => (v ? "YES" : "NO"), + }, +] as { + field: string; + label: string; + format?: (value: unknown, item: CollectionItem) => FlatTemplatable; +}[]; + +export default new (class PagesCRUDListPage extends SealiousItemListPage< + typeof Pages, + typeof BaseListPageFields +> { + fields = BaseListPageFields; + + async renderFilters(ctx: Context): Promise { + const query_params = qs.parse(ctx.search.slice(1)); + query_params.page = "1"; + const filter_values = await super.getFilterValues(ctx); + return ( +
+ {Object.entries(query_params).map(([key, value]) => { + if (key == "filter") { + return ""; + } + // this is necessary to not lose any query params when the user changes the filter values + return ; + })} + {filterFields.map(({ field, render }) => { + if (!render) { + render = DefaultListFilters.fallback.render; + } + return ( + render( + filter_values[field] || "", + this.collection.fields[field] + ) || "" + ); + })} + +
+ ); + } + + async getFilterValues(ctx: Context) { + // adding opportunity to adjust the values for a given field filter before it's sent to Sealious + const values = await super.getFilterValues(ctx); + for (const filterField of filterFields) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const key = filterField.field as keyof typeof values; + if (key in values) { + const prepare_fn = filterField.prepareValue; + if (prepare_fn) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + values[key] = prepare_fn(values[key]) as any; + } + } + } + return values; + } + + async renderItem(ctx: Context, item: CollectionItem) { + return ( + + {displayFields.map(({ field, format }) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any + const value = item.get(field as any); + return {format ? format(value, item) : value}; + })} + + + + + ); + } + + renderListContainer(ctx: Context, content: Templatable): FlatTemplatable { + return ( + + {this.renderTableHead(ctx, displayFields)} + {content} +
+ ); + } + + renderTableHead( + ctx: Context, + fields: { field: string; label?: string }[] + ): FlatTemplatable { + return tempstream/* HTML */ ` + + ${fields.map(({ label, field }) => this.renderHeading(ctx, field, label))} + Actions + + `; + } + + async render(ctx: Context) { + return html({ + ctx, + title: "PagesCRUDList", + description: "", + body: ( +
+

PagesCRUDList List

+ Create + {super.render(ctx)} +
+ ), + }); + } +})(Pages); diff --git a/src/back/routes/admin/pages/shared.ts b/src/back/routes/admin/pages/shared.ts new file mode 100644 index 0000000..8c799f5 --- /dev/null +++ b/src/back/routes/admin/pages/shared.ts @@ -0,0 +1,36 @@ +import { Controls, Fields } from "@sealcode/sealgen"; +import { Pages } from "../../../collections/collections.js"; + +import { imageRouter } from "../../../image-router.js"; +import { TheFileManager } from "../../../file-manager.js"; + +export const PagesFormFields = { + url: new Fields.CollectionField(Pages.fields.url.required, Pages.fields.url), + domain: new Fields.CollectionField(Pages.fields.domain.required, Pages.fields.domain), + title: new Fields.CollectionField(Pages.fields.title.required, Pages.fields.title), + heading: new Fields.CollectionField( + Pages.fields.heading.required, + Pages.fields.heading + ), + description: new Fields.CollectionField( + Pages.fields.description.required, + Pages.fields.description + ), + imageForMetadata: new Fields.File( + Pages.fields.imageForMetadata.required, + TheFileManager + ), + hideNavigation: new Fields.Boolean(Pages.fields.hideNavigation.required), +}; + +export const PagesFormControls = [ + new Controls.SimpleInput(PagesFormFields.url, { label: "url" }), + new Controls.SimpleInput(PagesFormFields.domain, { label: "domain" }), + new Controls.SimpleInput(PagesFormFields.title, { label: "title" }), + new Controls.SimpleInput(PagesFormFields.heading, { label: "heading" }), + new Controls.SimpleInput(PagesFormFields.description, { label: "description" }), + new Controls.Photo(PagesFormFields.imageForMetadata, imageRouter, { + label: "imageForMetadata", + }), + new Controls.Checkbox(PagesFormFields.hideNavigation, { label: "hideNavigation" }), +]; diff --git a/src/back/routes/common/navbar.ts b/src/back/routes/common/navbar.ts index 7f17b61..ec81164 100644 --- a/src/back/routes/common/navbar.ts +++ b/src/back/routes/common/navbar.ts @@ -1,17 +1,20 @@ import type { BaseContext } from "koa"; import type { FlatTemplatable } from "tempstream"; -import { SignInURL, LogoutURL } from "../urls.js"; +import { SignInURL, LogoutURL, PagesCRUDListURL } from "../urls.js"; export async function default_navbar(ctx: BaseContext): Promise { const isLoggedIn = !!ctx.$context.session_id; const linkData = isLoggedIn - ? [{ text: "Logout", url: LogoutURL }] + ? [ + { text: "Pages", url: PagesCRUDListURL }, + { text: "Logout", url: LogoutURL }, + ] : [{ text: "Sign in", url: SignInURL }]; const linksHTML = linkData .map((link) => - link.url === new URL(ctx.url, "https://a.com").pathname + link.url === new URL(ctx.url, "https://a.com").pathname // checking if it's the current path we're looking at ? `
  • ${link.text}
  • ` : /* HTML */ `
  • ${link.text}
  • ` )