-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy path4a260936.098fa74e.js
1 lines (1 loc) · 45.4 KB
/
4a260936.098fa74e.js
1
"use strict";(self.webpackChunkadminforth=self.webpackChunkadminforth||[]).push([[2728],{6091:(e,n,t)=>{t.r(n),t.d(n,{assets:()=>l,contentTitle:()=>a,default:()=>p,frontMatter:()=>o,metadata:()=>r,toc:()=>d});var r=t(1533),s=t(4848),i=t(8453);const o={slug:"ai-blog",title:"Build AI-Assisted blog with AdminForth and Nuxt in 20 minutes",authors:"ivanb",tags:["nuxt","chatgpt"]},a=void 0,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,i.R)(),...e.components};return(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(n.p,{children:"Many developers today are using copilots to write code faster and relax their minds from a routine tasks."}),"\n",(0,s.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,s.jsx)(n.p,{children:(0,s.jsx)(n.img,{alt:"alt text",src:t(2397).A+"",width:"1999",height:"1499"})}),"\n",(0,s.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,s.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,s.jsx)(n.p,{children:"Our tech stack will include:"}),"\n",(0,s.jsxs)(n.ul,{children:["\n",(0,s.jsxs)(n.li,{children:[(0,s.jsx)(n.a,{href:"https://nuxt.com/",children:"Nuxt.js"})," - SEO-friendly page rendering framework"]}),"\n",(0,s.jsxs)(n.li,{children:[(0,s.jsx)(n.a,{href:"https://adminforth.dev/",children:"AdminForth"})," - Admin panel framework for creating posts"]}),"\n",(0,s.jsxs)(n.li,{children:[(0,s.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,s.jsx)(n.li,{children:"Node and typescript"}),"\n",(0,s.jsx)(n.li,{children:"Prisma for migrations"}),"\n",(0,s.jsx)(n.li,{children:"SQLite for database, though you can easily switch it to Postgres or MongoDB"}),"\n"]}),"\n",(0,s.jsx)(n.h2,{id:"prerequirements",children:"Prerequirements"}),"\n",(0,s.jsxs)(n.p,{children:["We will use Node v20, if you not have it installed, we recommend ",(0,s.jsx)(n.a,{href:"https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script",children:"NVM"})]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"nvm install 20\nnvm alias default 20\nnvm use 20\n"})}),"\n",(0,s.jsx)(n.h2,{id:"step-1-create-a-new-adminforth-project",children:"Step 1: Create a new AdminForth project"}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"mkdir ai-blog\ncd ai-blog\nnpm init -y\nnpm i adminforth @adminforth/upload @adminforth/rich-editor @adminforth/chat-gpt \\\n express slugify http-proxy @types/express typescript tsx @types/node -D\nnpx --yes tsc --init --module NodeNext --target ESNext\n"})}),"\n",(0,s.jsx)(n.h2,{id:"step-2-prepare-environment",children:"Step 2: Prepare environment"}),"\n",(0,s.jsx)(n.h3,{id:"openai",children:"OpenAI"}),"\n",(0,s.jsxs)(n.p,{children:["To allocate OpenAI API key, go to ",(0,s.jsx)(n.a,{href:"https://platform.openai.com/",children:"https://platform.openai.com/"}),", open Dashboard -> API keys -> Create new secret key."]}),"\n",(0,s.jsx)(n.h3,{id:"s3",children:"S3"}),"\n",(0,s.jsxs)(n.ol,{children:["\n",(0,s.jsxs)(n.li,{children:["Go to ",(0,s.jsx)(n.a,{href:"https://aws.amazon.com",children:"https://aws.amazon.com"})," and login."]}),"\n",(0,s.jsxs)(n.li,{children:["Go to Services -> S3 and create a bucket. Put in bucket name e.g. ",(0,s.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,s.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,s.jsx)(n.pre,{children:(0,s.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,s.jsxs)(n.blockquote,{children:["\n",(0,s.jsxs)(n.p,{children:["\u261d\ufe0f In AllowedOrigins add all your domains. For example if you will serve blog and admin on ",(0,s.jsx)(n.code,{children:"https://blog.example.com/"})," you should add\n",(0,s.jsx)(n.code,{children:'"https://blog.example.com"'})," to AllowedOrigins:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-json",children:'[\n "https://blog.example.com",\n "http://localhost:3500"\n]\n'})}),"\n",(0,s.jsxs)(n.p,{children:["Every character matters, so don't forget to add ",(0,s.jsx)(n.code,{children:"http://"})," or ",(0,s.jsx)(n.code,{children:"https://"})," and don't add slashes at the end of the domain."]}),"\n"]}),"\n",(0,s.jsxs)(n.ol,{start:"4",children:["\n",(0,s.jsxs)(n.li,{children:["Go to Services -> IAM and create a new user. Put in user name e.g. ",(0,s.jsx)(n.code,{children:"my-ai-blog-bucket"}),"."]}),"\n",(0,s.jsxs)(n.li,{children:["Attach existing policies directly -> ",(0,s.jsx)(n.code,{children:"AmazonS3FullAccess"}),". Go to your user -> ",(0,s.jsx)(n.code,{children:"Add permissions"})," -> ",(0,s.jsx)(n.code,{children:"Attach policies directly"})," -> ",(0,s.jsx)(n.code,{children:"AmazonS3FullAccess"})]}),"\n",(0,s.jsxs)(n.li,{children:["Go to Security credentials and create a new access key. Save ",(0,s.jsx)(n.code,{children:"Access key ID"})," and ",(0,s.jsx)(n.code,{children:"Secret access key"}),"."]}),"\n"]}),"\n",(0,s.jsx)(n.h3,{id:"create-env-file-in-project-directory",children:"Create .env file in project directory"}),"\n",(0,s.jsxs)(n.p,{children:["Create ",(0,s.jsx)(n.code,{children:".env"})," file with the following content:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",metastring:'title=".env"',children:"DATABASE_URL=file:./db/db.sqlite\nADMINFORTH_SECRET=<some random string>\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,s.jsx)(n.h2,{id:"step-3-initialize-database",children:"Step 3: Initialize database"}),"\n",(0,s.jsxs)(n.p,{children:["Create ",(0,s.jsx)(n.code,{children:"./schema.prisma"})," and put next content there:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.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,s.jsxs)(n.p,{children:["Create database using ",(0,s.jsx)(n.code,{children:"prisma migrate"}),":"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"npx -y prisma migrate dev --name init\n"})}),"\n",(0,s.jsxs)(n.blockquote,{children:["\n",(0,s.jsxs)(n.p,{children:["in future if you will need to update schema, you can run ",(0,s.jsx)(n.code,{children:"npx prisma migrate dev --name <name>"})," where ",(0,s.jsx)(n.code,{children:"<name>"})," is a name of migration."]}),"\n"]}),"\n",(0,s.jsx)(n.h2,{id:"step-4-setting-up-adminforth",children:"Step 4: Setting up AdminForth"}),"\n",(0,s.jsxs)(n.p,{children:["Open ",(0,s.jsx)(n.code,{children:"package.json"}),", set ",(0,s.jsx)(n.code,{children:"type"})," to ",(0,s.jsx)(n.code,{children:"module"})," and add ",(0,s.jsx)(n.code,{children:"start"})," script:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.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,s.jsxs)(n.p,{children:["Create ",(0,s.jsx)(n.code,{children:"index.ts"})," file in root directory with following content:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.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: 'adminuser', // 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: 'adminuser',\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('adminuser').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('adminuser').get([Filters.EQ('email', 'adminforth@adminforth.dev')])) {\n await admin.resource('adminuser').create({\n email: 'adminforth@adminforth.dev',\n passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'),\n });\n }\n });\n\n admin.express.listen(port, () => {\n console.log(`\\n\u26a1 AdminForth is available at http://localhost:${port}\\n`)\n });\n}\n"})}),"\n",(0,s.jsx)(n.h2,{id:"step-5-create-resources",children:"Step 5: Create resources"}),"\n",(0,s.jsxs)(n.p,{children:["Create ",(0,s.jsx)(n.code,{children:"res"})," folder. Create ",(0,s.jsx)(n.code,{children:"./res/adminuser.ts"})," file with following content:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-ts",metastring:'title="./res/adminuser.ts"',children:"import AdminForth, { AdminForthDataTypes } from 'adminforth';\nimport { randomUUID } from 'crypto';\nimport UploadPlugin from '@adminforth/upload';\n\nexport default {\n dataSource: 'maindb',\n table: 'adminuser',\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: {\n edit: false,\n create: false,\n },\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: {\n edit: false,\n create: false,\n },\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: {\n show: false,\n list: false,\n filter: false,\n },\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: { all: false } },\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,s.jsxs)(n.p,{children:["Create ",(0,s.jsx)(n.code,{children:"posts.ts"})," file in res directory with following content:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.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: {\n list: false,\n edit: false,\n create: false,\n },\n },\n {\n name: 'title',\n required: true,\n showIn: { all: true },\n maxLength: 255,\n minLength: 3,\n type: AdminForthDataTypes.STRING,\n },\n {\n name: 'picture',\n showIn: { all: true },\n },\n {\n name: 'slug',\n showIn: {\n list: false,\n edit: false,\n create: false,\n },\n },\n {\n name: 'content',\n showIn: { list: false },\n type: AdminForthDataTypes.RICHTEXT,\n },\n {\n name: 'createdAt',\n showIn: {\n edit: false,\n create: false,\n },\n fillOnCreate: () => (new Date()).toISOString(),\n },\n {\n name: 'published',\n required: true,\n },\n {\n name: 'authorId',\n foreignResource: {\n resourceId: 'adminuser',\n },\n showIn: {\n list: false,\n edit: false,\n create: false,\n },\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,s.jsxs)(n.p,{children:["Also create ",(0,s.jsx)(n.code,{children:"content-image.ts"})," file in ",(0,s.jsx)(n.code,{children:"res"})," directory with following content:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.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: {\n edit: false,\n create: false,\n },\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,s.jsx)(n.p,{children:"Now you can start your admin panel:"}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"npm start\n"})}),"\n",(0,s.jsxs)(n.p,{children:["Open ",(0,s.jsx)(n.code,{children:"http://localhost:3500/admin"})," in your browser and login with ",(0,s.jsx)(n.code,{children:"adminforth@adminforth.dev"})," and ",(0,s.jsx)(n.code,{children:"adminforth"})," credentials.\nSet up your avatar (you can generate it with AI) and public name in user settings."]}),"\n",(0,s.jsx)(n.p,{children:(0,s.jsx)(n.img,{alt:"alt text",src:t(2121).A+"",width:"3670",height:"2588"})}),"\n",(0,s.jsx)(n.h2,{id:"step-5-create-nuxt-project",children:"Step 5: Create Nuxt project"}),"\n",(0,s.jsx)(n.p,{children:"Now let's initialize our seo-facing frontend:"}),"\n",(0,s.jsx)(n.pre,{children:(0,s.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,s.jsxs)(n.p,{children:["Edit ",(0,s.jsx)(n.code,{children:"app.vue"}),":"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-html",metastring:'title="./seo/app.vue"',children:'<template>\n <div id="app">\n <NuxtPage />\n </div>\n</template>\n\n\n<style lang="scss">\n\n$grColor1: #74E1FF;\n$grColor2: #8580B4;\n$grColor3: #5E53C3;\n$grColor4: #4FC7E9;\n$grColor5: #695BE9;\n\n #app {\n font-family: Avenir, Helvetica, Arial, sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n // gradient with color spots\n animation: gradient 15s ease infinite;\n min-height: 100vh;\n }\n body {\n margin: 0;\n padding: 0;\n max-height: 100vh;\n overflow: overlay;\n background-image: radial-gradient(\n circle farthest-corner at top left, $grColor1 0%, rgba(225, 243, 97,0) 50%),\n radial-gradient(\n circle farthest-side at top right, $grColor2 0%, rgba(181, 176, 177,0) 10%),\n radial-gradient(circle farthest-corner at bottom right, $grColor3 0%, rgba(204, 104, 119, 0) 33%),\n radial-gradient(\n circle farthest-corner at top right, $grColor4 0%, rgba(155, 221, 240,0) 50%),\n radial-gradient(ellipse at bottom center, $grColor5 0%, rgba(254, 43, 0, 0) 80%); \n background-attachment: fixed;\n }\n</style>\n'})}),"\n",(0,s.jsxs)(n.p,{children:["Add folder ",(0,s.jsx)(n.code,{children:"pages"})," and create ",(0,s.jsx)(n.code,{children:"index.vue"}),":"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-html",metastring:'title="./seo/pages/index.vue"',children:'<template>\n <div class="container">\n <PostCard \n v-for="post in posts" \n :key="post.id" \n :post="post"\n />\n <div class="no-posts" v-if="!posts.length">\n No posts added yet\n <a href="/admin">Add a first one in admin</a>\n </div>\n </div>\n</template>\n\n<style lang="scss">\n.container {\n display: flex;\n justify-content: center;\n align-items: center;\n flex-wrap: wrap;\n flex-direction: column;\n gap: 1rem;\n padding-top: 2rem;\n}\n\n.no-posts {\n margin-top: 2rem;\n font-size: 1.5rem;\n text-align: center;\n background-color: rgba(255 244 255 / 0.43);\n padding: 2rem;\n border-radius: 0.5rem;\n border: 1px solid #FFFFFF;\n box-shadow: 0.2rem 0.3rem 2rem rgba(0, 0, 0, 0.1);\n color: #555;\n a {\n color: #333;\n text-decoration: underline;\n margin-top: 1rem;\n display: block;\n font-size: 1.2rem;\n }\n\n}\n</style>\n\n<script lang="ts" setup>\n\nimport PostCard from \'~/PostCard.vue\'\n\nconst posts = ref([])\n\nonMounted(async () => {\n const resp = await fetch(`/api/posts`);\n posts.value = await resp.json();\n})\n\n<\/script>\n'})}),"\n",(0,s.jsxs)(n.p,{children:["Finally, create ",(0,s.jsx)(n.code,{children:"PostCard.vue"})," component:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-html",metastring:'title="./seo/PostCard.vue"',children:'<template>\n <div class="post-card">\n <img v-if="props.post.picture" :src="props.post.picture" alt="post image" />\n <h2>{{ props.post.title }}</h2>\n <div class="content" v-html="props.post.content"></div>\n <div class="posted-at">\n <div>{{ formatDate(props.post.createdAt) }}</div>\n <div class="author">\n <img :src="props.post.author.avatar" alt="author avatar" />\n <div>\n {{ props.post.author.publicName }}\n </div>\n </div>\n </div>\n </div>\n</template>\n\n<script setup lang="ts">\n\nconst props = defineProps<{\n post: {\n title: string\n content: string\n createdAt: string // iso date\n picture?: string\n author: {\n publicName: string\n avatar: string\n }\n }\n}>()\n\n\nfunction formatDate(date: string) {\n // format to format MMM DD, YYYY using Intl.DateTimeFormat\n return new Intl.DateTimeFormat(\'en-US\', {\n month: \'short\',\n day: \'2-digit\',\n year: \'numeric\'\n }).format(new Date(date))\n}\n<\/script>\n\n<style lang="scss">\n\n.post-card {\n background-color: rgba(255 244 255 / 0.43);\n padding: 2rem;\n border-radius: 0.5rem;\n border: 1px solid #FFFFFF;\n box-shadow: 0.2rem 0.3rem 2rem rgba(0, 0, 0, 0.1);\n max-width: calc(100vw - 4rem);\n width: 600px;\n color: #333;\n line-height: 1.8rem;\n\n >img {\n width: 100%;\n border-radius: 0.5rem;\n margin-bottom: 2rem;\n }\n \n h2 {\n margin: 0 0 2rem 0;\n font-size: 1.5rem;\n }\n\n .content {\n margin-top: 1rem;\n }\n\n .posted-at {\n margin-top: 1rem;\n font-size: 0.8rem;\n color: #666;\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n .author {\n display: flex;\n align-items: center;\n\n img {\n width: 2rem;\n height: 2rem;\n border-radius: 50%;\n margin-right: 0.5rem;\n }\n div {\n // flash wire dot line effect\n position: relative;\n overflow: hidden;\n border-radius: 1rem;\n padding: 0.2rem 0.5rem;\n font-size: 1rem;\n background: linear-gradient(90deg, rgb(0 21 255) 0%, rgb(0 0 0) 100%);\n background-size: 200% auto;\n background-clip: text;\n -webkit-background-clip: text;\n color: transparent; /* Hide the original text color */\n animation: shimmer 2s infinite;\n @keyframes shimmer {\n 0% {\n background-position: -200% center;\n }\n 100% {\n background-position: 200% center;\n }\n }\n\n }\n }\n\n}\n\n</style>\n'})}),"\n",(0,s.jsx)(n.p,{children:"Now you can start your Nuxt project:"}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"npm run dev\n"})}),"\n",(0,s.jsxs)(n.p,{children:["And run ",(0,s.jsx)(n.code,{children:"npm start"})," if you did not run it previously:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"npm start\n"})}),"\n",(0,s.jsxs)(n.p,{children:["Open ",(0,s.jsx)(n.code,{children:"http://localhost:3500"})," in your browser and you will see your blog with posts from admin panel:"]}),"\n",(0,s.jsx)(n.p,{children:(0,s.jsx)(n.img,{alt:"alt text",src:t(8423).A+"",width:"3700",height:"1932"})}),"\n",(0,s.jsxs)(n.p,{children:["Go to ",(0,s.jsx)(n.code,{children:"http://localhost:3500/admin"})," to add new posts."]}),"\n",(0,s.jsx)(n.h2,{id:"step-6-deploy",children:"Step 6: Deploy"}),"\n",(0,s.jsx)(n.p,{children:"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."}),"\n",(0,s.jsx)(n.p,{children:"Please note that in this demo example we routing requests to Nuxt.js app from AdminForth app using http-proxy.\nWhile this will work fine, it might give slower serving then if you would route traffik using dedicated reverse proxies like traefik or nginx."}),"\n",(0,s.jsx)(n.h3,{id:"dockerize-in-single-container",children:"Dockerize in single container"}),"\n",(0,s.jsxs)(n.p,{children:["Create ",(0,s.jsx)(n.code,{children:"bundleNow.ts"})," file in root project directory:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-ts",metastring:'title="./bundleNow.ts"',children:"import { admin } from './index.js';\n\nawait admin.bundleNow({ hotReload: false});\nconsole.log('Bundling AdminForth done.');\n"})}),"\n",(0,s.jsxs)(n.p,{children:["Create ",(0,s.jsx)(n.code,{children:"Dockerfile"})," in root project directory:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-dockerfile",metastring:'title="./Dockerfile"',children:'FROM node:20-alpine\nEXPOSE 3500\nWORKDIR /app\nRUN apk add --no-cache supervisor\nCOPY package.json package-lock.json ./\nRUN npm ci\nCOPY seo/package.json seo/package-lock.json seo/\nRUN cd seo && npm ci\nCOPY . .\n\nRUN npx tsx bundleNow.ts\nRUN cd seo && npm run build\n\nRUN cat > /etc/supervisord.conf <<EOF\n[supervisord]\nnodaemon=true\n\n[program:app]\ncommand=npm run startLive\ndirectory=/app\nautostart=true\nautorestart=true\nstdout_logfile=/dev/stdout\nstderr_logfile=/dev/stderr\n\n[program:seo]\ncommand=sh -c "cd seo && node .output/server/index.mjs"\ndirectory=/app\nautostart=true\nautorestart=true\nstdout_logfile=/dev/stdout\nstderr_logfile=/dev/stderr\n\n[program:prisma]\ncommand=npx --yes prisma migrate deploy\ndirectory=/app\nautostart=true\nstdout_logfile=/dev/stdout\nstderr_logfile=/dev/stderr\n\nEOF\n\nCMD ["supervisord", "-c", "/etc/supervisord.conf"]\n'})}),"\n",(0,s.jsxs)(n.p,{children:["Create ",(0,s.jsx)(n.code,{children:".dockerignore"})," file in root project directory:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",metastring:'title=".dockerignore"',children:".env\nnode_modules\nseo/node_modules\n.git\ndb\n*.tar\n.terraform*\nterraform*\n*.tf\n"})}),"\n",(0,s.jsx)(n.p,{children:"Build and run your docker container locally:"}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"sudo docker run -p80:3500 -v ./prodDb:/app/db --env-file .env -it $(docker build -q .)\n"})}),"\n",(0,s.jsxs)(n.p,{children:["Now you can open ",(0,s.jsx)(n.code,{children:"http://localhost"})," in your browser and see your blog."]}),"\n",(0,s.jsx)(n.h3,{id:"deploy-to-ec2-with-terraform",children:"Deploy to EC2 with terraform"}),"\n",(0,s.jsxs)(n.p,{children:["First of all install Terraform as described here ",(0,s.jsx)(n.a,{href:"https://developer.hashicorp.com/terraform/install#linux",children:"terraform installation"}),"."]}),"\n",(0,s.jsx)(n.p,{children:"If you are on Ubuntu(WSL2 or native) you can use the following commands:"}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:'wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg\necho "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\nsudo apt update && sudo apt install terraform\n'})}),"\n",(0,s.jsxs)(n.p,{children:["Create special AWS credentials for deployemnts by going to ",(0,s.jsx)(n.code,{children:"AWS console"})," -> ",(0,s.jsx)(n.code,{children:"IAM"})," -> ",(0,s.jsx)(n.code,{children:"Users"})," -> ",(0,s.jsx)(n.code,{children:"Add user"})," (e.g. my-ai-blog-user) -> Attach existing policies directly -> ",(0,s.jsx)(n.code,{children:"AdministratorAccess"})," -> Create user. Save ",(0,s.jsx)(n.code,{children:"Access key ID"})," and ",(0,s.jsx)(n.code,{children:"Secret access key"})," into ",(0,s.jsx)(n.code,{children:"~/.aws/credentials"})," file:"]}),"\n",(0,s.jsx)(n.p,{children:"Create or open file:"}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"code ~/.aws/credentials\n"})}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"...\n\n[myaws]\naws_access_key_id = YOUR_ACCESS_KEY\naws_secret_access_key = YOUR_SECRET\n"})}),"\n",(0,s.jsxs)(n.p,{children:["Create file ",(0,s.jsx)(n.code,{children:"main.tf"})," in root project directory:"]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-hcl",metastring:'title="./main.tf"',children:'provider "aws" {\n region = "eu-central-1"\n profile = "myaws"\n}\n\ndata "aws_ami" "amazon_linux" {\n most_recent = true\n owners = ["amazon"]\n\n filter {\n name = "name"\n values = ["amzn2-ami-hvm-*-x86_64-gp2"]\n }\n}\n\ndata "aws_vpc" "default" {\n default = true\n}\n\ndata "aws_subnet" "default_subnet" {\n filter {\n name = "vpc-id"\n values = [data.aws_vpc.default.id]\n }\n\n filter {\n name = "default-for-az"\n values = ["true"]\n }\n\n filter {\n name = "availability-zone"\n values = ["eu-central-1a"]\n }\n}\n\nresource "aws_security_group" "instance_sg" {\n name = "my-ai-blog-instance-sg"\n vpc_id = data.aws_vpc.default.id\n\n ingress {\n description = "Allow HTTP"\n from_port = 80\n to_port = 80\n protocol = "tcp"\n cidr_blocks = ["0.0.0.0/0"]\n }\n\n # SSH\n ingress {\n description = "Allow SSH"\n from_port = 22\n to_port = 22\n protocol = "tcp"\n cidr_blocks = ["0.0.0.0/0"]\n }\n\n egress {\n description = "Allow all outbound traffic"\n from_port = 0\n to_port = 0\n protocol = "-1"\n cidr_blocks = ["0.0.0.0/0"]\n }\n}\n\nresource "aws_key_pair" "deployer" {\n key_name = "terraform-deployer-key"\n public_key = file("~/.ssh/id_rsa.pub") # Path to your public SSH key\n}\n\n\nresource "aws_instance" "docker_instance" {\n ami = data.aws_ami.amazon_linux.id\n instance_type = "t3a.micro"\n subnet_id = data.aws_subnet.default_subnet.id\n vpc_security_group_ids = [aws_security_group.instance_sg.id]\n key_name = aws_key_pair.deployer.key_name\n\n # prevent accidental termination of ec2 instance and data loss\n # if you will need to recreate the instance still (not sure why it can be?), you will need to remove this block manually by next command:\n # > terraform taint aws_instance.app_instance\n lifecycle {\n prevent_destroy = true\n ignore_changes = [ami]\n }\n\n user_data = <<-EOF\n #!/bin/bash\n yum update -y\n amazon-linux-extras install docker -y\n systemctl start docker\n systemctl enable docker\n usermod -a -G docker ec2-user\n EOF\n\n tags = {\n Name = "my-ai-blog-instance"\n }\n}\n\nresource "null_resource" "build_image" {\n provisioner "local-exec" {\n command = "docker build -t blogapp . && docker save blogapp:latest -o blogapp_image.tar"\n }\n triggers = {\n always_run = timestamp() # Force re-run if necessary\n }\n}\n\nresource "null_resource" "remote_commands" {\n depends_on = [aws_instance.docker_instance, null_resource.build_image]\n\n triggers = {\n always_run = timestamp()\n }\n\n\n provisioner "file" {\n source = "${path.module}/blogapp_image.tar"\n destination = "/home/ec2-user/blogapp_image.tar"\n \n connection {\n type = "ssh"\n user = "ec2-user"\n private_key = file("~/.ssh/id_rsa")\n host = aws_instance.docker_instance.public_ip\n }\n }\n\n provisioner "file" {\n source = "${path.module}/.env"\n destination = "/home/ec2-user/.env"\n \n connection {\n type = "ssh"\n user = "ec2-user"\n private_key = file("~/.ssh/id_rsa")\n host = aws_instance.docker_instance.public_ip\n }\n }\n\n provisioner "remote-exec" {\n inline = [\n "while ! command -v docker &> /dev/null; do echo \'Waiting for Docker to be installed...\'; sleep 1; done",\n "while ! sudo docker info &> /dev/null; do echo \'Waiting for Docker to start...\'; sleep 1; done",\n "sudo docker system prune -af",\n "docker load -i /home/ec2-user/blogapp_image.tar",\n "sudo docker rm -f blogapp || true",\n "sudo docker run --env-file .env -d -p 80:3500 --name blogapp -v /home/ec2-user/db:/app/db blogapp"\n ]\n\n connection {\n type = "ssh"\n user = "ec2-user"\n private_key = file("~/.ssh/id_rsa")\n host = aws_instance.docker_instance.public_ip\n }\n }\n\n \n}\n\noutput "instance_public_ip" {\n value = aws_instance.docker_instance.public_ip\n}\n\n'})}),"\n",(0,s.jsx)(n.p,{children:"Now you can deploy your app to AWS EC2:"}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{className:"language-bash",children:"terraform init\nterraform apply -auto-approve\n"})}),"\n",(0,s.jsxs)(n.blockquote,{children:["\n",(0,s.jsxs)(n.p,{children:["\u261d\ufe0f To destroy and stop billing run ",(0,s.jsx)(n.code,{children:"terraform destroy -auto-approve"})]}),"\n"]}),"\n",(0,s.jsxs)(n.blockquote,{children:["\n",(0,s.jsxs)(n.p,{children:["\u261d\ufe0f To check logs run ",(0,s.jsx)(n.code,{children:"ssh -i ~/.ssh/id_rsa ec2-user@$(terraform output instance_public_ip)"}),", then ",(0,s.jsx)(n.code,{children:"sudo docker logs -n100 -f aiblog"})]}),"\n"]}),"\n",(0,s.jsxs)(n.p,{children:["Terraform config will build Docker image locally and then copy it to EC2 instance. This approach allows to save build resources (CPU/RAM) on EC2 instance, however increases network traffic (image might be around 200MB). If you want to build image on EC2 instance, you can adjust config slightly: remove ",(0,s.jsx)(n.code,{children:"null_resource.build_image"})," and change ",(0,s.jsx)(n.code,{children:"null_resource.remote_commands"})," to build image on EC2 instance, however micro instance most likely will not be able to build and keep app running at the same time, so you will need to increase instance type or terminate app while building image (which introduces downtime so not recommended as well)."]}),"\n",(0,s.jsx)(n.h3,{id:"add-https-and-cdn",children:"Add HTTPs and CDN"}),"\n",(0,s.jsxs)(n.p,{children:["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 ",(0,s.jsx)(n.a,{href:"https://cloudflare.com",children:"https://cloudflare.com"})," and create an account. Add your domain and follow instructions to change your domain nameservers to Cloudflare ones."]}),"\n",(0,s.jsxs)(n.p,{children:["Go to your domain settings and add A record with your server IP address, which was shown in output of ",(0,s.jsx)(n.code,{children:"terraform apply"})," command."]}),"\n",(0,s.jsx)(n.pre,{children:(0,s.jsx)(n.code,{children:"Type: A\nName: blog\nValue: x.y.z.w\nCloudflare proxy: orange (enabled)\n"})}),"\n",(0,s.jsx)(n.p,{children:(0,s.jsx)(n.img,{alt:"alt text",src:t(670).A+"",width:"2476",height:"502"})}),"\n",(0,s.jsx)(n.h2,{id:"useful-links",children:"Useful links"}),"\n",(0,s.jsxs)(n.ul,{children:["\n",(0,s.jsx)(n.li,{children:(0,s.jsx)(n.a,{href:"https://github.com/devforth/adminforth-example-ai-blog",children:"Full source code of the project"})}),"\n",(0,s.jsx)(n.li,{children:(0,s.jsx)(n.a,{href:"https://blog-demo.adminforth.dev/admin/resource/post",children:"Live demo of AI BLog"})}),"\n",(0,s.jsx)(n.li,{children:(0,s.jsx)(n.a,{href:"https://adminforth.dev/docs/tutorial/gettingStarted/",children:"AdminForth documentation"})}),"\n",(0,s.jsx)(n.li,{children:(0,s.jsx)(n.a,{href:"https://github.com/devforth/adminforth",children:"AdminForth GitHub"})}),"\n",(0,s.jsx)(n.li,{children:(0,s.jsx)(n.a,{href:"https://nuxt.com/docs/getting-started/introduction",children:"Nuxt.js documentation"})}),"\n"]})]})}function p(e={}){const{wrapper:n}={...(0,i.R)(),...e.components};return n?(0,s.jsx)(n,{...e,children:(0,s.jsx)(c,{...e})}):c(e)}},2121:(e,n,t)=>{t.d(n,{A:()=>r});const r=t.p+"assets/images/aiblogpost-0dd2eb35b7b6ebca0822a2cc79711c44.png"},670:(e,n,t)=>{t.d(n,{A:()=>r});const r=t.p+"assets/images/image-19ae59e490ade7cd0c77f839f664ecf5.png"},8423:(e,n,t)=>{t.d(n,{A:()=>r});const r=t.p+"assets/images/localhost_3500_-c9ecd1678af4f1eaf02d27640104af5e.png"},2397:(e,n,t)=>{t.d(n,{A:()=>r});const r=t.p+"assets/images/nuxtBlog-d00baa7e1b41d87869b09d475045fa7b.gif"},8453:(e,n,t)=>{t.d(n,{R:()=>o,x:()=>a});var r=t(6540);const s={},i=r.createContext(s);function o(e){const n=r.useContext(i);return r.useMemo((function(){return"function"==typeof e?e(n):{...n,...e}}),[n,e])}function a(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(s):e.components||s:o(e.components),r.createElement(i.Provider,{value:n},e.children)}},1533:e=>{e.exports=JSON.parse('{"permalink":"/blog/ai-blog","source":"@site/blog/2024-10-01-ai-blog/index.md","title":"Build AI-Assisted blog with AdminForth and Nuxt in 20 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":false,"label":"Nuxt.js","permalink":"/blog/tags/nuxt","description":"Nuxt.js is a free and open-source web application framework based on Vue.js, Node.js, Webpack, and Babel.js."},{"inline":false,"label":"ChatGPT","permalink":"/blog/tags/chatgpt","description":"ChatGPT is a conversational AI model that can generate human-like responses to text inputs."}],"readingTime":18.68,"hasTruncateMarker":true,"authors":[{"name":"Ivan Borshchov","title":"Maintainer of AdminForth","url":"https://github.com/ivictbor","imageURL":"https://avatars.githubusercontent.com/u/1838656?v=4","key":"ivanb","page":null}],"frontMatter":{"slug":"ai-blog","title":"Build AI-Assisted blog with AdminForth and Nuxt in 20 minutes","authors":"ivanb","tags":["nuxt","chatgpt"]},"unlisted":false,"prevItem":{"title":"Deploy AdminForth to EC2 with terraform (without CI)","permalink":"/blog/compose-ec2-deployment"},"nextItem":{"title":"Chat-GPT plugin to co-write texts and strings","permalink":"/blog/chatgpt-plugin"}}')}}]);