Misc UX fixes

This commit is contained in:
Kuba Orlik 2025-03-03 19:08:20 +01:00
parent a2ccad1929
commit cf165cd9de
13 changed files with 388 additions and 88 deletions

10
package-lock.json generated
View File

@ -17,7 +17,7 @@
"@sealcode/file-manager": "^1.0.2", "@sealcode/file-manager": "^1.0.2",
"@sealcode/jdd": "^0.7.1", "@sealcode/jdd": "^0.7.1",
"@sealcode/jdd-editor": "^0.1.19", "@sealcode/jdd-editor": "^0.1.19",
"@sealcode/sealgen": "^0.17.24", "@sealcode/sealgen": "^0.17.26",
"@sealcode/simplemde": "^1.12.1", "@sealcode/simplemde": "^1.12.1",
"@sealcode/ts-predicates": "^0.6.2", "@sealcode/ts-predicates": "^0.6.2",
"@types/kill-port": "^2.0.0", "@types/kill-port": "^2.0.0",
@ -1004,9 +1004,9 @@
"integrity": "sha512-pDsGlk2KokQkwzsJDBUWJFDRpEoxxth6TMQGDJyCTmWnd1Vn+cQb5moXDKaf7cXnWb9Y6QtdNX/fPzM/3RH2Cg==" "integrity": "sha512-pDsGlk2KokQkwzsJDBUWJFDRpEoxxth6TMQGDJyCTmWnd1Vn+cQb5moXDKaf7cXnWb9Y6QtdNX/fPzM/3RH2Cg=="
}, },
"node_modules/@sealcode/sealgen": { "node_modules/@sealcode/sealgen": {
"version": "0.17.24", "version": "0.17.26",
"resolved": "https://registry.npmjs.org/@sealcode/sealgen/-/sealgen-0.17.24.tgz", "resolved": "https://registry.npmjs.org/@sealcode/sealgen/-/sealgen-0.17.26.tgz",
"integrity": "sha512-xj3BTX2b3uFaO0Fj71inNY282Y96JiiCDFJF4dCmgWQu+yOLlzaVKF7p4bSi1CoNN9eHsvwXUEYKoRfvz8Q9GQ==", "integrity": "sha512-eehUUh+d3XW8F41d18q9tBuYEg9P21IfvovxFiM/p609OaplcZ4pUD+pma7zu/cMr+Xh85aw/gQUox5C9Obp4A==",
"dependencies": { "dependencies": {
"@koa/router": "^12.0.1", "@koa/router": "^12.0.1",
"@sealcode/file-manager": "^1.0.2", "@sealcode/file-manager": "^1.0.2",
@ -1017,6 +1017,7 @@
"esbuild": "^0.20.0", "esbuild": "^0.20.0",
"escape-goat": "^4.0.0", "escape-goat": "^4.0.0",
"google-fonts-helper": "^3.4.1", "google-fonts-helper": "^3.4.1",
"husky": "^9.1.7",
"is-what": "^4.1.16", "is-what": "^4.1.16",
"js-convert-case": "^4.2.0", "js-convert-case": "^4.2.0",
"json5": "^2.2.3", "json5": "^2.2.3",
@ -5131,7 +5132,6 @@
"version": "9.1.7", "version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"husky": "bin.js" "husky": "bin.js"

View File

@ -82,7 +82,7 @@
"@sealcode/file-manager": "^1.0.2", "@sealcode/file-manager": "^1.0.2",
"@sealcode/jdd": "^0.7.1", "@sealcode/jdd": "^0.7.1",
"@sealcode/jdd-editor": "^0.1.19", "@sealcode/jdd-editor": "^0.1.19",
"@sealcode/sealgen": "^0.17.24", "@sealcode/sealgen": "^0.17.26",
"@sealcode/simplemde": "^1.12.1", "@sealcode/simplemde": "^1.12.1",
"@sealcode/ts-predicates": "^0.6.2", "@sealcode/ts-predicates": "^0.6.2",
"@types/kill-port": "^2.0.0", "@types/kill-port": "^2.0.0",

View File

@ -3,7 +3,7 @@ import { TempstreamJSX } from "tempstream";
import { Page } from "@sealcode/sealgen"; import { Page } from "@sealcode/sealgen";
import html from "src/back/html.js"; import html from "src/back/html.js";
import { button } from "../elements/button.js"; import { button } from "../elements/button.js";
import { LoginURL, NavbarLinksCRUDURL, PagesCRUDListURL } from "./urls.js"; import { LoginURL, NavbarLinksCRUDListURL, PagesCRUDListURL } from "./urls.js";
export const actionName = "Admin"; export const actionName = "Admin";
@ -24,9 +24,10 @@ export default new (class AdminPage extends Page {
title: "Admin", title: "Admin",
body: ( body: (
<div> <div>
<h1>Admin panel </h1>
{[ {[
{ text: "Pages", href: PagesCRUDListURL }, { text: "Edit Pages", href: PagesCRUDListURL },
{ text: "Navbar", href: NavbarLinksCRUDURL }, { text: "Edit Navbar", href: NavbarLinksCRUDListURL },
].map((e) => button({ ...e, variant: "accent1" }))} ].map((e) => button({ ...e, variant: "accent1" }))}
</div> </div>
), ),

View File

@ -0,0 +1,28 @@
import type { Context } from "koa";
import { Mountable } from "@sealcode/sealgen";
import type Router from "@koa/router";
import { NavbarLinks } from "../../../../collections/collections.js";
import { NavbarLinksCRUDListURL } from "../../../urls.js";
export const actionName = "NavbarLinksCRUDDelete";
export default new (class NavbarLinksCRUDDeleteRedirect extends Mountable {
canAccess = async (ctx: Context) => {
const policy = NavbarLinks.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["navbar-links"].removeByID(
ctx.$context,
ctx.params.id!
);
ctx.status = 302;
ctx.redirect(NavbarLinksCRUDListURL);
});
}
})();

View File

@ -0,0 +1,105 @@
import type { Context } from "koa";
import type { FormData } from "@sealcode/sealgen";
import { Form, Controls, fieldsToShape } from "@sealcode/sealgen";
import html from "../../../../html.js";
import { NavbarLinksFormFields, NavbarLinksFormControls } from "../shared.js";
import { NavbarLinks } from "../../../../collections/collections.js";
import { NavbarLinksCRUDListURL } from "../../../urls.js";
import { tempstream } from "tempstream";
import { withFallback } from "@sealcode/sealgen";
export const actionName = "NavbarLinksCRUDEdit";
const fields = {
...NavbarLinksFormFields,
};
export const NavbarLinksCRUDEditShape = fieldsToShape(fields);
export default new (class NavbarLinksCRUDEditForm extends Form<typeof fields, void> {
defaultSuccessMessage = "Formularz wypełniony poprawnie";
fields = fields;
controls = [new Controls.FormHeader("Edit NavbarLinks"), ...NavbarLinksFormControls];
async getID(ctx: Context): Promise<string> {
// trying to automatically guess which param is the one that represents the ID
// of the resource to edit.
// If sealgen got it wrong, just return the param name that should be used here instead
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["navbar-links"]
.list(ctx.$context)
.ids([id])
.attach({})
.fetch();
if (!item) {
throw new Error("Item with given id not found: " + id);
}
return {
label: withFallback(item.get("label"), ""),
href: withFallback(item.get("href"), ""),
};
}
async onSubmit(ctx: Context) {
const data = await this.getParsedValues(ctx);
const id = await this.getID(ctx);
const {
items: [item],
} = await ctx.$app.collections["navbar-links"]
.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");
}
item.setMultiple({
label: data["label"] != null ? data["label"] : "",
href: data["href"] != null ? data["href"] : "",
});
await item.save(ctx.$context);
}
canAccess = async (ctx: Context) => {
const policy = NavbarLinks.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 NavbarLinks",
body: tempstream/* HTML */ ` <div class="sealgen-crud-form">
<a class="" href="${NavbarLinksCRUDListURL}"
> Back to navbar-links list</a
>
${await super.render(ctx, data, show_field_errors)}
</div>`,
description: "",
css_clumps: ["admin-forms"],
});
}
})();

View File

@ -0,0 +1,63 @@
import type { Context } from "koa";
import type { FormData } from "@sealcode/sealgen";
import { Form, Controls, fieldsToShape } from "@sealcode/sealgen";
import html from "../../../html.js";
import { NavbarLinksFormFields, NavbarLinksFormControls } from "./shared.js";
import { NavbarLinks } from "../../../collections/collections.js";
import { NavbarLinksCRUDListURL } from "../../urls.js";
import { tempstream } from "tempstream";
export const actionName = "NavbarLinksCRUDCreate";
const fields = {
...NavbarLinksFormFields,
};
export const NavbarLinksCRUDCreateShape = fieldsToShape(fields);
export default new (class NavbarLinksCRUDCreateForm extends Form<typeof fields, void> {
defaultSuccessMessage = "Formularz wypełniony poprawnie";
fields = fields;
controls = [
new Controls.FormHeader("Create NavbarLinks"),
...NavbarLinksFormControls,
];
async onSubmit(ctx: Context) {
const data = await this.getParsedValues(ctx);
if (!data) {
throw new Error("Error when parsing the form values");
}
await ctx.$app.collections["navbar-links"].create(ctx.$context, {
label: data["label"] != null ? data["label"] : "",
href: data["href"] != null ? data["href"] : "",
});
}
canAccess = async (ctx: Context) => {
const policy = NavbarLinks.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 navbar-links",
body: tempstream/* HTML */ ` <div class="sealgen-crud-form">
<a class="" href="${NavbarLinksCRUDListURL}"
> Back to navbar-links list</a
>
${await super.render(ctx, data, show_field_errors)}
</div>`,
description: "",
css_clumps: ["admin-forms"],
});
}
})();

View File

@ -0,0 +1,159 @@
import type { Context } from "koa";
import type { CollectionItem } from "sealious";
import type { FlatTemplatable, Templatable } from "tempstream";
import { TempstreamJSX, tempstream } from "tempstream";
import { NavbarLinks } 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 {
NavbarLinksCRUDCreateURL,
NavbarLinksCRUDEditURL,
NavbarLinksCRUDDeleteURL,
AdminURL,
} from "../../urls.js";
export const actionName = "NavbarLinksCRUDList";
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const filterFields = [
{ field: "label", ...DefaultListFilters["text"] },
{ field: "href", ...DefaultListFilters["text"] },
] as {
field: keyof (typeof NavbarLinks)["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: "label", label: "label" },
{ field: "href", label: "href" },
] as {
field: string;
label: string;
format?: (
value: unknown,
item: CollectionItem<typeof NavbarLinks>
) => FlatTemplatable;
}[];
export default new (class NavbarLinksCRUDListPage extends SealiousItemListPage<
typeof NavbarLinks,
typeof BaseListPageFields
> {
fields = BaseListPageFields;
async renderFilters(ctx: Context): Promise<FlatTemplatable> {
const query_params = qs.parse(ctx.search.slice(1));
query_params.page = "1";
const filter_values = await super.getFilterValues(ctx);
return (
<form>
{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 <input type="hidden" name={key} value={value} />;
})}
{filterFields.map(({ field, render }) => {
if (!render) {
render = DefaultListFilters.fallback.render;
}
return (
render(
filter_values[field] || "",
this.collection.fields[field]
) || ""
);
})}
<input type="submit" />
</form>
);
}
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<typeof NavbarLinks>) {
return (
<tr>
{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 <td>{format ? format(value, item) : value}</td>;
})}
<td>
<div class="sealious-list__actions">
<a href={NavbarLinksCRUDEditURL(item.id)}>Edit</a>
<a
data-turbo-method="POST"
data-turbo-confirm="Delete item?"
href={NavbarLinksCRUDDeleteURL(item.id)}
>
Delete
</a>
</div>
</td>
</tr>
);
}
renderListContainer(ctx: Context, content: Templatable): FlatTemplatable {
return (
<table class="sealious-list navbar-links-crudlist-table">
{this.renderTableHead(ctx, displayFields)}
<tbody>{content}</tbody>
</table>
);
}
renderTableHead(
ctx: Context,
fields: { field: string; label?: string }[]
): FlatTemplatable {
return tempstream/* HTML */ `<thead>
<tr>
${fields.map(({ label, field }) => this.renderHeading(ctx, field, label))}
<th>Actions</th>
</tr>
</thead>`;
}
async render(ctx: Context) {
return html({
ctx,
title: "Navbar",
description: "",
body: (
<div class="sealious-list-wrapper navbar-links-crudlist--wrapper">
<a href={AdminURL}> Back to Admin</a>
<h2>Edit Navbar items</h2>
<a href={NavbarLinksCRUDCreateURL}> Create </a>
{super.render(ctx)}
</div>
),
});
}
})(NavbarLinks);

View File

@ -1,66 +0,0 @@
import { CRUD } from "@sealcode/crud-ui";
import type { CollectionItem } from "sealious";
import type { ListFilterRender } from "@sealcode/sealgen";
import { Controls, DefaultListFilters, Fields } from "@sealcode/sealgen";
import { NavbarLinks } from "src/back/collections/collections.js";
import html from "src/back/html.js";
export const actionName = "NavbarLinksCRUD";
const edit_fields = <const>{
label: new Fields.CollectionField(
NavbarLinks.fields.label.required,
NavbarLinks.fields.label
),
href: new Fields.CollectionField(
NavbarLinks.fields.href.required,
NavbarLinks.fields.href
),
};
const edit_controls = [
new Controls.SimpleInput(edit_fields.label, { label: "label" }),
new Controls.SimpleInput(edit_fields.href, { label: "href" }),
];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fields_for_display = [
{ field: "label", label: "label" },
{ field: "href", label: "href" },
] as {
field: keyof (typeof NavbarLinks)["fields"];
label: string;
format?: (
value: unknown,
item: CollectionItem<typeof NavbarLinks>
) => string | Promise<string>;
}[];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fields_for_filters = [
{ field: "label", ...DefaultListFilters["text"] },
{ field: "href", ...DefaultListFilters["text"] },
] as {
field: keyof (typeof NavbarLinks)["fields"];
render?: ListFilterRender;
prepareValue?: (filter_value: unknown) => unknown; // set this function to change what filter value is passed to Sealious
}[];
export default new CRUD({
collection: NavbarLinks,
nice_collection_name: "NavbarLinks",
fields_for_display,
fields_for_filters,
html,
list_title: "Navbar Links",
edit_title: "Edit",
edit_button_text: "Edit",
delete_button_text: "Delete",
back_to_list_button_text: "← Back to NavbarLinks list",
edit_fields,
edit_controls,
form_value_to_sealious_value: {},
sealious_value_to_form_value: {},
post_edit: async () => {},
post_create: async () => {},
});

View File

@ -0,0 +1,18 @@
import { Controls, Fields } from "@sealcode/sealgen";
import { NavbarLinks } from "../../../collections/collections.js";
export const NavbarLinksFormFields = <const>{
label: new Fields.CollectionField(
NavbarLinks.fields.label.required,
NavbarLinks.fields.label
),
href: new Fields.CollectionField(
NavbarLinks.fields.href.required,
NavbarLinks.fields.href
),
};
export const NavbarLinksFormControls = [
new Controls.SimpleInput(NavbarLinksFormFields.label, { label: "label" }),
new Controls.SimpleInput(NavbarLinksFormFields.href, { label: "href" }),
];

View File

@ -81,9 +81,6 @@ export default new (class PagesCRUDEditForm extends Form<typeof fields, void> {
const preparedImageForMetadata = const preparedImageForMetadata =
data.imageForMetadata?.new || data.imageForMetadata?.old; data.imageForMetadata?.new || data.imageForMetadata?.old;
if (!preparedImageForMetadata) {
throw new Error("Missing field: imageForMetadata");
}
item.setMultiple({ item.setMultiple({
url: data["url"], url: data["url"],
@ -92,7 +89,7 @@ export default new (class PagesCRUDEditForm extends Form<typeof fields, void> {
title: data["title"] != null ? data["title"] : "", title: data["title"] != null ? data["title"] : "",
heading: data["heading"] != null ? data["heading"] : "", heading: data["heading"] != null ? data["heading"] : "",
description: data["description"] != null ? data["description"] : "", description: data["description"] != null ? data["description"] : "",
imageForMetadata: preparedImageForMetadata, imageForMetadata: preparedImageForMetadata || undefined,
hideNavigation: !!data.hideNavigation != null ? !!data.hideNavigation : false, hideNavigation: !!data.hideNavigation != null ? !!data.hideNavigation : false,
}); });
await item.save(ctx.$context); await item.save(ctx.$context);

View File

@ -33,9 +33,6 @@ export default new (class PagesCRUDCreateForm extends Form<typeof fields, void>
const preparedImageForMetadata = const preparedImageForMetadata =
data.imageForMetadata?.new || data.imageForMetadata?.old; data.imageForMetadata?.new || data.imageForMetadata?.old;
if (!preparedImageForMetadata) {
throw new Error("Missing field: imageForMetadata");
}
await ctx.$app.collections["pages"].create(ctx.$context, { await ctx.$app.collections["pages"].create(ctx.$context, {
url: data["url"], url: data["url"],
@ -44,7 +41,7 @@ export default new (class PagesCRUDCreateForm extends Form<typeof fields, void>
title: data["title"] != null ? data["title"] : "", title: data["title"] != null ? data["title"] : "",
heading: data["heading"] != null ? data["heading"] : "", heading: data["heading"] != null ? data["heading"] : "",
description: data["description"] != null ? data["description"] : "", description: data["description"] != null ? data["description"] : "",
imageForMetadata: preparedImageForMetadata, imageForMetadata: preparedImageForMetadata || undefined,
hideNavigation: !!data.hideNavigation != null ? !!data.hideNavigation : false, hideNavigation: !!data.hideNavigation != null ? !!data.hideNavigation : false,
}); });
} }

View File

@ -16,10 +16,7 @@ export const PagesFormFields = <const>{
Pages.fields.description.required, Pages.fields.description.required,
Pages.fields.description Pages.fields.description
), ),
imageForMetadata: new Fields.File( imageForMetadata: new Fields.File(false, TheFileManager),
Pages.fields.imageForMetadata.required,
TheFileManager
),
hideNavigation: new Fields.Boolean(Pages.fields.hideNavigation.required), hideNavigation: new Fields.Boolean(Pages.fields.hideNavigation.required),
}; };

View File

@ -1,6 +1,7 @@
import type { BaseContext } from "koa"; import type { BaseContext } from "koa";
import { button } from "src/back/elements/button.js"; import { button } from "src/back/elements/button.js";
import { tempstream, type FlatTemplatable } from "tempstream"; import { tempstream } from "tempstream";
import type { FlatTemplatable } from "tempstream";
export async function default_navbar(ctx: BaseContext): Promise<FlatTemplatable> { export async function default_navbar(ctx: BaseContext): Promise<FlatTemplatable> {
const { items: navbar_items } = await ctx.$app.collections["navbar-links"] const { items: navbar_items } = await ctx.$app.collections["navbar-links"]