From 6ed66b4b000c7268e07ee8741b7a72586155c458 Mon Sep 17 00:00:00 2001 From: Ivan Borshchov Date: Thu, 10 Oct 2024 12:14:52 +0300 Subject: [PATCH] Deploy website - based on ddd426e9c8acb6f0bc148eb4e7a7d1c4d4d82738 --- 404.html | 2 +- assets/js/0f528969.6e724431.js | 1 + assets/js/0f528969.72e0fac7.js | 1 - assets/js/4a260936.c636fabf.js | 1 - assets/js/4a260936.e2313285.js | 1 + assets/js/dbfc4782.8e3add22.js | 1 + assets/js/dbfc4782.b2d59e40.js | 1 - ...n.9a4d0037.js => runtime~main.cc09aba3.js} | 2 +- blog/ai-blog/index.html | 23 +++++++++++++------ blog/archive/index.html | 2 +- blog/atom.xml | 21 ++++++++++++----- blog/chatgpt-plugin/index.html | 2 +- blog/index.html | 23 +++++++++++++------ blog/rss.xml | 21 ++++++++++++----- blog/tags/chatgpt/index.html | 21 ++++++++++++----- blog/tags/index.html | 2 +- blog/tags/nuxt/index.html | 21 ++++++++++++----- blog/tags/plugin/index.html | 2 +- docs/api/index.html | 2 +- docs/api/plugins/audit-log/types/index.html | 2 +- .../type-aliases/PluginOptions/index.html | 2 +- docs/api/plugins/chat-gpt/types/index.html | 2 +- .../types/interfaces/PluginOptions/index.html | 2 +- .../email-password-reset/types/index.html | 2 +- .../types/interfaces/PluginOptions/index.html | 2 +- .../foreign-inline-list/types/index.html | 2 +- .../type-aliases/PluginOptions/index.html | 2 +- .../plugins/import-export/types/index.html | 2 +- .../types/interfaces/PluginOptions/index.html | 2 +- docs/api/plugins/rich-editor/types/index.html | 2 +- .../types/interfaces/PluginOptions/index.html | 2 +- .../plugins/two-factors-auth/types/index.html | 2 +- .../type-aliases/PluginOptions/index.html | 2 +- docs/api/plugins/upload/types/index.html | 2 +- .../type-aliases/PluginOptions/index.html | 2 +- .../classes/Filters/index.html | 2 +- .../AdminForthConfig/classes/Sorts/index.html | 2 +- .../enumerations/ActionCheckSource/index.html | 2 +- .../AdminForthDataTypes/index.html | 2 +- .../AdminForthFilterOperators/index.html | 2 +- .../AdminForthMenuTypes/index.html | 2 +- .../AdminForthResourcePages/index.html | 2 +- .../AdminForthSortDirections/index.html | 2 +- .../AllowedActionsEnum/index.html | 2 +- docs/api/types/AdminForthConfig/index.html | 2 +- .../interfaces/IAdminForth/index.html | 2 +- .../interfaces/IAdminForthAuth/index.html | 2 +- .../IAdminForthDataSourceConnector/index.html | 2 +- .../index.html | 2 +- .../index.html | 2 +- .../interfaces/IAdminForthFilter/index.html | 2 +- .../IAdminForthHttpResponse/index.html | 2 +- .../interfaces/IAdminForthPlugin/index.html | 2 +- .../interfaces/IAdminForthSort/index.html | 2 +- .../interfaces/ICodeInjector/index.html | 2 +- .../interfaces/IConfigValidator/index.html | 2 +- .../interfaces/IExpressHttpServer/index.html | 2 +- .../interfaces/IHttpServer/index.html | 2 +- .../IOperationalResource/index.html | 2 +- .../AdminForthBulkAction/index.html | 2 +- .../AdminForthColumnEnumItem/index.html | 2 +- .../AdminForthComponentDeclaration/index.html | 2 +- .../index.html | 2 +- .../type-aliases/AdminForthConfig/index.html | 2 +- .../AdminForthConfigMenuItem/index.html | 2 +- .../AdminForthDataSource/index.html | 2 +- .../AdminForthFieldComponents/index.html | 2 +- .../AdminForthForeignResource/index.html | 2 +- .../AdminForthResource/index.html | 2 +- .../AdminForthResourceColumn/index.html | 2 +- .../type-aliases/AdminUser/index.html | 2 +- .../index.html | 2 +- .../type-aliases/AfterSaveFunction/index.html | 2 +- .../AllowedActionValue/index.html | 2 +- .../type-aliases/AllowedActions/index.html | 2 +- .../AllowedActionsResolved/index.html | 2 +- .../index.html | 2 +- .../index.html | 2 +- .../BeforeSaveFunction/index.html | 2 +- .../type-aliases/FDataFilter/index.html | 2 +- .../type-aliases/FDataSort/index.html | 2 +- .../type-aliases/ValidationObject/index.html | 2 +- .../enumerations/AlertVariant/index.html | 2 +- docs/api/types/FrontendAPI/index.html | 2 +- .../FrontendAPIInterface/index.html | 2 +- .../type-aliases/AlertParams/index.html | 2 +- .../type-aliases/ConfirmParams/index.html | 2 +- .../Advanced/plugin-development/index.html | 2 +- docs/tutorial/Customization/alert/index.html | 2 +- .../Customization/branding/index.html | 2 +- .../Customization/bulkActions/index.html | 2 +- .../customFieldRendering/index.html | 2 +- .../Customization/customPages/index.html | 2 +- .../tutorial/Customization/dataApi/index.html | 2 +- docs/tutorial/Customization/hooks/index.html | 2 +- .../Customization/limitingAccess/index.html | 2 +- .../menuConfiguration/index.html | 2 +- .../Customization/pageInjections/index.html | 2 +- .../Customization/security/index.html | 2 +- .../Customization/virtualColumns/index.html | 2 +- docs/tutorial/Plugins/AuditLog/index.html | 2 +- .../Plugins/ForeignInlineList/index.html | 2 +- docs/tutorial/Plugins/RichEditor/index.html | 2 +- .../Plugins/TwoFactorsAuth/index.html | 2 +- docs/tutorial/Plugins/chat-gpt/index.html | 2 +- .../Plugins/email-password-reset/index.html | 2 +- .../tutorial/Plugins/import-export/index.html | 2 +- docs/tutorial/Plugins/upload/index.html | 2 +- docs/tutorial/deploy/index.html | 2 +- docs/tutorial/gettingStarted/index.html | 2 +- docs/tutorial/glossary/index.html | 2 +- docs/tutorial/helloWorld/index.html | 2 +- index.html | 2 +- search/index.html | 2 +- 114 files changed, 197 insertions(+), 143 deletions(-) create mode 100644 assets/js/0f528969.6e724431.js delete mode 100644 assets/js/0f528969.72e0fac7.js delete mode 100644 assets/js/4a260936.c636fabf.js create mode 100644 assets/js/4a260936.e2313285.js create mode 100644 assets/js/dbfc4782.8e3add22.js delete mode 100644 assets/js/dbfc4782.b2d59e40.js rename assets/js/{runtime~main.9a4d0037.js => runtime~main.cc09aba3.js} (97%) diff --git a/404.html b/404.html index 9acd89f36..f827c4069 100644 --- a/404.html +++ b/404.html @@ -15,7 +15,7 @@ - + diff --git a/assets/js/0f528969.6e724431.js b/assets/js/0f528969.6e724431.js new file mode 100644 index 000000000..53e6d5a79 --- /dev/null +++ b/assets/js/0f528969.6e724431.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkadminforth=self.webpackChunkadminforth||[]).push([[430],{999:(e,n,t)=>{t.r(n),t.d(n,{assets:()=>l,contentTitle:()=>o,default:()=>p,frontMatter:()=>i,metadata:()=>a,toc:()=>d});var r=t(4848),s=t(8453);const i={slug:"ai-blog",title:"AI-Assisted blog with AdminForth and Nuxt in a minutes",authors:"ivanb",tags:["nuxt","chatgpt"]},o=void 0,a={permalink:"/blog/ai-blog",source:"@site/blog/2024-10-01-ai-blog/index.md",title:"AI-Assisted blog with AdminForth and Nuxt in a minutes",description:"Many developers today are using copilots to write code faster and relax their minds from a routine tasks.",date:"2024-10-01T00:00:00.000Z",tags:[{inline:!0,label:"nuxt",permalink:"/blog/tags/nuxt"},{inline:!1,label:"ChatGPT",permalink:"/blog/tags/chatgpt",description:"ChatGPT is a conversational AI model that can generate human-like responses to text inputs."}],readingTime:17.99,hasTruncateMarker:!1,authors:[{name:"Ivan Borshcho",title:"Maintainer of AdminForth",url:"https://github.com/ivictbor",imageURL:"https://avatars.githubusercontent.com/u/1838656?v=4",key:"ivanb"}],frontMatter:{slug:"ai-blog",title:"AI-Assisted blog with AdminForth and Nuxt in a minutes",authors:"ivanb",tags:["nuxt","chatgpt"]},unlisted:!1,nextItem:{title:"Chat-GPT plugin to co-write texts and strings",permalink:"/blog/chatgpt-plugin"}},l={authorsImageUrls:[void 0]},d=[{value:"Prerequirements",id:"prerequirements",level:2},{value:"Step 1: Create a new AdminForth project",id:"step-1-create-a-new-adminforth-project",level:2},{value:"Step 2: Prepare environment",id:"step-2-prepare-environment",level:2},{value:"OpenAI",id:"openai",level:3},{value:"S3",id:"s3",level:3},{value:"Create .env file in project directory",id:"create-env-file-in-project-directory",level:3},{value:"Step 3: Initialize database",id:"step-3-initialize-database",level:2},{value:"Step 4: Setting up AdminForth",id:"step-4-setting-up-adminforth",level:2},{value:"Step 5: Create resources",id:"step-5-create-resources",level:2},{value:"Step 5: Create Nuxt project",id:"step-5-create-nuxt-project",level:2},{value:"Step 6: Deploy",id:"step-6-deploy",level:2},{value:"Dockerize in single container",id:"dockerize-in-single-container",level:3},{value:"Deploy to EC2 with terraform",id:"deploy-to-ec2-with-terraform",level:3},{value:"Add HTTPs and CDN",id:"add-https-and-cdn",level:3},{value:"Useful links",id:"useful-links",level:2}];function c(e){const n={a:"a",blockquote:"blockquote",code:"code",h2:"h2",h3:"h3",img:"img",li:"li",ol:"ol",p:"p",pre:"pre",ul:"ul",...(0,s.R)(),...e.components};return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)(n.p,{children:"Many developers today are using copilots to write code faster and relax their minds from a routine tasks."}),"\n",(0,r.jsx)(n.p,{children:"But what about writing plain text? For example blogs and micro-blogs: sometimes you want to share your progress but you are lazy for typing. Then you can give a try to AI-assisted blogging. Our Open-Source AdminForth framework has couple of new AI-capable plugins to write text and generate images."}),"\n",(0,r.jsx)(n.p,{children:(0,r.jsx)(n.img,{alt:"alt text",src:t(2397).A+"",width:"1999",height:"1499"})}),"\n",(0,r.jsxs)(n.p,{children:["You can also touch a blog which we will create in ",(0,r.jsx)(n.a,{href:"https://blog-demo.adminforth.dev/admin",children:"live"}),"."]}),"\n",(0,r.jsx)(n.p,{children:"For AI plugins are backed by OpenAI API, but their architecture allows to be easily extended for other AI providers once OpenAI competitors will reach the same or better level of quality."}),"\n",(0,r.jsx)(n.p,{children:"Here we will suggest you simple as 1-2-3 steps to build and host a blog with AI assistant which will help you to write posts."}),"\n",(0,r.jsx)(n.p,{children:"Our tech stack will include:"}),"\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.a,{href:"https://nuxt.com/",children:"Nuxt.js"})," - SEO-friendly page rendering framework"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.a,{href:"https://adminforth.dev/",children:"AdminForth"})," - Admin panel framework for creating posts"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.a,{href:"https://adminforth.dev/docs/tutorial/Plugins/RichEditor/",children:"AdminForth RichEditor plugin"})," - WYSIWYG editor with AI assistant in Copilot style"]}),"\n",(0,r.jsx)(n.li,{children:"Node and typescript"}),"\n",(0,r.jsx)(n.li,{children:"Prisma for migrations"}),"\n",(0,r.jsx)(n.li,{children:"SQLite for database, though you can easily switch it to Postgres or MongoDB"}),"\n"]}),"\n",(0,r.jsx)(n.h2,{id:"prerequirements",children:"Prerequirements"}),"\n",(0,r.jsxs)(n.p,{children:["We will use Node v20, if you not have it installed, we recommend ",(0,r.jsx)(n.a,{href:"https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script",children:"NVM"})]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"nvm install 20\nnvm alias default 20\nnvm use 20\n"})}),"\n",(0,r.jsx)(n.h2,{id:"step-1-create-a-new-adminforth-project",children:"Step 1: Create a new AdminForth project"}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"mkdir ai-blog\ncd ai-blog\nnpm init -y\nnpm install adminforth @adminforth/upload @adminforth/rich-editor @adminforth/chat-gpt \\\n express slugify http-proxy @types/express typescript tsx @types/node --save-dev\nnpx --yes tsc --init --module ESNext --target ESNext\n"})}),"\n",(0,r.jsx)(n.h2,{id:"step-2-prepare-environment",children:"Step 2: Prepare environment"}),"\n",(0,r.jsx)(n.h3,{id:"openai",children:"OpenAI"}),"\n",(0,r.jsxs)(n.p,{children:["To allocate OpenAI API key, go to ",(0,r.jsx)(n.a,{href:"https://platform.openai.com/",children:"https://platform.openai.com/"}),", open Dashboard -> API keys -> Create new secret key."]}),"\n",(0,r.jsx)(n.h3,{id:"s3",children:"S3"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["Go to ",(0,r.jsx)(n.a,{href:"https://aws.amazon.com",children:"https://aws.amazon.com"})," and login."]}),"\n",(0,r.jsxs)(n.li,{children:["Go to Services -> S3 and create a bucket. Put in bucket name e.g. ",(0,r.jsx)(n.code,{children:"my-ai-blog-bucket"}),'.\nFirst of all go to your bucket settings, Permissions, scroll down to Block public access (bucket settings for this bucket) and uncheck all checkboxes.\nGo to bucket settings, Permissions, Object ownership and select "ACLs Enabled" and "Bucket owner preferred" radio buttons.']}),"\n",(0,r.jsx)(n.li,{children:"Go to bucket settings, Permissions, scroll down to Cross-origin resource sharing (CORS) and put in the following configuration:"}),"\n"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-json",children:'[\n {\n "AllowedHeaders": [\n "*"\n ],\n "AllowedMethods": [\n "PUT"\n ],\n "AllowedOrigins": [\n "http://localhost:3500"\n ],\n "ExposeHeaders": []\n }\n]\n'})}),"\n",(0,r.jsxs)(n.blockquote,{children:["\n",(0,r.jsxs)(n.p,{children:["\u261d\ufe0f In AllowedOrigins add all your domains. For example if you will serve blog and admin on ",(0,r.jsx)(n.code,{children:"https://example.com/"})," you should add\n",(0,r.jsx)(n.code,{children:'"https://example.com"'})," to AllowedOrigins:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-json",children:'[\n "https://example.com",\n "http://localhost:3500"\n]\n'})}),"\n",(0,r.jsxs)(n.p,{children:["Every character matters, so don't forget to add ",(0,r.jsx)(n.code,{children:"http://"})," or ",(0,r.jsx)(n.code,{children:"https://"}),"!"]}),"\n"]}),"\n",(0,r.jsxs)(n.ol,{start:"4",children:["\n",(0,r.jsxs)(n.li,{children:["Go to Services -> IAM and create a new user. Put in user name e.g. ",(0,r.jsx)(n.code,{children:"my-ai-blog-bucket"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Attach existing policies directly -> ",(0,r.jsx)(n.code,{children:"AmazonS3FullAccess"}),". Go to your user -> ",(0,r.jsx)(n.code,{children:"Add permissions"})," -> ",(0,r.jsx)(n.code,{children:"Attach policies directly"})," -> ",(0,r.jsx)(n.code,{children:"AmazonS3FullAccess"})]}),"\n",(0,r.jsxs)(n.li,{children:["Go to Security credentials and create a new access key. Save ",(0,r.jsx)(n.code,{children:"Access key ID"})," and ",(0,r.jsx)(n.code,{children:"Secret access key"}),"."]}),"\n"]}),"\n",(0,r.jsx)(n.h3,{id:"create-env-file-in-project-directory",children:"Create .env file in project directory"}),"\n",(0,r.jsxs)(n.p,{children:["Create ",(0,r.jsx)(n.code,{children:".env"})," file with the following content:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",metastring:'title=".env"',children:"DATABASE_URL=file:./db/db.sqlite\nADMINFORTH_SECRET=123\nOPENAI_API_KEY=...\nAWS_ACCESS_KEY_ID=your_access_key_id\nAWS_SECRET_ACCESS_KEY=your_secret_access_key\nAWS_S3_BUCKET=my-ai-blog-bucket\nAWS_S3_REGION=us-east-1\n"})}),"\n",(0,r.jsx)(n.h2,{id:"step-3-initialize-database",children:"Step 3: Initialize database"}),"\n",(0,r.jsxs)(n.p,{children:["Create ",(0,r.jsx)(n.code,{children:"./schema.prisma"})," and put next content there:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-yaml",metastring:'title="./schema.prisma" ',children:'generator client {\n provider = "prisma-client-js"\n}\n\ndatasource db {\n provider = "sqlite"\n url = env("DATABASE_URL")\n}\n\nmodel User {\n id String @id\n createdAt DateTime \n email String @unique\n avatar String?\n publicName String?\n passwordHash String\n posts Post[]\n}\n\nmodel Post {\n id String @id\n createdAt DateTime \n title String\n slug String\n picture String?\n content String\n published Boolean \n author User? @relation(fields: [authorId], references: [id])\n authorId String?\n contentImages ContentImage[]\n}\n\nmodel ContentImage {\n id String @id\n createdAt DateTime \n img String\n postId String\n resourceId String\n post Post @relation(fields: [postId], references: [id])\n}\n'})}),"\n",(0,r.jsxs)(n.p,{children:["Create database using ",(0,r.jsx)(n.code,{children:"prisma migrate"}),":"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"npx -y prisma migrate dev --name init\n"})}),"\n",(0,r.jsx)(n.h2,{id:"step-4-setting-up-adminforth",children:"Step 4: Setting up AdminForth"}),"\n",(0,r.jsxs)(n.p,{children:["Open ",(0,r.jsx)(n.code,{children:"package.json"}),", set ",(0,r.jsx)(n.code,{children:"type"})," to ",(0,r.jsx)(n.code,{children:"module"})," and add ",(0,r.jsx)(n.code,{children:"start"})," script:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-json",metastring:'title="./package.json"',children:'{\n ...\n//diff-add\n "type": "module",\n "scripts": {\n ...\n//diff-add\n "start": "NODE_ENV=development tsx watch --env-file=.env index.ts",\n//diff-add\n "startLive": "NODE_ENV=production APP_PORT=80 tsx index.ts"\n },\n}\n'})}),"\n",(0,r.jsxs)(n.p,{children:["Create ",(0,r.jsx)(n.code,{children:"index.ts"})," file in root directory with following content:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-ts",metastring:'title="./index.ts"',children:"import express from 'express';\nimport AdminForth, { Filters, Sorts } from 'adminforth';\nimport userResource from './res/user.js';\nimport postResource from './res/posts.js';\nimport contentImageResource from './res/content-image.js';\nimport httpProxy from 'http-proxy';\n\ndeclare var process : {\n env: {\n DATABASE_URL: string\n NODE_ENV: string,\n AWS_S3_BUCKET: string,\n AWS_S3_REGION: string,\n }\n argv: string[]\n}\n\nexport const admin = new AdminForth({\n baseUrl: '/admin',\n auth: {\n usersResourceId: 'user', // resource to get user during login\n usernameField: 'email', // field where username is stored, should exist in resource\n passwordHashField: 'passwordHash',\n },\n customization: {\n brandName: 'My Admin',\n datesFormat: 'D MMM',\n timeFormat: 'HH:mm',\n emptyFieldPlaceholder: '-',\n styles: {\n colors: {\n light: {\n // color for links, icons etc.\n primary: 'rgb(47 37 227)',\n // color for sidebar and text\n sidebar: {main:'#EFF5F7', text:'#333'},\n },\n }\n }\n },\n dataSources: [{\n id: 'maindb',\n url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),\n }],\n resources: [\n userResource,\n postResource,\n contentImageResource,\n ],\n menu: [\n {\n homepage: true,\n label: 'Posts',\n icon: 'flowbite:home-solid',\n resourceId: 'post',\n },\n { type: 'gap' },\n { type: 'divider' },\n { type: 'heading', label: 'SYSTEM' },\n {\n label: 'Users',\n icon: 'flowbite:user-solid',\n resourceId: 'user',\n }\n ],\n});\n\n\nif (import.meta.url === `file://${process.argv[1]}`) {\n // if script is executed directly e.g. node index.ts or npm start\n\n const app = express()\n app.use(express.json());\n const port = 3500;\n\n // needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime\n if (process.env.NODE_ENV === 'development') {\n await admin.bundleNow({ hotReload: true });\n }\n console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');\n\n // api to server recent posts\n app.get('/api/posts', async (req, res) => {\n const { offset = 0, limit = 100, slug = null } = req.query;\n const posts = await admin.resource('post').list(\n [Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],\n limit,\n offset,\n Sorts.DESC('createdAt'),\n );\n const authorIds = [...new Set(posts.map((p: any) => p.authorId))];\n const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))\n .reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});\n posts.forEach((p: any) => {\n const author = authors[p.authorId];\n p.author = { \n publicName: author.publicName, \n avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`\n };\n p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;\n });\n res.json(posts);\n });\n\n // here we proxy all non-/admin requests to nuxt instance http://localhost:3000\n // this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx\n app.use((req, res, next) => {\n if (!req.url.startsWith('/admin')) {\n const proxy = httpProxy.createProxyServer();\n proxy.on('error', function (err, req, res) {\n res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)\n });\n proxy.web(req, res, { target: 'http://localhost:3000' });\n } else {\n next();\n }\n });\n\n // serve after you added all api\n admin.express.serve(app)\n\n admin.discoverDatabases().then(async () => {\n if (!await admin.resource('user').get([Filters.EQ('email', 'adminforth@adminforth.dev')])) {\n await admin.resource('user').create({\n email: 'adminforth@adminforth.dev',\n passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),\n });\n }\n });\n\n app.listen(port, () => {\n console.log(`\\n\u26a1 AdminForth is available at http://localhost:${port}\\n`)\n });\n}\n"})}),"\n",(0,r.jsx)(n.h2,{id:"step-5-create-resources",children:"Step 5: Create resources"}),"\n",(0,r.jsxs)(n.p,{children:["Create ",(0,r.jsx)(n.code,{children:"res"})," folder. Create ",(0,r.jsx)(n.code,{children:"./res/user.ts"})," file with following content:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-ts",metastring:'title="./res/users.ts"',children:"import AdminForth, { AdminForthDataTypes } from 'adminforth';\nimport { randomUUID } from 'crypto';\nimport UploadPlugin from '@adminforth/upload';\n\nexport default {\n dataSource: 'maindb',\n table: 'user',\n label: 'Users',\n recordLabel: (r: any) => `\ud83d\udc64 ${r.email}`,\n columns: [\n {\n name: 'id',\n primaryKey: true,\n fillOnCreate: () => randomUUID(),\n showIn: ['list', 'filter', 'show'],\n },\n {\n name: 'email',\n required: true,\n isUnique: true,\n enforceLowerCase: true,\n validation: [\n AdminForth.Utils.EMAIL_VALIDATOR,\n ],\n type: AdminForthDataTypes.STRING,\n },\n {\n name: 'createdAt',\n type: AdminForthDataTypes.DATETIME,\n showIn: ['list', 'filter', 'show'],\n fillOnCreate: () => (new Date()).toISOString(),\n },\n {\n name: 'password',\n virtual: true,\n required: { create: true },\n editingNote: { edit: 'Leave empty to keep password unchanged' },\n minLength: 8,\n type: AdminForthDataTypes.STRING,\n showIn: ['create', 'edit'],\n masked: true,\n validation: [\n // request to have at least 1 digit, 1 upper case, 1 lower case\n AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM,\n ],\n },\n { name: 'passwordHash', backendOnly: true, showIn: [] },\n { \n name: 'publicName',\n type: AdminForthDataTypes.STRING,\n },\n { name: 'avatar' },\n ],\n hooks: {\n create: {\n beforeSave: async ({ record, adminUser, resource }) => {\n record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);\n return { ok: true };\n }\n },\n edit: {\n beforeSave: async ({ record, adminUser, resource }) => {\n if (record.password) {\n record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);\n }\n return { ok: true }\n },\n },\n }\n plugins: [\n new UploadPlugin({\n pathColumnName: 'avatar',\n s3Bucket: process.env.AWS_S3_BUCKET,\n s3Region: process.env.AWS_S3_REGION,\n allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webm','webp'],\n maxFileSize: 1024 * 1024 * 20, // 20MB\n s3AccessKeyId: process.env.AWS_ACCESS_KEY_ID,\n s3SecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,\n s3ACL: 'public-read', // ACL which will be set to uploaded file\n s3Path: (\n { originalFilename, originalExtension }: {originalFilename: string, originalExtension: string }\n ) => `user-avatars/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`,\n generation: {\n provider: 'openai-dall-e',\n countToGenerate: 2,\n openAiOptions: {\n model: 'dall-e-3',\n size: '1024x1024',\n apiKey: process.env.OPENAI_API_KEY,\n },\n },\n }),\n ],\n}\n"})}),"\n",(0,r.jsxs)(n.p,{children:["Create ",(0,r.jsx)(n.code,{children:"posts.ts"})," file in res directory with following content:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-ts",metastring:'title="./res/post.ts"',children:"import { AdminUser, AdminForthDataTypes } from 'adminforth';\nimport { randomUUID } from 'crypto';\nimport UploadPlugin from '@adminforth/upload';\nimport RichEditorPlugin from '@adminforth/rich-editor';\nimport ChatGptPlugin from '@adminforth/chat-gpt';\nimport slugify from 'slugify';\n\nexport default {\n table: 'post',\n dataSource: 'maindb',\n label: 'Posts',\n recordLabel: (r: any) => `\ud83d\udcdd ${r.title}`,\n columns: [\n {\n name: 'id',\n primaryKey: true,\n fillOnCreate: () => randomUUID(),\n showIn: ['filter', 'show'],\n },\n {\n name: 'title',\n required: true,\n showIn: ['list', 'create', 'edit', 'filter', 'show'],\n maxLength: 255,\n minLength: 3,\n type: AdminForthDataTypes.STRING,\n },\n {\n name: 'picture',\n showIn: ['list', 'create', 'edit', 'filter', 'show'],\n },\n {\n name: 'slug',\n showIn: ['filter', 'show'],\n },\n {\n name: 'content',\n showIn: ['create', 'edit', 'filter', 'show'],\n type: AdminForthDataTypes.RICHTEXT,\n },\n {\n name: 'createdAt',\n showIn: ['list', 'filter', 'show',],\n fillOnCreate: () => (new Date()).toISOString(),\n },\n {\n name: 'published',\n required: true,\n },\n {\n name: 'authorId',\n foreignResource: {\n resourceId: 'user',\n },\n showIn: ['filter', 'show'],\n fillOnCreate: ({ adminUser }: { adminUser: AdminUser }) => {\n return adminUser.dbUser.id;\n }\n }\n ],\n hooks: {\n create: {\n beforeSave: async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {\n record.slug = slugify(record.title, { lower: true });\n return { ok: true };\n },\n },\n edit: {\n beforeSave: async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {\n if (record.title) {\n record.slug = slugify(record.title, { lower: true });\n }\n return { ok: true };\n },\n },\n },\n plugins: [\n new UploadPlugin({\n pathColumnName: 'picture',\n s3Bucket: process.env.AWS_S3_BUCKET,\n s3Region: process.env.AWS_S3_REGION,\n allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webm','webp'],\n maxFileSize: 1024 * 1024 * 20, // 20MB\n s3AccessKeyId: process.env.AWS_ACCESS_KEY_ID,\n s3SecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,\n s3ACL: 'public-read', // ACL which will be set to uploaded file\n s3Path: (\n { originalFilename, originalExtension }: {originalFilename: string, originalExtension: string }\n ) => `post-previews/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`,\n generation: {\n provider: 'openai-dall-e',\n countToGenerate: 2,\n openAiOptions: {\n model: 'dall-e-3',\n size: '1792x1024',\n apiKey: process.env.OPENAI_API_KEY,\n },\n fieldsForContext: ['title'],\n },\n }),\n new RichEditorPlugin({\n htmlFieldName: 'content',\n completion: {\n provider: 'openai-chat-gpt',\n params: {\n apiKey: process.env.OPENAI_API_KEY,\n model: 'gpt-4o',\n },\n expert: {\n debounceTime: 250,\n }\n }, \n attachments: {\n attachmentResource: 'contentImage',\n attachmentFieldName: 'img',\n attachmentRecordIdFieldName: 'postId',\n attachmentResourceIdFieldName: 'resourceId',\n },\n }),\n new ChatGptPlugin({\n openAiApiKey: process.env.OPENAI_API_KEY,\n model: 'gpt-4o',\n fieldName: 'title',\n expert: {\n debounceTime: 250,\n }\n }),\n ]\n}\n"})}),"\n",(0,r.jsxs)(n.p,{children:["Also create ",(0,r.jsx)(n.code,{children:"content-image.ts"})," file in ",(0,r.jsx)(n.code,{children:"res"})," directory with following content:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-ts",metastring:'title="./res/content-image.ts"',children:"\nimport { AdminForthDataTypes } from 'adminforth';\nimport { randomUUID } from 'crypto';\nimport UploadPlugin from '@adminforth/upload';\n\nexport default {\n table: 'contentImage',\n dataSource: 'maindb',\n label: 'Content Images',\n recordLabel: (r: any) => `\ud83d\uddbc\ufe0f ${r.img}`,\n columns: [\n {\n name: 'id',\n primaryKey: true,\n fillOnCreate: () => randomUUID(),\n },\n {\n name: 'createdAt',\n type: AdminForthDataTypes.DATETIME,\n fillOnCreate: () => (new Date()).toISOString(),\n },\n {\n name: 'img',\n type: AdminForthDataTypes.STRING,\n required: true,\n },\n {\n name: 'postId',\n foreignResource: {\n resourceId: 'post',\n },\n showIn: ['list', 'filter', 'show'],\n },\n {\n name: 'resourceId',\n }\n ],\n plugins: [\n new UploadPlugin({\n pathColumnName: 'img',\n s3Bucket: process.env.AWS_S3_BUCKET,\n s3Region: process.env.AWS_S3_REGION,\n allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webm','webp'],\n maxFileSize: 1024 * 1024 * 20, // 20MB\n s3AccessKeyId: process.env.AWS_ACCESS_KEY_ID,\n s3SecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,\n s3ACL: 'public-read', // ACL which will be set to uploaded file\n s3Path: (\n { originalFilename, originalExtension }: {originalFilename: string, originalExtension: string }\n ) => `post-content/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`,\n }),\n ],\n}\n"})}),"\n",(0,r.jsx)(n.p,{children:"Now you can start your admin panel:"}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"npm start\n"})}),"\n",(0,r.jsxs)(n.p,{children:["Open ",(0,r.jsx)(n.code,{children:"http://localhost:3500/admin"})," in your browser and login with ",(0,r.jsx)(n.code,{children:"adminforth@adminforth.dev"})," and ",(0,r.jsx)(n.code,{children:"adminforth"})," credentials.\nSet up your avatar (you can generate it with AI) and public name in user settings."]}),"\n",(0,r.jsx)(n.p,{children:(0,r.jsx)(n.img,{alt:"alt text",src:t(9871).A+"",width:"3670",height:"2588"})}),"\n",(0,r.jsx)(n.h2,{id:"step-5-create-nuxt-project",children:"Step 5: Create Nuxt project"}),"\n",(0,r.jsx)(n.p,{children:"Now let's initialize our seo-facing frontend:"}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"npx nuxi@latest init seo\ncd seo\nnpm install -D sass-embedded\nnpm run dev\n"})}),"\n",(0,r.jsxs)(n.p,{children:["Edit ",(0,r.jsx)(n.code,{children:"app.vue"}),":"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-html",metastring:'title="./seo/app.vue"',children:'\n\n\n\n'})}),"\n",(0,r.jsxs)(n.p,{children:["Add folder ",(0,r.jsx)(n.code,{children:"pages"})," and create ",(0,r.jsx)(n.code,{children:"index.vue"}),":"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-html",metastring:'title="./seo/pages/index.vue"',children:'\n\n\n\n +AI-Assisted blog with AdminForth and Nuxt in a minutes | Vue & Node admin panel framework @@ -15,11 +15,11 @@ - + -
Skip to main content

AI-Assisted blog with AdminForth and Nuxt in a minutes

· 18 min read
Ivan Borshcho
Maintainer of AdminForth

Many developers today are using copilots to write code faster and relax their minds for some routine tasks.

+

AI-Assisted blog with AdminForth and Nuxt in a minutes

· 18 min read
Ivan Borshcho
Maintainer of AdminForth

Many developers today are using copilots to write code faster and relax their minds from a routine tasks.

But what about writing plain text? For example blogs and micro-blogs: sometimes you want to share your progress but you are lazy for typing. Then you can give a try to AI-assisted blogging. Our Open-Source AdminForth framework has couple of new AI-capable plugins to write text and generate images.

alt text

You can also touch a blog which we will create in live.

@@ -74,7 +74,7 @@

Open package.json, set type to module and add start script:

./package.json
{
...
"type": "module",
"scripts": {
...
"start": "NODE_ENV=development tsx watch --env-file=.env index.ts",
"startLive": "NODE_ENV=production APP_PORT=80 tsx index.ts"
},
}

Create index.ts file in root directory with following content:

-
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.IN('email', 'adminforth')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}
+
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.EQ('email', 'adminforth@adminforth.dev')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}

Step 5: Create resources​

Create res folder. Create ./res/user.ts file with following content:

./res/users.ts
import AdminForth, { AdminForthDataTypes } from 'adminforth';
import { randomUUID } from 'crypto';
import UploadPlugin from '@adminforth/upload';

export default {
dataSource: 'maindb',
table: 'user',
label: 'Users',
recordLabel: (r: any) => `👤 ${r.email}`,
columns: [
{
name: 'id',
primaryKey: true,
fillOnCreate: () => randomUUID(),
showIn: ['list', 'filter', 'show'],
},
{
name: 'email',
required: true,
isUnique: true,
enforceLowerCase: true,
validation: [
AdminForth.Utils.EMAIL_VALIDATOR,
],
type: AdminForthDataTypes.STRING,
},
{
name: 'createdAt',
type: AdminForthDataTypes.DATETIME,
showIn: ['list', 'filter', 'show'],
fillOnCreate: () => (new Date()).toISOString(),
},
{
name: 'password',
virtual: true,
required: { create: true },
editingNote: { edit: 'Leave empty to keep password unchanged' },
minLength: 8,
type: AdminForthDataTypes.STRING,
showIn: ['create', 'edit'],
masked: true,
validation: [
// request to have at least 1 digit, 1 upper case, 1 lower case
AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM,
],
},
{ name: 'passwordHash', backendOnly: true, showIn: [] },
{
name: 'publicName',
type: AdminForthDataTypes.STRING,
},
{ name: 'avatar' },
],
hooks: {
create: {
beforeSave: async ({ record, adminUser, resource }) => {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
return { ok: true };
}
},
edit: {
beforeSave: async ({ record, adminUser, resource }) => {
if (record.password) {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
}
return { ok: true }
},
},
}
plugins: [
new UploadPlugin({
pathColumnName: 'avatar',
s3Bucket: process.env.AWS_S3_BUCKET,
s3Region: process.env.AWS_S3_REGION,
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webm','webp'],
maxFileSize: 1024 * 1024 * 20, // 20MB
s3AccessKeyId: process.env.AWS_ACCESS_KEY_ID,
s3SecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3ACL: 'public-read', // ACL which will be set to uploaded file
s3Path: (
{ originalFilename, originalExtension }: {originalFilename: string, originalExtension: string }
) => `user-avatars/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`,
generation: {
provider: 'openai-dall-e',
countToGenerate: 2,
openAiOptions: {
model: 'dall-e-3',
size: '1024x1024',
apiKey: process.env.OPENAI_API_KEY,
},
},
}),
],
}
@@ -107,16 +107,17 @@

Step 6: Deploy

We will dockerize app to make it easy to deploy with many ways. We will wrap both Node.js adminforth app and Nuxt.js app into single container for simplicity using supervisor. However you can split them into two containers and deploy them separately e.g. using docker compose.

Please note that in this demo example we routing requests to Nuxt.js app from AdminForth app using http-proxy. While this will work fine, it might give slower serving then if you would route traffik using dedicated reverse proxies like traefik or nginx.

+

Dockerize in single container​

Create bundleNow.ts file in root project directory:

./bundleNow.ts
import { admin } from './index.js';

await admin.bundleNow({ hotReload: false});
console.log('Bundling AdminForth done.');

Create Dockerfile in root project directory:

-
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF


CMD ["supervisord", "-c", "/etc/supervisord.conf"]
+
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF

CMD ["supervisord", "-c", "/etc/supervisord.conf"]

Create .dockerignore file in root project directory:

.dockerignore
.env
node_modules
seo/node_modules
.git
db
*.tar
.terraform*
terraform*
*.tf

Build and run your docker container locally:

sudo docker run -p80:3500 -v ./prodDb:/app/db --env-file .env -it $(docker build -q .)

Now you can open http://localhost in your browser and see your blog.

-

Deploy to EC2​

+

Deploy to EC2 with terraform​

First of all install Terraform as described here [https://developer.hashicorp.com/terraform/install#linux](terraform installation).

If you are on Ubuntu(WSL2 or native) you can use the following commands:

wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
@@ -139,6 +140,14 @@

Add HTTPs

For adding HTTPS and CDN you will use free Cloudflare service (though you can use paid AWS Cloudfront or any different way e.g. add Traefik and Let's Encrypt). Go to https://cloudflare.com and create an account. Add your domain and follow instructions to change your domain nameservers to Cloudflare ones.

Go to your domain settings and add A record with your Elastic Beanstalk IP address, e.g.

Type: A
Name: blog
Value: x.y.z.w
Cloudflare proxy: orange (enabled)
-

alt text

+

alt text

+ +
\ No newline at end of file diff --git a/blog/archive/index.html b/blog/archive/index.html index a3ef01bf7..17fdbba18 100644 --- a/blog/archive/index.html +++ b/blog/archive/index.html @@ -15,7 +15,7 @@ - + diff --git a/blog/atom.xml b/blog/atom.xml index 1229f7f32..331f9cd68 100644 --- a/blog/atom.xml +++ b/blog/atom.xml @@ -12,8 +12,8 @@ https://adminforth.dev/blog/ai-blog/ 2024-10-01T00:00:00.000Z - - Many developers today are using copilots to write code faster and relax their minds for some routine tasks.

+ + Many developers today are using copilots to write code faster and relax their minds from a routine tasks.

But what about writing plain text? For example blogs and micro-blogs: sometimes you want to share your progress but you are lazy for typing. Then you can give a try to AI-assisted blogging. Our Open-Source AdminForth framework has couple of new AI-capable plugins to write text and generate images.

alt text

You can also touch a blog which we will create in live.

@@ -68,7 +68,7 @@ Go to bucket settings, Permissions, Object ownership and select "ACLs Enabled" a

Open package.json, set type to module and add start script:

./package.json
{
...
"type": "module",
"scripts": {
...
"start": "NODE_ENV=development tsx watch --env-file=.env index.ts",
"startLive": "NODE_ENV=production APP_PORT=80 tsx index.ts"
},
}

Create index.ts file in root directory with following content:

-
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.IN('email', 'adminforth')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}
+
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.EQ('email', 'adminforth@adminforth.dev')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}

Step 5: Create resources​

Create res folder. Create ./res/user.ts file with following content:

./res/users.ts
import AdminForth, { AdminForthDataTypes } from 'adminforth';
import { randomUUID } from 'crypto';
import UploadPlugin from '@adminforth/upload';

export default {
dataSource: 'maindb',
table: 'user',
label: 'Users',
recordLabel: (r: any) => `👤 ${r.email}`,
columns: [
{
name: 'id',
primaryKey: true,
fillOnCreate: () => randomUUID(),
showIn: ['list', 'filter', 'show'],
},
{
name: 'email',
required: true,
isUnique: true,
enforceLowerCase: true,
validation: [
AdminForth.Utils.EMAIL_VALIDATOR,
],
type: AdminForthDataTypes.STRING,
},
{
name: 'createdAt',
type: AdminForthDataTypes.DATETIME,
showIn: ['list', 'filter', 'show'],
fillOnCreate: () => (new Date()).toISOString(),
},
{
name: 'password',
virtual: true,
required: { create: true },
editingNote: { edit: 'Leave empty to keep password unchanged' },
minLength: 8,
type: AdminForthDataTypes.STRING,
showIn: ['create', 'edit'],
masked: true,
validation: [
// request to have at least 1 digit, 1 upper case, 1 lower case
AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM,
],
},
{ name: 'passwordHash', backendOnly: true, showIn: [] },
{
name: 'publicName',
type: AdminForthDataTypes.STRING,
},
{ name: 'avatar' },
],
hooks: {
create: {
beforeSave: async ({ record, adminUser, resource }) => {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
return { ok: true };
}
},
edit: {
beforeSave: async ({ record, adminUser, resource }) => {
if (record.password) {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
}
return { ok: true }
},
},
}
plugins: [
new UploadPlugin({
pathColumnName: 'avatar',
s3Bucket: process.env.AWS_S3_BUCKET,
s3Region: process.env.AWS_S3_REGION,
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webm','webp'],
maxFileSize: 1024 * 1024 * 20, // 20MB
s3AccessKeyId: process.env.AWS_ACCESS_KEY_ID,
s3SecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3ACL: 'public-read', // ACL which will be set to uploaded file
s3Path: (
{ originalFilename, originalExtension }: {originalFilename: string, originalExtension: string }
) => `user-avatars/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`,
generation: {
provider: 'openai-dall-e',
countToGenerate: 2,
openAiOptions: {
model: 'dall-e-3',
size: '1024x1024',
apiKey: process.env.OPENAI_API_KEY,
},
},
}),
],
}
@@ -101,16 +101,17 @@ Set up your avatar (you can generate it with AI) and public name in user setting

We will dockerize app to make it easy to deploy with many ways. We will wrap both Node.js adminforth app and Nuxt.js app into single container for simplicity using supervisor. However you can split them into two containers and deploy them separately e.g. using docker compose.

Please note that in this demo example we routing requests to Nuxt.js app from AdminForth app using http-proxy. While this will work fine, it might give slower serving then if you would route traffik using dedicated reverse proxies like traefik or nginx.

+

Dockerize in single container​

Create bundleNow.ts file in root project directory:

./bundleNow.ts
import { admin } from './index.js';

await admin.bundleNow({ hotReload: false});
console.log('Bundling AdminForth done.');

Create Dockerfile in root project directory:

-
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF


CMD ["supervisord", "-c", "/etc/supervisord.conf"]
+
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF

CMD ["supervisord", "-c", "/etc/supervisord.conf"]

Create .dockerignore file in root project directory:

.dockerignore
.env
node_modules
seo/node_modules
.git
db
*.tar
.terraform*
terraform*
*.tf

Build and run your docker container locally:

sudo docker run -p80:3500 -v ./prodDb:/app/db --env-file .env -it $(docker build -q .)

Now you can open http://localhost in your browser and see your blog.

-

Deploy to EC2​

+

Deploy to EC2 with terraform​

First of all install Terraform as described here [https://developer.hashicorp.com/terraform/install#linux](terraform installation).

If you are on Ubuntu(WSL2 or native) you can use the following commands:

wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
@@ -133,7 +134,15 @@ While this will work fine, it might give slower serving then if you would route

For adding HTTPS and CDN you will use free Cloudflare service (though you can use paid AWS Cloudfront or any different way e.g. add Traefik and Let's Encrypt). Go to https://cloudflare.com and create an account. Add your domain and follow instructions to change your domain nameservers to Cloudflare ones.

Go to your domain settings and add A record with your Elastic Beanstalk IP address, e.g.

Type: A
Name: blog
Value: x.y.z.w
Cloudflare proxy: orange (enabled)
-

alt text

]]>
+

alt text

+ +]]>
Ivan Borshcho https://github.com/ivictbor diff --git a/blog/chatgpt-plugin/index.html b/blog/chatgpt-plugin/index.html index 525fca3fc..9de15723f 100644 --- a/blog/chatgpt-plugin/index.html +++ b/blog/chatgpt-plugin/index.html @@ -15,7 +15,7 @@ - + diff --git a/blog/index.html b/blog/index.html index 98b10e60d..ac24db31d 100644 --- a/blog/index.html +++ b/blog/index.html @@ -3,7 +3,7 @@ -Blog | Vue & Node admin panel framework +Blog | Vue & Node admin panel framework @@ -15,11 +15,11 @@ - + -
Skip to main content

AI-Assisted blog with AdminForth and Nuxt in a minutes

· 18 min read
Ivan Borshcho
Maintainer of AdminForth

Many developers today are using copilots to write code faster and relax their minds for some routine tasks.

+

AI-Assisted blog with AdminForth and Nuxt in a minutes

· 18 min read
Ivan Borshcho
Maintainer of AdminForth

Many developers today are using copilots to write code faster and relax their minds from a routine tasks.

But what about writing plain text? For example blogs and micro-blogs: sometimes you want to share your progress but you are lazy for typing. Then you can give a try to AI-assisted blogging. Our Open-Source AdminForth framework has couple of new AI-capable plugins to write text and generate images.

alt text

You can also touch a blog which we will create in live.

@@ -74,7 +74,7 @@

Open package.json, set type to module and add start script:

./package.json
{
...
"type": "module",
"scripts": {
...
"start": "NODE_ENV=development tsx watch --env-file=.env index.ts",
"startLive": "NODE_ENV=production APP_PORT=80 tsx index.ts"
},
}

Create index.ts file in root directory with following content:

-
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.IN('email', 'adminforth')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}
+
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.EQ('email', 'adminforth@adminforth.dev')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}

Step 5: Create resources​

Create res folder. Create ./res/user.ts file with following content:

./res/users.ts
import AdminForth, { AdminForthDataTypes } from 'adminforth';
import { randomUUID } from 'crypto';
import UploadPlugin from '@adminforth/upload';

export default {
dataSource: 'maindb',
table: 'user',
label: 'Users',
recordLabel: (r: any) => `👤 ${r.email}`,
columns: [
{
name: 'id',
primaryKey: true,
fillOnCreate: () => randomUUID(),
showIn: ['list', 'filter', 'show'],
},
{
name: 'email',
required: true,
isUnique: true,
enforceLowerCase: true,
validation: [
AdminForth.Utils.EMAIL_VALIDATOR,
],
type: AdminForthDataTypes.STRING,
},
{
name: 'createdAt',
type: AdminForthDataTypes.DATETIME,
showIn: ['list', 'filter', 'show'],
fillOnCreate: () => (new Date()).toISOString(),
},
{
name: 'password',
virtual: true,
required: { create: true },
editingNote: { edit: 'Leave empty to keep password unchanged' },
minLength: 8,
type: AdminForthDataTypes.STRING,
showIn: ['create', 'edit'],
masked: true,
validation: [
// request to have at least 1 digit, 1 upper case, 1 lower case
AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM,
],
},
{ name: 'passwordHash', backendOnly: true, showIn: [] },
{
name: 'publicName',
type: AdminForthDataTypes.STRING,
},
{ name: 'avatar' },
],
hooks: {
create: {
beforeSave: async ({ record, adminUser, resource }) => {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
return { ok: true };
}
},
edit: {
beforeSave: async ({ record, adminUser, resource }) => {
if (record.password) {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
}
return { ok: true }
},
},
}
plugins: [
new UploadPlugin({
pathColumnName: 'avatar',
s3Bucket: process.env.AWS_S3_BUCKET,
s3Region: process.env.AWS_S3_REGION,
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webm','webp'],
maxFileSize: 1024 * 1024 * 20, // 20MB
s3AccessKeyId: process.env.AWS_ACCESS_KEY_ID,
s3SecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3ACL: 'public-read', // ACL which will be set to uploaded file
s3Path: (
{ originalFilename, originalExtension }: {originalFilename: string, originalExtension: string }
) => `user-avatars/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`,
generation: {
provider: 'openai-dall-e',
countToGenerate: 2,
openAiOptions: {
model: 'dall-e-3',
size: '1024x1024',
apiKey: process.env.OPENAI_API_KEY,
},
},
}),
],
}
@@ -107,16 +107,17 @@

Step 6: Deploy

We will dockerize app to make it easy to deploy with many ways. We will wrap both Node.js adminforth app and Nuxt.js app into single container for simplicity using supervisor. However you can split them into two containers and deploy them separately e.g. using docker compose.

Please note that in this demo example we routing requests to Nuxt.js app from AdminForth app using http-proxy. While this will work fine, it might give slower serving then if you would route traffik using dedicated reverse proxies like traefik or nginx.

+

Dockerize in single container​

Create bundleNow.ts file in root project directory:

./bundleNow.ts
import { admin } from './index.js';

await admin.bundleNow({ hotReload: false});
console.log('Bundling AdminForth done.');

Create Dockerfile in root project directory:

-
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF


CMD ["supervisord", "-c", "/etc/supervisord.conf"]
+
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF

CMD ["supervisord", "-c", "/etc/supervisord.conf"]

Create .dockerignore file in root project directory:

.dockerignore
.env
node_modules
seo/node_modules
.git
db
*.tar
.terraform*
terraform*
*.tf

Build and run your docker container locally:

sudo docker run -p80:3500 -v ./prodDb:/app/db --env-file .env -it $(docker build -q .)

Now you can open http://localhost in your browser and see your blog.

-

Deploy to EC2​

+

Deploy to EC2 with terraform​

First of all install Terraform as described here [https://developer.hashicorp.com/terraform/install#linux](terraform installation).

If you are on Ubuntu(WSL2 or native) you can use the following commands:

wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
@@ -139,7 +140,15 @@

Add HTTPs

For adding HTTPS and CDN you will use free Cloudflare service (though you can use paid AWS Cloudfront or any different way e.g. add Traefik and Let's Encrypt). Go to https://cloudflare.com and create an account. Add your domain and follow instructions to change your domain nameservers to Cloudflare ones.

Go to your domain settings and add A record with your Elastic Beanstalk IP address, e.g.

Type: A
Name: blog
Value: x.y.z.w
Cloudflare proxy: orange (enabled)
-

alt text

Chat-GPT plugin to co-write texts and strings

· 4 min read
Ivan Borshcho
Maintainer of AdminForth

Couple of days ago we released a plugin which allows you to co-write texts and strings with the AI.

Today LLM is already a must tool to speed-up writing, brainstorming, or generating ideas.

Here is how it looks in action:

alt text

diff --git a/blog/rss.xml b/blog/rss.xml index 142956af3..4fdc62198 100644 --- a/blog/rss.xml +++ b/blog/rss.xml @@ -13,8 +13,8 @@ https://adminforth.dev/blog/ai-blog/ https://adminforth.dev/blog/ai-blog/ Tue, 01 Oct 2024 00:00:00 GMT - - Many developers today are using copilots to write code faster and relax their minds for some routine tasks.

+ + Many developers today are using copilots to write code faster and relax their minds from a routine tasks.

But what about writing plain text? For example blogs and micro-blogs: sometimes you want to share your progress but you are lazy for typing. Then you can give a try to AI-assisted blogging. Our Open-Source AdminForth framework has couple of new AI-capable plugins to write text and generate images.

alt text

You can also touch a blog which we will create in live.

@@ -69,7 +69,7 @@ Go to bucket settings, Permissions, Object ownership and select "ACLs Enabled" a

Open package.json, set type to module and add start script:

./package.json
{
...
"type": "module",
"scripts": {
...
"start": "NODE_ENV=development tsx watch --env-file=.env index.ts",
"startLive": "NODE_ENV=production APP_PORT=80 tsx index.ts"
},
}

Create index.ts file in root directory with following content:

-
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.IN('email', 'adminforth')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}
+
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.EQ('email', 'adminforth@adminforth.dev')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}

Step 5: Create resources​

Create res folder. Create ./res/user.ts file with following content:

./res/users.ts
import AdminForth, { AdminForthDataTypes } from 'adminforth';
import { randomUUID } from 'crypto';
import UploadPlugin from '@adminforth/upload';

export default {
dataSource: 'maindb',
table: 'user',
label: 'Users',
recordLabel: (r: any) => `👤 ${r.email}`,
columns: [
{
name: 'id',
primaryKey: true,
fillOnCreate: () => randomUUID(),
showIn: ['list', 'filter', 'show'],
},
{
name: 'email',
required: true,
isUnique: true,
enforceLowerCase: true,
validation: [
AdminForth.Utils.EMAIL_VALIDATOR,
],
type: AdminForthDataTypes.STRING,
},
{
name: 'createdAt',
type: AdminForthDataTypes.DATETIME,
showIn: ['list', 'filter', 'show'],
fillOnCreate: () => (new Date()).toISOString(),
},
{
name: 'password',
virtual: true,
required: { create: true },
editingNote: { edit: 'Leave empty to keep password unchanged' },
minLength: 8,
type: AdminForthDataTypes.STRING,
showIn: ['create', 'edit'],
masked: true,
validation: [
// request to have at least 1 digit, 1 upper case, 1 lower case
AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM,
],
},
{ name: 'passwordHash', backendOnly: true, showIn: [] },
{
name: 'publicName',
type: AdminForthDataTypes.STRING,
},
{ name: 'avatar' },
],
hooks: {
create: {
beforeSave: async ({ record, adminUser, resource }) => {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
return { ok: true };
}
},
edit: {
beforeSave: async ({ record, adminUser, resource }) => {
if (record.password) {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
}
return { ok: true }
},
},
}
plugins: [
new UploadPlugin({
pathColumnName: 'avatar',
s3Bucket: process.env.AWS_S3_BUCKET,
s3Region: process.env.AWS_S3_REGION,
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webm','webp'],
maxFileSize: 1024 * 1024 * 20, // 20MB
s3AccessKeyId: process.env.AWS_ACCESS_KEY_ID,
s3SecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3ACL: 'public-read', // ACL which will be set to uploaded file
s3Path: (
{ originalFilename, originalExtension }: {originalFilename: string, originalExtension: string }
) => `user-avatars/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`,
generation: {
provider: 'openai-dall-e',
countToGenerate: 2,
openAiOptions: {
model: 'dall-e-3',
size: '1024x1024',
apiKey: process.env.OPENAI_API_KEY,
},
},
}),
],
}
@@ -102,16 +102,17 @@ Set up your avatar (you can generate it with AI) and public name in user setting

We will dockerize app to make it easy to deploy with many ways. We will wrap both Node.js adminforth app and Nuxt.js app into single container for simplicity using supervisor. However you can split them into two containers and deploy them separately e.g. using docker compose.

Please note that in this demo example we routing requests to Nuxt.js app from AdminForth app using http-proxy. While this will work fine, it might give slower serving then if you would route traffik using dedicated reverse proxies like traefik or nginx.

+

Dockerize in single container​

Create bundleNow.ts file in root project directory:

./bundleNow.ts
import { admin } from './index.js';

await admin.bundleNow({ hotReload: false});
console.log('Bundling AdminForth done.');

Create Dockerfile in root project directory:

-
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF


CMD ["supervisord", "-c", "/etc/supervisord.conf"]
+
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF

CMD ["supervisord", "-c", "/etc/supervisord.conf"]

Create .dockerignore file in root project directory:

.dockerignore
.env
node_modules
seo/node_modules
.git
db
*.tar
.terraform*
terraform*
*.tf

Build and run your docker container locally:

sudo docker run -p80:3500 -v ./prodDb:/app/db --env-file .env -it $(docker build -q .)

Now you can open http://localhost in your browser and see your blog.

-

Deploy to EC2​

+

Deploy to EC2 with terraform​

First of all install Terraform as described here [https://developer.hashicorp.com/terraform/install#linux](terraform installation).

If you are on Ubuntu(WSL2 or native) you can use the following commands:

wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
@@ -134,7 +135,15 @@ While this will work fine, it might give slower serving then if you would route

For adding HTTPS and CDN you will use free Cloudflare service (though you can use paid AWS Cloudfront or any different way e.g. add Traefik and Let's Encrypt). Go to https://cloudflare.com and create an account. Add your domain and follow instructions to change your domain nameservers to Cloudflare ones.

Go to your domain settings and add A record with your Elastic Beanstalk IP address, e.g.

Type: A
Name: blog
Value: x.y.z.w
Cloudflare proxy: orange (enabled)
-

alt text

]]>
+

alt text

+ +]]>
nuxt ChatGPT diff --git a/blog/tags/chatgpt/index.html b/blog/tags/chatgpt/index.html index 34f1cf2a9..afadb83e0 100644 --- a/blog/tags/chatgpt/index.html +++ b/blog/tags/chatgpt/index.html @@ -15,11 +15,11 @@ - + -

2 posts tagged with "ChatGPT"

ChatGPT is a conversational AI model that can generate human-like responses to text inputs.

View All Tags

AI-Assisted blog with AdminForth and Nuxt in a minutes

· 18 min read
Ivan Borshcho
Maintainer of AdminForth

Many developers today are using copilots to write code faster and relax their minds for some routine tasks.

+

2 posts tagged with "ChatGPT"

ChatGPT is a conversational AI model that can generate human-like responses to text inputs.

View All Tags

AI-Assisted blog with AdminForth and Nuxt in a minutes

· 18 min read
Ivan Borshcho
Maintainer of AdminForth

Many developers today are using copilots to write code faster and relax their minds from a routine tasks.

But what about writing plain text? For example blogs and micro-blogs: sometimes you want to share your progress but you are lazy for typing. Then you can give a try to AI-assisted blogging. Our Open-Source AdminForth framework has couple of new AI-capable plugins to write text and generate images.

alt text

You can also touch a blog which we will create in live.

@@ -74,7 +74,7 @@

Open package.json, set type to module and add start script:

./package.json
{
...
"type": "module",
"scripts": {
...
"start": "NODE_ENV=development tsx watch --env-file=.env index.ts",
"startLive": "NODE_ENV=production APP_PORT=80 tsx index.ts"
},
}

Create index.ts file in root directory with following content:

-
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.IN('email', 'adminforth')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}
+
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.EQ('email', 'adminforth@adminforth.dev')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}

Step 5: Create resources​

Create res folder. Create ./res/user.ts file with following content:

./res/users.ts
import AdminForth, { AdminForthDataTypes } from 'adminforth';
import { randomUUID } from 'crypto';
import UploadPlugin from '@adminforth/upload';

export default {
dataSource: 'maindb',
table: 'user',
label: 'Users',
recordLabel: (r: any) => `👤 ${r.email}`,
columns: [
{
name: 'id',
primaryKey: true,
fillOnCreate: () => randomUUID(),
showIn: ['list', 'filter', 'show'],
},
{
name: 'email',
required: true,
isUnique: true,
enforceLowerCase: true,
validation: [
AdminForth.Utils.EMAIL_VALIDATOR,
],
type: AdminForthDataTypes.STRING,
},
{
name: 'createdAt',
type: AdminForthDataTypes.DATETIME,
showIn: ['list', 'filter', 'show'],
fillOnCreate: () => (new Date()).toISOString(),
},
{
name: 'password',
virtual: true,
required: { create: true },
editingNote: { edit: 'Leave empty to keep password unchanged' },
minLength: 8,
type: AdminForthDataTypes.STRING,
showIn: ['create', 'edit'],
masked: true,
validation: [
// request to have at least 1 digit, 1 upper case, 1 lower case
AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM,
],
},
{ name: 'passwordHash', backendOnly: true, showIn: [] },
{
name: 'publicName',
type: AdminForthDataTypes.STRING,
},
{ name: 'avatar' },
],
hooks: {
create: {
beforeSave: async ({ record, adminUser, resource }) => {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
return { ok: true };
}
},
edit: {
beforeSave: async ({ record, adminUser, resource }) => {
if (record.password) {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
}
return { ok: true }
},
},
}
plugins: [
new UploadPlugin({
pathColumnName: 'avatar',
s3Bucket: process.env.AWS_S3_BUCKET,
s3Region: process.env.AWS_S3_REGION,
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webm','webp'],
maxFileSize: 1024 * 1024 * 20, // 20MB
s3AccessKeyId: process.env.AWS_ACCESS_KEY_ID,
s3SecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3ACL: 'public-read', // ACL which will be set to uploaded file
s3Path: (
{ originalFilename, originalExtension }: {originalFilename: string, originalExtension: string }
) => `user-avatars/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`,
generation: {
provider: 'openai-dall-e',
countToGenerate: 2,
openAiOptions: {
model: 'dall-e-3',
size: '1024x1024',
apiKey: process.env.OPENAI_API_KEY,
},
},
}),
],
}
@@ -107,16 +107,17 @@

Step 6: Deploy

We will dockerize app to make it easy to deploy with many ways. We will wrap both Node.js adminforth app and Nuxt.js app into single container for simplicity using supervisor. However you can split them into two containers and deploy them separately e.g. using docker compose.

Please note that in this demo example we routing requests to Nuxt.js app from AdminForth app using http-proxy. While this will work fine, it might give slower serving then if you would route traffik using dedicated reverse proxies like traefik or nginx.

+

Dockerize in single container​

Create bundleNow.ts file in root project directory:

./bundleNow.ts
import { admin } from './index.js';

await admin.bundleNow({ hotReload: false});
console.log('Bundling AdminForth done.');

Create Dockerfile in root project directory:

-
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF


CMD ["supervisord", "-c", "/etc/supervisord.conf"]
+
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF

CMD ["supervisord", "-c", "/etc/supervisord.conf"]

Create .dockerignore file in root project directory:

.dockerignore
.env
node_modules
seo/node_modules
.git
db
*.tar
.terraform*
terraform*
*.tf

Build and run your docker container locally:

sudo docker run -p80:3500 -v ./prodDb:/app/db --env-file .env -it $(docker build -q .)

Now you can open http://localhost in your browser and see your blog.

-

Deploy to EC2​

+

Deploy to EC2 with terraform​

First of all install Terraform as described here [https://developer.hashicorp.com/terraform/install#linux](terraform installation).

If you are on Ubuntu(WSL2 or native) you can use the following commands:

wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
@@ -139,7 +140,15 @@

Add HTTPs

For adding HTTPS and CDN you will use free Cloudflare service (though you can use paid AWS Cloudfront or any different way e.g. add Traefik and Let's Encrypt). Go to https://cloudflare.com and create an account. Add your domain and follow instructions to change your domain nameservers to Cloudflare ones.

Go to your domain settings and add A record with your Elastic Beanstalk IP address, e.g.

Type: A
Name: blog
Value: x.y.z.w
Cloudflare proxy: orange (enabled)
-

alt text

Chat-GPT plugin to co-write texts and strings

· 4 min read
Ivan Borshcho
Maintainer of AdminForth

Couple of days ago we released a plugin which allows you to co-write texts and strings with the AI.

Today LLM is already a must tool to speed-up writing, brainstorming, or generating ideas.

Here is how it looks in action:

alt text

diff --git a/blog/tags/index.html b/blog/tags/index.html index 80c597d15..b2e2c9940 100644 --- a/blog/tags/index.html +++ b/blog/tags/index.html @@ -15,7 +15,7 @@ - + diff --git a/blog/tags/nuxt/index.html b/blog/tags/nuxt/index.html index 1fa89bda5..6b468c944 100644 --- a/blog/tags/nuxt/index.html +++ b/blog/tags/nuxt/index.html @@ -15,11 +15,11 @@ - + -

One post tagged with "nuxt"

View All Tags

AI-Assisted blog with AdminForth and Nuxt in a minutes

· 18 min read
Ivan Borshcho
Maintainer of AdminForth

Many developers today are using copilots to write code faster and relax their minds for some routine tasks.

+

One post tagged with "nuxt"

View All Tags

AI-Assisted blog with AdminForth and Nuxt in a minutes

· 18 min read
Ivan Borshcho
Maintainer of AdminForth

Many developers today are using copilots to write code faster and relax their minds from a routine tasks.

But what about writing plain text? For example blogs and micro-blogs: sometimes you want to share your progress but you are lazy for typing. Then you can give a try to AI-assisted blogging. Our Open-Source AdminForth framework has couple of new AI-capable plugins to write text and generate images.

alt text

You can also touch a blog which we will create in live.

@@ -74,7 +74,7 @@

Open package.json, set type to module and add start script:

./package.json
{
...
"type": "module",
"scripts": {
...
"start": "NODE_ENV=development tsx watch --env-file=.env index.ts",
"startLive": "NODE_ENV=production APP_PORT=80 tsx index.ts"
},
}

Create index.ts file in root directory with following content:

-
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.IN('email', 'adminforth')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}
+
./index.ts
import express from 'express';
import AdminForth, { Filters, Sorts } from 'adminforth';
import userResource from './res/user.js';
import postResource from './res/posts.js';
import contentImageResource from './res/content-image.js';
import httpProxy from 'http-proxy';

declare var process : {
env: {
DATABASE_URL: string
NODE_ENV: string,
AWS_S3_BUCKET: string,
AWS_S3_REGION: string,
}
argv: string[]
}

export const admin = new AdminForth({
baseUrl: '/admin',
auth: {
usersResourceId: 'user', // resource to get user during login
usernameField: 'email', // field where username is stored, should exist in resource
passwordHashField: 'passwordHash',
},
customization: {
brandName: 'My Admin',
datesFormat: 'D MMM',
timeFormat: 'HH:mm',
emptyFieldPlaceholder: '-',
styles: {
colors: {
light: {
// color for links, icons etc.
primary: 'rgb(47 37 227)',
// color for sidebar and text
sidebar: {main:'#EFF5F7', text:'#333'},
},
}
}
},
dataSources: [{
id: 'maindb',
url: process.env.DATABASE_URL?.replace('file:', 'sqlite://'),
}],
resources: [
userResource,
postResource,
contentImageResource,
],
menu: [
{
homepage: true,
label: 'Posts',
icon: 'flowbite:home-solid',
resourceId: 'post',
},
{ type: 'gap' },
{ type: 'divider' },
{ type: 'heading', label: 'SYSTEM' },
{
label: 'Users',
icon: 'flowbite:user-solid',
resourceId: 'user',
}
],
});


if (import.meta.url === `file://${process.argv[1]}`) {
// if script is executed directly e.g. node index.ts or npm start

const app = express()
app.use(express.json());
const port = 3500;

// needed to compile SPA. Call it here or from a build script e.g. in Docker build time to reduce downtime
if (process.env.NODE_ENV === 'development') {
await admin.bundleNow({ hotReload: true });
}
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');

// api to server recent posts
app.get('/api/posts', async (req, res) => {
const { offset = 0, limit = 100, slug = null } = req.query;
const posts = await admin.resource('post').list(
[Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])],
limit,
offset,
Sorts.DESC('createdAt'),
);
const authorIds = [...new Set(posts.map((p: any) => p.authorId))];
const authors = (await admin.resource('user').list(Filters.IN('id', authorIds)))
.reduce((acc: any, a: any) => {acc[a.id] = a; return acc;}, {});
posts.forEach((p: any) => {
const author = authors[p.authorId];
p.author = {
publicName: author.publicName,
avatar: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${author.avatar}`
};
p.picture = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_S3_REGION}.amazonaws.com/${p.picture}`;
});
res.json(posts);
});

// here we proxy all non-/admin requests to nuxt instance http://localhost:3000
// this is done for demo purposes, in production you should do this using high-performance reverse proxy like traefik or nginx
app.use((req, res, next) => {
if (!req.url.startsWith('/admin')) {
const proxy = httpProxy.createProxyServer();
proxy.on('error', function (err, req, res) {
res.send(`No response from Nuxt at http://localhost:3000, did you start it? ${err}`)
});
proxy.web(req, res, { target: 'http://localhost:3000' });
} else {
next();
}
});

// serve after you added all api
admin.express.serve(app)

admin.discoverDatabases().then(async () => {
if (!await admin.resource('user').get([Filters.EQ('email', 'adminforth@adminforth.dev')])) {
await admin.resource('user').create({
email: 'adminforth@adminforth.dev',
passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),
});
}
});

app.listen(port, () => {
console.log(`\nâš¡ AdminForth is available at http://localhost:${port}\n`)
});
}

Step 5: Create resources​

Create res folder. Create ./res/user.ts file with following content:

./res/users.ts
import AdminForth, { AdminForthDataTypes } from 'adminforth';
import { randomUUID } from 'crypto';
import UploadPlugin from '@adminforth/upload';

export default {
dataSource: 'maindb',
table: 'user',
label: 'Users',
recordLabel: (r: any) => `👤 ${r.email}`,
columns: [
{
name: 'id',
primaryKey: true,
fillOnCreate: () => randomUUID(),
showIn: ['list', 'filter', 'show'],
},
{
name: 'email',
required: true,
isUnique: true,
enforceLowerCase: true,
validation: [
AdminForth.Utils.EMAIL_VALIDATOR,
],
type: AdminForthDataTypes.STRING,
},
{
name: 'createdAt',
type: AdminForthDataTypes.DATETIME,
showIn: ['list', 'filter', 'show'],
fillOnCreate: () => (new Date()).toISOString(),
},
{
name: 'password',
virtual: true,
required: { create: true },
editingNote: { edit: 'Leave empty to keep password unchanged' },
minLength: 8,
type: AdminForthDataTypes.STRING,
showIn: ['create', 'edit'],
masked: true,
validation: [
// request to have at least 1 digit, 1 upper case, 1 lower case
AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM,
],
},
{ name: 'passwordHash', backendOnly: true, showIn: [] },
{
name: 'publicName',
type: AdminForthDataTypes.STRING,
},
{ name: 'avatar' },
],
hooks: {
create: {
beforeSave: async ({ record, adminUser, resource }) => {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
return { ok: true };
}
},
edit: {
beforeSave: async ({ record, adminUser, resource }) => {
if (record.password) {
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
}
return { ok: true }
},
},
}
plugins: [
new UploadPlugin({
pathColumnName: 'avatar',
s3Bucket: process.env.AWS_S3_BUCKET,
s3Region: process.env.AWS_S3_REGION,
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webm','webp'],
maxFileSize: 1024 * 1024 * 20, // 20MB
s3AccessKeyId: process.env.AWS_ACCESS_KEY_ID,
s3SecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3ACL: 'public-read', // ACL which will be set to uploaded file
s3Path: (
{ originalFilename, originalExtension }: {originalFilename: string, originalExtension: string }
) => `user-avatars/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`,
generation: {
provider: 'openai-dall-e',
countToGenerate: 2,
openAiOptions: {
model: 'dall-e-3',
size: '1024x1024',
apiKey: process.env.OPENAI_API_KEY,
},
},
}),
],
}
@@ -107,16 +107,17 @@

Step 6: Deploy

We will dockerize app to make it easy to deploy with many ways. We will wrap both Node.js adminforth app and Nuxt.js app into single container for simplicity using supervisor. However you can split them into two containers and deploy them separately e.g. using docker compose.

Please note that in this demo example we routing requests to Nuxt.js app from AdminForth app using http-proxy. While this will work fine, it might give slower serving then if you would route traffik using dedicated reverse proxies like traefik or nginx.

+

Dockerize in single container​

Create bundleNow.ts file in root project directory:

./bundleNow.ts
import { admin } from './index.js';

await admin.bundleNow({ hotReload: false});
console.log('Bundling AdminForth done.');

Create Dockerfile in root project directory:

-
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF


CMD ["supervisord", "-c", "/etc/supervisord.conf"]
+
./Dockerfile
FROM node:20-alpine
EXPOSE 3500
WORKDIR /app
RUN apk add --no-cache supervisor
COPY package.json package-lock.json ./
RUN npm ci
COPY seo/package.json seo/package-lock.json seo/
RUN cd seo && npm ci
COPY . .

RUN npx tsx bundleNow.ts
RUN cd seo && npm run build

RUN cat > /etc/supervisord.conf <<EOF
[supervisord]
nodaemon=true

[program:app]
command=npm run startLive
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:seo]
command=sh -c "cd seo && node .output/server/index.mjs"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

[program:prisma]
command=npx --yes prisma migrate dev --name init
directory=/app
autostart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr

EOF

CMD ["supervisord", "-c", "/etc/supervisord.conf"]

Create .dockerignore file in root project directory:

.dockerignore
.env
node_modules
seo/node_modules
.git
db
*.tar
.terraform*
terraform*
*.tf

Build and run your docker container locally:

sudo docker run -p80:3500 -v ./prodDb:/app/db --env-file .env -it $(docker build -q .)

Now you can open http://localhost in your browser and see your blog.

-

Deploy to EC2​

+

Deploy to EC2 with terraform​

First of all install Terraform as described here [https://developer.hashicorp.com/terraform/install#linux](terraform installation).

If you are on Ubuntu(WSL2 or native) you can use the following commands:

wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
@@ -139,6 +140,14 @@

Add HTTPs

For adding HTTPS and CDN you will use free Cloudflare service (though you can use paid AWS Cloudfront or any different way e.g. add Traefik and Let's Encrypt). Go to https://cloudflare.com and create an account. Add your domain and follow instructions to change your domain nameservers to Cloudflare ones.

Go to your domain settings and add A record with your Elastic Beanstalk IP address, e.g.

Type: A
Name: blog
Value: x.y.z.w
Cloudflare proxy: orange (enabled)
-

alt text

+

alt text

+ +
\ No newline at end of file diff --git a/blog/tags/plugin/index.html b/blog/tags/plugin/index.html index 1ae08349b..d2fbb3122 100644 --- a/blog/tags/plugin/index.html +++ b/blog/tags/plugin/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/index.html b/docs/api/index.html index 98582a192..5c76cbc6f 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/audit-log/types/index.html b/docs/api/plugins/audit-log/types/index.html index e6d692fb8..fecd61e8c 100644 --- a/docs/api/plugins/audit-log/types/index.html +++ b/docs/api/plugins/audit-log/types/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/audit-log/types/type-aliases/PluginOptions/index.html b/docs/api/plugins/audit-log/types/type-aliases/PluginOptions/index.html index f07946af6..00487b14e 100644 --- a/docs/api/plugins/audit-log/types/type-aliases/PluginOptions/index.html +++ b/docs/api/plugins/audit-log/types/type-aliases/PluginOptions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/chat-gpt/types/index.html b/docs/api/plugins/chat-gpt/types/index.html index 720fbe6fb..d9a8cea25 100644 --- a/docs/api/plugins/chat-gpt/types/index.html +++ b/docs/api/plugins/chat-gpt/types/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/chat-gpt/types/interfaces/PluginOptions/index.html b/docs/api/plugins/chat-gpt/types/interfaces/PluginOptions/index.html index e93cbbd68..d8754f0be 100644 --- a/docs/api/plugins/chat-gpt/types/interfaces/PluginOptions/index.html +++ b/docs/api/plugins/chat-gpt/types/interfaces/PluginOptions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/email-password-reset/types/index.html b/docs/api/plugins/email-password-reset/types/index.html index c26ef1526..2d90d6dde 100644 --- a/docs/api/plugins/email-password-reset/types/index.html +++ b/docs/api/plugins/email-password-reset/types/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/email-password-reset/types/interfaces/PluginOptions/index.html b/docs/api/plugins/email-password-reset/types/interfaces/PluginOptions/index.html index e8f4af420..ebcbc9958 100644 --- a/docs/api/plugins/email-password-reset/types/interfaces/PluginOptions/index.html +++ b/docs/api/plugins/email-password-reset/types/interfaces/PluginOptions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/foreign-inline-list/types/index.html b/docs/api/plugins/foreign-inline-list/types/index.html index e1d0092c6..1e6aa76fe 100644 --- a/docs/api/plugins/foreign-inline-list/types/index.html +++ b/docs/api/plugins/foreign-inline-list/types/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/foreign-inline-list/types/type-aliases/PluginOptions/index.html b/docs/api/plugins/foreign-inline-list/types/type-aliases/PluginOptions/index.html index 674d7b775..338e4919a 100644 --- a/docs/api/plugins/foreign-inline-list/types/type-aliases/PluginOptions/index.html +++ b/docs/api/plugins/foreign-inline-list/types/type-aliases/PluginOptions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/import-export/types/index.html b/docs/api/plugins/import-export/types/index.html index 98b2624ca..8b72f80c9 100644 --- a/docs/api/plugins/import-export/types/index.html +++ b/docs/api/plugins/import-export/types/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/import-export/types/interfaces/PluginOptions/index.html b/docs/api/plugins/import-export/types/interfaces/PluginOptions/index.html index 37653b64b..41c320bbb 100644 --- a/docs/api/plugins/import-export/types/interfaces/PluginOptions/index.html +++ b/docs/api/plugins/import-export/types/interfaces/PluginOptions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/rich-editor/types/index.html b/docs/api/plugins/rich-editor/types/index.html index 02cd2cca3..1839b1207 100644 --- a/docs/api/plugins/rich-editor/types/index.html +++ b/docs/api/plugins/rich-editor/types/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/rich-editor/types/interfaces/PluginOptions/index.html b/docs/api/plugins/rich-editor/types/interfaces/PluginOptions/index.html index ef30f0383..3a0bf61ed 100644 --- a/docs/api/plugins/rich-editor/types/interfaces/PluginOptions/index.html +++ b/docs/api/plugins/rich-editor/types/interfaces/PluginOptions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/two-factors-auth/types/index.html b/docs/api/plugins/two-factors-auth/types/index.html index d69023c77..b3207241f 100644 --- a/docs/api/plugins/two-factors-auth/types/index.html +++ b/docs/api/plugins/two-factors-auth/types/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/two-factors-auth/types/type-aliases/PluginOptions/index.html b/docs/api/plugins/two-factors-auth/types/type-aliases/PluginOptions/index.html index 09859d933..f65f45888 100644 --- a/docs/api/plugins/two-factors-auth/types/type-aliases/PluginOptions/index.html +++ b/docs/api/plugins/two-factors-auth/types/type-aliases/PluginOptions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/upload/types/index.html b/docs/api/plugins/upload/types/index.html index e3c52dc74..83af0efc8 100644 --- a/docs/api/plugins/upload/types/index.html +++ b/docs/api/plugins/upload/types/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/plugins/upload/types/type-aliases/PluginOptions/index.html b/docs/api/plugins/upload/types/type-aliases/PluginOptions/index.html index f1cf89785..a16940860 100644 --- a/docs/api/plugins/upload/types/type-aliases/PluginOptions/index.html +++ b/docs/api/plugins/upload/types/type-aliases/PluginOptions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/classes/Filters/index.html b/docs/api/types/AdminForthConfig/classes/Filters/index.html index 259eb39fb..3cd9f01c9 100644 --- a/docs/api/types/AdminForthConfig/classes/Filters/index.html +++ b/docs/api/types/AdminForthConfig/classes/Filters/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/classes/Sorts/index.html b/docs/api/types/AdminForthConfig/classes/Sorts/index.html index 571bc0f7c..7c63cd491 100644 --- a/docs/api/types/AdminForthConfig/classes/Sorts/index.html +++ b/docs/api/types/AdminForthConfig/classes/Sorts/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/enumerations/ActionCheckSource/index.html b/docs/api/types/AdminForthConfig/enumerations/ActionCheckSource/index.html index 733131545..9bc19372b 100644 --- a/docs/api/types/AdminForthConfig/enumerations/ActionCheckSource/index.html +++ b/docs/api/types/AdminForthConfig/enumerations/ActionCheckSource/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/enumerations/AdminForthDataTypes/index.html b/docs/api/types/AdminForthConfig/enumerations/AdminForthDataTypes/index.html index 2320be6aa..97b456e40 100644 --- a/docs/api/types/AdminForthConfig/enumerations/AdminForthDataTypes/index.html +++ b/docs/api/types/AdminForthConfig/enumerations/AdminForthDataTypes/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/enumerations/AdminForthFilterOperators/index.html b/docs/api/types/AdminForthConfig/enumerations/AdminForthFilterOperators/index.html index 229375827..94b834147 100644 --- a/docs/api/types/AdminForthConfig/enumerations/AdminForthFilterOperators/index.html +++ b/docs/api/types/AdminForthConfig/enumerations/AdminForthFilterOperators/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/enumerations/AdminForthMenuTypes/index.html b/docs/api/types/AdminForthConfig/enumerations/AdminForthMenuTypes/index.html index 14c784924..3855dd0a2 100644 --- a/docs/api/types/AdminForthConfig/enumerations/AdminForthMenuTypes/index.html +++ b/docs/api/types/AdminForthConfig/enumerations/AdminForthMenuTypes/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/enumerations/AdminForthResourcePages/index.html b/docs/api/types/AdminForthConfig/enumerations/AdminForthResourcePages/index.html index 4143af3ac..80de73145 100644 --- a/docs/api/types/AdminForthConfig/enumerations/AdminForthResourcePages/index.html +++ b/docs/api/types/AdminForthConfig/enumerations/AdminForthResourcePages/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/enumerations/AdminForthSortDirections/index.html b/docs/api/types/AdminForthConfig/enumerations/AdminForthSortDirections/index.html index 779f4a461..6a4179763 100644 --- a/docs/api/types/AdminForthConfig/enumerations/AdminForthSortDirections/index.html +++ b/docs/api/types/AdminForthConfig/enumerations/AdminForthSortDirections/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/enumerations/AllowedActionsEnum/index.html b/docs/api/types/AdminForthConfig/enumerations/AllowedActionsEnum/index.html index 339a3b359..225314a06 100644 --- a/docs/api/types/AdminForthConfig/enumerations/AllowedActionsEnum/index.html +++ b/docs/api/types/AdminForthConfig/enumerations/AllowedActionsEnum/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/index.html b/docs/api/types/AdminForthConfig/index.html index 9943dc535..b5855b572 100644 --- a/docs/api/types/AdminForthConfig/index.html +++ b/docs/api/types/AdminForthConfig/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IAdminForth/index.html b/docs/api/types/AdminForthConfig/interfaces/IAdminForth/index.html index a50bbe214..57cd014e4 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IAdminForth/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IAdminForth/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IAdminForthAuth/index.html b/docs/api/types/AdminForthConfig/interfaces/IAdminForthAuth/index.html index f65bdecae..effc58331 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IAdminForthAuth/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IAdminForthAuth/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnector/index.html b/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnector/index.html index 56f001383..9ba1ab0bd 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnector/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnector/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnectorBase/index.html b/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnectorBase/index.html index 2fb2a6141..8ac373e17 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnectorBase/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnectorBase/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnectorConstructor/index.html b/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnectorConstructor/index.html index 1be084082..527728bc6 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnectorConstructor/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IAdminForthDataSourceConnectorConstructor/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IAdminForthFilter/index.html b/docs/api/types/AdminForthConfig/interfaces/IAdminForthFilter/index.html index 18c55d35d..6b4dac77d 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IAdminForthFilter/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IAdminForthFilter/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IAdminForthHttpResponse/index.html b/docs/api/types/AdminForthConfig/interfaces/IAdminForthHttpResponse/index.html index 48d36f2ea..709ca9c2f 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IAdminForthHttpResponse/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IAdminForthHttpResponse/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IAdminForthPlugin/index.html b/docs/api/types/AdminForthConfig/interfaces/IAdminForthPlugin/index.html index 293624f61..6ec979f07 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IAdminForthPlugin/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IAdminForthPlugin/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IAdminForthSort/index.html b/docs/api/types/AdminForthConfig/interfaces/IAdminForthSort/index.html index 34aa5f4c3..3c64b3ce8 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IAdminForthSort/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IAdminForthSort/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/ICodeInjector/index.html b/docs/api/types/AdminForthConfig/interfaces/ICodeInjector/index.html index 16caab7ad..438c25002 100644 --- a/docs/api/types/AdminForthConfig/interfaces/ICodeInjector/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/ICodeInjector/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IConfigValidator/index.html b/docs/api/types/AdminForthConfig/interfaces/IConfigValidator/index.html index a85a91444..5ec91d0b9 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IConfigValidator/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IConfigValidator/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IExpressHttpServer/index.html b/docs/api/types/AdminForthConfig/interfaces/IExpressHttpServer/index.html index 8d6be811f..42029c1a3 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IExpressHttpServer/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IExpressHttpServer/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IHttpServer/index.html b/docs/api/types/AdminForthConfig/interfaces/IHttpServer/index.html index 40d5af28b..e755ac092 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IHttpServer/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IHttpServer/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/interfaces/IOperationalResource/index.html b/docs/api/types/AdminForthConfig/interfaces/IOperationalResource/index.html index 8018f288a..55a83bab0 100644 --- a/docs/api/types/AdminForthConfig/interfaces/IOperationalResource/index.html +++ b/docs/api/types/AdminForthConfig/interfaces/IOperationalResource/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthBulkAction/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthBulkAction/index.html index 237ba76fb..fd5a0acc0 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthBulkAction/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthBulkAction/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthColumnEnumItem/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthColumnEnumItem/index.html index 34190f406..171237933 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthColumnEnumItem/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthColumnEnumItem/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthComponentDeclaration/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthComponentDeclaration/index.html index b6faed1e3..325796511 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthComponentDeclaration/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthComponentDeclaration/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthComponentDeclarationFull/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthComponentDeclarationFull/index.html index aa5832e8d..6f5054e3f 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthComponentDeclarationFull/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthComponentDeclarationFull/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthConfig/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthConfig/index.html index 04ba16d68..1921f2c0d 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthConfig/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthConfig/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthConfigMenuItem/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthConfigMenuItem/index.html index f8adc5e89..43dc2b588 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthConfigMenuItem/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthConfigMenuItem/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthDataSource/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthDataSource/index.html index de12bcb7d..fbe3ddbf5 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthDataSource/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthDataSource/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthFieldComponents/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthFieldComponents/index.html index 8326e913d..621eec51d 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthFieldComponents/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthFieldComponents/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthForeignResource/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthForeignResource/index.html index 775413421..635265b3a 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthForeignResource/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthForeignResource/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthResource/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthResource/index.html index 75bdfb5de..0ba92f2e2 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthResource/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthResource/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminForthResourceColumn/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminForthResourceColumn/index.html index a8c179a94..fa43a8bae 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminForthResourceColumn/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminForthResourceColumn/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AdminUser/index.html b/docs/api/types/AdminForthConfig/type-aliases/AdminUser/index.html index f7cc6f919..badd8cf34 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AdminUser/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AdminUser/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AfterDataSourceResponseFunction/index.html b/docs/api/types/AdminForthConfig/type-aliases/AfterDataSourceResponseFunction/index.html index 5e2a801b1..b0eac8095 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AfterDataSourceResponseFunction/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AfterDataSourceResponseFunction/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AfterSaveFunction/index.html b/docs/api/types/AdminForthConfig/type-aliases/AfterSaveFunction/index.html index d6a412f58..a16fbdfb8 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AfterSaveFunction/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AfterSaveFunction/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AllowedActionValue/index.html b/docs/api/types/AdminForthConfig/type-aliases/AllowedActionValue/index.html index 9eebd2db8..42eeeb8ad 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AllowedActionValue/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AllowedActionValue/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AllowedActions/index.html b/docs/api/types/AdminForthConfig/type-aliases/AllowedActions/index.html index 623937d52..b5e3240f1 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AllowedActions/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AllowedActions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/AllowedActionsResolved/index.html b/docs/api/types/AdminForthConfig/type-aliases/AllowedActionsResolved/index.html index aa03aa94f..225ff540d 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/AllowedActionsResolved/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/AllowedActionsResolved/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/BeforeDataSourceRequestFunction/index.html b/docs/api/types/AdminForthConfig/type-aliases/BeforeDataSourceRequestFunction/index.html index 871eaea4f..1660f3a7f 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/BeforeDataSourceRequestFunction/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/BeforeDataSourceRequestFunction/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/BeforeLoginConfirmationFunction/index.html b/docs/api/types/AdminForthConfig/type-aliases/BeforeLoginConfirmationFunction/index.html index 573deaa9a..fe71a2380 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/BeforeLoginConfirmationFunction/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/BeforeLoginConfirmationFunction/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/BeforeSaveFunction/index.html b/docs/api/types/AdminForthConfig/type-aliases/BeforeSaveFunction/index.html index 691f16cdc..632b91114 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/BeforeSaveFunction/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/BeforeSaveFunction/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/FDataFilter/index.html b/docs/api/types/AdminForthConfig/type-aliases/FDataFilter/index.html index 29ee8fd8e..06dd87feb 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/FDataFilter/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/FDataFilter/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/FDataSort/index.html b/docs/api/types/AdminForthConfig/type-aliases/FDataSort/index.html index 445886b2a..8afd40802 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/FDataSort/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/FDataSort/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/AdminForthConfig/type-aliases/ValidationObject/index.html b/docs/api/types/AdminForthConfig/type-aliases/ValidationObject/index.html index 7d4c95bad..054671981 100644 --- a/docs/api/types/AdminForthConfig/type-aliases/ValidationObject/index.html +++ b/docs/api/types/AdminForthConfig/type-aliases/ValidationObject/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/FrontendAPI/enumerations/AlertVariant/index.html b/docs/api/types/FrontendAPI/enumerations/AlertVariant/index.html index 0cfdbf1d5..e28329fe2 100644 --- a/docs/api/types/FrontendAPI/enumerations/AlertVariant/index.html +++ b/docs/api/types/FrontendAPI/enumerations/AlertVariant/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/FrontendAPI/index.html b/docs/api/types/FrontendAPI/index.html index debe31f33..f726c8d59 100644 --- a/docs/api/types/FrontendAPI/index.html +++ b/docs/api/types/FrontendAPI/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/FrontendAPI/interfaces/FrontendAPIInterface/index.html b/docs/api/types/FrontendAPI/interfaces/FrontendAPIInterface/index.html index a8a050159..858514df3 100644 --- a/docs/api/types/FrontendAPI/interfaces/FrontendAPIInterface/index.html +++ b/docs/api/types/FrontendAPI/interfaces/FrontendAPIInterface/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/FrontendAPI/type-aliases/AlertParams/index.html b/docs/api/types/FrontendAPI/type-aliases/AlertParams/index.html index e2f7914eb..4cc775d7d 100644 --- a/docs/api/types/FrontendAPI/type-aliases/AlertParams/index.html +++ b/docs/api/types/FrontendAPI/type-aliases/AlertParams/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/api/types/FrontendAPI/type-aliases/ConfirmParams/index.html b/docs/api/types/FrontendAPI/type-aliases/ConfirmParams/index.html index 95fa8cd03..a959c5a7c 100644 --- a/docs/api/types/FrontendAPI/type-aliases/ConfirmParams/index.html +++ b/docs/api/types/FrontendAPI/type-aliases/ConfirmParams/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Advanced/plugin-development/index.html b/docs/tutorial/Advanced/plugin-development/index.html index 3afac3746..37b828aa4 100644 --- a/docs/tutorial/Advanced/plugin-development/index.html +++ b/docs/tutorial/Advanced/plugin-development/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/alert/index.html b/docs/tutorial/Customization/alert/index.html index 9df866dd8..0c5b8f585 100644 --- a/docs/tutorial/Customization/alert/index.html +++ b/docs/tutorial/Customization/alert/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/branding/index.html b/docs/tutorial/Customization/branding/index.html index f59873814..3ceeaee3a 100644 --- a/docs/tutorial/Customization/branding/index.html +++ b/docs/tutorial/Customization/branding/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/bulkActions/index.html b/docs/tutorial/Customization/bulkActions/index.html index 0649642cf..0894730bf 100644 --- a/docs/tutorial/Customization/bulkActions/index.html +++ b/docs/tutorial/Customization/bulkActions/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/customFieldRendering/index.html b/docs/tutorial/Customization/customFieldRendering/index.html index d34adea95..dcd3e7959 100644 --- a/docs/tutorial/Customization/customFieldRendering/index.html +++ b/docs/tutorial/Customization/customFieldRendering/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/customPages/index.html b/docs/tutorial/Customization/customPages/index.html index 883f78649..932abe405 100644 --- a/docs/tutorial/Customization/customPages/index.html +++ b/docs/tutorial/Customization/customPages/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/dataApi/index.html b/docs/tutorial/Customization/dataApi/index.html index ccbccc3b5..424ef28b0 100644 --- a/docs/tutorial/Customization/dataApi/index.html +++ b/docs/tutorial/Customization/dataApi/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/hooks/index.html b/docs/tutorial/Customization/hooks/index.html index d834b3212..2d332b699 100644 --- a/docs/tutorial/Customization/hooks/index.html +++ b/docs/tutorial/Customization/hooks/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/limitingAccess/index.html b/docs/tutorial/Customization/limitingAccess/index.html index 3c1832c5b..a88fcc956 100644 --- a/docs/tutorial/Customization/limitingAccess/index.html +++ b/docs/tutorial/Customization/limitingAccess/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/menuConfiguration/index.html b/docs/tutorial/Customization/menuConfiguration/index.html index b960a1671..8b94f2951 100644 --- a/docs/tutorial/Customization/menuConfiguration/index.html +++ b/docs/tutorial/Customization/menuConfiguration/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/pageInjections/index.html b/docs/tutorial/Customization/pageInjections/index.html index 6db6abe10..bc3cf4ef1 100644 --- a/docs/tutorial/Customization/pageInjections/index.html +++ b/docs/tutorial/Customization/pageInjections/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/security/index.html b/docs/tutorial/Customization/security/index.html index 7cf5c3086..be39842c0 100644 --- a/docs/tutorial/Customization/security/index.html +++ b/docs/tutorial/Customization/security/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Customization/virtualColumns/index.html b/docs/tutorial/Customization/virtualColumns/index.html index 184ffb4f2..8c3e97b65 100644 --- a/docs/tutorial/Customization/virtualColumns/index.html +++ b/docs/tutorial/Customization/virtualColumns/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Plugins/AuditLog/index.html b/docs/tutorial/Plugins/AuditLog/index.html index b7b1505f4..5e69b58c6 100644 --- a/docs/tutorial/Plugins/AuditLog/index.html +++ b/docs/tutorial/Plugins/AuditLog/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Plugins/ForeignInlineList/index.html b/docs/tutorial/Plugins/ForeignInlineList/index.html index c4ee2c01d..b8d50cb4c 100644 --- a/docs/tutorial/Plugins/ForeignInlineList/index.html +++ b/docs/tutorial/Plugins/ForeignInlineList/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Plugins/RichEditor/index.html b/docs/tutorial/Plugins/RichEditor/index.html index 3251b77c9..cfcf0c3d7 100644 --- a/docs/tutorial/Plugins/RichEditor/index.html +++ b/docs/tutorial/Plugins/RichEditor/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Plugins/TwoFactorsAuth/index.html b/docs/tutorial/Plugins/TwoFactorsAuth/index.html index 93c8444ac..015154802 100644 --- a/docs/tutorial/Plugins/TwoFactorsAuth/index.html +++ b/docs/tutorial/Plugins/TwoFactorsAuth/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Plugins/chat-gpt/index.html b/docs/tutorial/Plugins/chat-gpt/index.html index be20aedb8..4883b23e9 100644 --- a/docs/tutorial/Plugins/chat-gpt/index.html +++ b/docs/tutorial/Plugins/chat-gpt/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Plugins/email-password-reset/index.html b/docs/tutorial/Plugins/email-password-reset/index.html index b7dbeccd5..9005f996b 100644 --- a/docs/tutorial/Plugins/email-password-reset/index.html +++ b/docs/tutorial/Plugins/email-password-reset/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Plugins/import-export/index.html b/docs/tutorial/Plugins/import-export/index.html index 7345508a6..6cca4effa 100644 --- a/docs/tutorial/Plugins/import-export/index.html +++ b/docs/tutorial/Plugins/import-export/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/Plugins/upload/index.html b/docs/tutorial/Plugins/upload/index.html index 8368dd54b..2ea0384af 100644 --- a/docs/tutorial/Plugins/upload/index.html +++ b/docs/tutorial/Plugins/upload/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/deploy/index.html b/docs/tutorial/deploy/index.html index 418cc3362..6f18440e5 100644 --- a/docs/tutorial/deploy/index.html +++ b/docs/tutorial/deploy/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/gettingStarted/index.html b/docs/tutorial/gettingStarted/index.html index 8a118a283..a723c8d7b 100644 --- a/docs/tutorial/gettingStarted/index.html +++ b/docs/tutorial/gettingStarted/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/glossary/index.html b/docs/tutorial/glossary/index.html index 3352ec5b7..54c4701c1 100644 --- a/docs/tutorial/glossary/index.html +++ b/docs/tutorial/glossary/index.html @@ -15,7 +15,7 @@ - + diff --git a/docs/tutorial/helloWorld/index.html b/docs/tutorial/helloWorld/index.html index f88a51fad..432918e0a 100644 --- a/docs/tutorial/helloWorld/index.html +++ b/docs/tutorial/helloWorld/index.html @@ -15,7 +15,7 @@ - + diff --git a/index.html b/index.html index 7afd89ff2..f82a6d415 100644 --- a/index.html +++ b/index.html @@ -15,7 +15,7 @@ - + diff --git a/search/index.html b/search/index.html index 6599fe0c1..a0d43e103 100644 --- a/search/index.html +++ b/search/index.html @@ -15,7 +15,7 @@ - +