diff --git a/adminforth/commands/createCustomComponent/configUpdater.js b/adminforth/commands/createCustomComponent/configUpdater.js index dbd1d8ea..b2ed56c4 100644 --- a/adminforth/commands/createCustomComponent/configUpdater.js +++ b/adminforth/commands/createCustomComponent/configUpdater.js @@ -205,3 +205,282 @@ export async function updateResourceConfig(resourceId, columnName, fieldType, co throw new Error(`Failed to update resource file ${path.basename(filePath)}: ${error.message}`); } } + + +export async function injectLoginComponent(indexFilePath, componentPath) { + console.log(chalk.dim(`Reading file: ${indexFilePath}`)); + const content = await fs.readFile(indexFilePath, 'utf-8'); + const ast = recast.parse(content, { + parser: typescriptParser, + }); + + let updated = false; + + recast.visit(ast, { + visitNewExpression(path) { + if ( + n.Identifier.check(path.node.callee) && + path.node.callee.name === 'AdminForth' && + path.node.arguments.length > 0 && + n.ObjectExpression.check(path.node.arguments[0]) + ) { + const configObject = path.node.arguments[0]; + + let customizationProp = configObject.properties.find( + p => n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === 'customization' + ); + + if (!customizationProp) { + const customizationObj = b.objectExpression([]); + customizationProp = b.objectProperty(b.identifier('customization'), customizationObj); + configObject.properties.push(customizationProp); + console.log(chalk.dim(`Added missing 'customization' property.`)); + } + + const customizationValue = customizationProp.value; + if (!n.ObjectExpression.check(customizationValue)) return false; + + let loginPageInjections = customizationValue.properties.find( + p => n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === 'loginPageInjections' + ); + + if (!loginPageInjections) { + const injectionsObj = b.objectExpression([]); + loginPageInjections = b.objectProperty(b.identifier('loginPageInjections'), injectionsObj); + customizationValue.properties.push(loginPageInjections); + console.log(chalk.dim(`Added missing 'loginPageInjections'.`)); + } + + const injectionsValue = loginPageInjections.value; + if (!n.ObjectExpression.check(injectionsValue)) return false; + + let underInputsProp = injectionsValue.properties.find( + p => n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === 'underInputs' + ); + + if (underInputsProp) { + underInputsProp.value = b.stringLiteral(componentPath); + console.log(chalk.dim(`Updated 'underInputs' to ${componentPath}`)); + } else { + injectionsValue.properties.push( + b.objectProperty(b.identifier('underInputs'), b.stringLiteral(componentPath)) + ); + console.log(chalk.dim(`Added 'underInputs': ${componentPath}`)); + } + + updated = true; + this.abort(); + } + return false; + } + }); + + if (!updated) { + throw new Error(`Could not find AdminForth configuration in file: ${indexFilePath}`); + } + + const outputCode = recast.print(ast).code; + await fs.writeFile(indexFilePath, outputCode, 'utf-8'); + console.log(chalk.green(`✅ Successfully updated login injection in: ${indexFilePath}`)); +} + + +export async function injectGlobalComponent(indexFilePath, injectionType, componentPath) { + console.log(chalk.dim(`Reading file: ${indexFilePath}`)); + const content = await fs.readFile(indexFilePath, 'utf-8'); + const ast = recast.parse(content, { + parser: typescriptParser, + }); + + let updated = false; + + console.log(JSON.stringify(injectionType)); + recast.visit(ast, { + visitNewExpression(path) { + if ( + n.Identifier.check(path.node.callee) && + path.node.callee.name === 'AdminForth' && + path.node.arguments.length > 0 && + n.ObjectExpression.check(path.node.arguments[0]) + ) { + const configObject = path.node.arguments[0]; + + let customizationProp = configObject.properties.find( + p => n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === 'customization' + ); + + if (!customizationProp) { + const customizationObj = b.objectExpression([]); + customizationProp = b.objectProperty(b.identifier('customization'), customizationObj); + configObject.properties.push(customizationProp); + console.log(chalk.dim(`Added missing 'customization' property.`)); + } + + const customizationValue = customizationProp.value; + if (!n.ObjectExpression.check(customizationValue)) return false; + + let globalInjections = customizationValue.properties.find( + p => n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === 'globalInjections' + ); + + if (!globalInjections) { + const injectionsObj = b.objectExpression([]); + globalInjections = b.objectProperty(b.identifier('globalInjections'), injectionsObj); + customizationValue.properties.push(globalInjections); + console.log(chalk.dim(`Added missing 'globalInjections'.`)); + } + + const injectionsValue = globalInjections.value; + if (!n.ObjectExpression.check(injectionsValue)) return false; + console.log(JSON.stringify(injectionType)); + let injectionProp = injectionsValue.properties.find( + p => n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === injectionType + ); + if (injectionProp) { + const currentValue = injectionProp.value; + + if (n.ArrayExpression.check(currentValue)) { + currentValue.elements.push(b.stringLiteral(componentPath)); + console.log(chalk.dim(`Added '${componentPath}' to existing array in '${injectionType}'`)); + } else if (n.StringLiteral.check(currentValue)) { + injectionProp.value = b.arrayExpression([ + b.stringLiteral(currentValue.value), + b.stringLiteral(componentPath) + ]); + console.log(chalk.dim(`Converted '${injectionType}' from string to array and added '${componentPath}'`)); + } else { + throw new Error(`Unsupported value type for '${injectionType}'. Must be string or array.`); + } + } else { + injectionsValue.properties.push( + b.objectProperty( + b.identifier(injectionType), + b.arrayExpression([b.stringLiteral(componentPath)]) + ) + ); + console.log(chalk.dim(`Added new array for '${injectionType}' with '${componentPath}'`)); + } + + updated = true; + this.abort(); + } + return false; + } + }); + + if (!updated) { + throw new Error(`Could not find AdminForth configuration in file: ${indexFilePath}`); + } + + const outputCode = recast.print(ast).code; + await fs.writeFile(indexFilePath, outputCode, 'utf-8'); + console.log(chalk.green(`✅ Successfully updated global injection '${injectionType}' in: ${indexFilePath}`)); +} + +export async function updateCrudInjectionConfig(resourceId, crudType, injectionPosition, componentPathForConfig, isThin) { + const filePath = await findResourceFilePath(resourceId); + console.log(chalk.dim(`Attempting to update resource CRUD injection: ${filePath}`)); + + let content; + try { + content = await fs.readFile(filePath, 'utf-8'); + } catch (error) { + console.error(chalk.red(`❌ Error reading resource file: ${filePath}`)); + throw new Error(`Could not read resource file ${filePath}.`); + } + + try { + const ast = recast.parse(content, { + parser: typescriptParser + }); + + let updateApplied = false; + + recast.visit(ast, { + visitExportDefaultDeclaration(path) { + const declaration = path.node.declaration; + let objectExpressionNode = null; + + if (n.TSAsExpression.check(declaration) && n.ObjectExpression.check(declaration.expression)) { + objectExpressionNode = declaration.expression; + } else if (n.ObjectExpression.check(declaration)) { + objectExpressionNode = declaration; + } + + if (!objectExpressionNode) { + console.warn(chalk.yellow(`Warning: Default export in ${filePath} is not an ObjectExpression. Skipping update.`)); + return false; + } + + const getOrCreateObjectProp = (obj, propName) => { + let prop = obj.properties.find(p => n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === propName); + if (!prop) { + const newObject = b.objectExpression([]); + prop = b.objectProperty(b.identifier(propName), newObject); + obj.properties.push(prop); + } + return prop.value; + }; + + const options = getOrCreateObjectProp(objectExpressionNode, 'options'); + if (!n.ObjectExpression.check(options)) return false; + + const pageInjections = getOrCreateObjectProp(options, 'pageInjections'); + if (!n.ObjectExpression.check(pageInjections)) return false; + + let crudProp = pageInjections.properties.find(p => + n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === crudType + ); + + if (!crudProp) { + crudProp = b.objectProperty( + b.identifier(crudType), + b.objectExpression([]) + ); + pageInjections.properties.push(crudProp); + } + + const crudValue = crudProp.value; + if (!n.ObjectExpression.check(crudValue)) return false; + + let injectionProp = crudValue.properties.find(p => + n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === injectionPosition + ); + + const newInjectionObject = b.objectExpression([ + b.objectProperty(b.identifier('file'), b.stringLiteral(componentPathForConfig)), + b.objectProperty( + b.identifier('meta'), + b.objectExpression([ + b.objectProperty(b.identifier('thinEnoughToShrinkTable'), b.booleanLiteral(!!isThin)), + ]) + ), + ]); + + if (injectionProp) { + injectionProp.value = newInjectionObject; + console.log(chalk.dim(`Updated '${injectionPosition}' injection for '${crudType}'.`)); + } else { + crudValue.properties.push(b.objectProperty(b.identifier(injectionPosition), newInjectionObject)); + console.log(chalk.dim(`Added '${injectionPosition}' injection for '${crudType}'.`)); + } + + updateApplied = true; + this.abort(); + return false; + } + }); + + if (!updateApplied) { + throw new Error(`Could not inject CRUD component in resource ${resourceId}.`); + } + + const outputCode = recast.print(ast).code; + await fs.writeFile(filePath, outputCode, 'utf-8'); + console.log(chalk.dim(`✅ Successfully updated CRUD injection in resource file: ${filePath}`)); + + } catch (error) { + console.error(chalk.red(`❌ Error processing resource file: ${filePath}`)); + throw new Error(`Failed to inject CRUD component in ${path.basename(filePath)}: ${error.message}`); + } + } \ No newline at end of file diff --git a/adminforth/commands/createCustomComponent/fileGenerator.js b/adminforth/commands/createCustomComponent/fileGenerator.js index b6b9bfb6..7da934c8 100644 --- a/adminforth/commands/createCustomComponent/fileGenerator.js +++ b/adminforth/commands/createCustomComponent/fileGenerator.js @@ -1,4 +1,5 @@ import fs from 'fs/promises'; +import fsSync from 'fs'; import path from 'path'; import chalk from 'chalk'; import Handlebars from 'handlebars'; @@ -16,22 +17,30 @@ async function renderHBSTemplate(templatePath, data) { } async function generateVueContent(fieldType, { resource, column }) { - const componentName = `${resource.label}${column.label}${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)}`; - const columnName = column.name; + const hasColumn = !!column; + const componentName = hasColumn + ? `${resource.label}${column.label}${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)}` + : `${resource.label}${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)}`; + const resourceId = resource.resourceId; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - const templatePath = path.join(__dirname, 'templates', 'customFields', `${fieldType}.vue.hbs`); + + const templatePath = hasColumn + ? path.join(__dirname, 'templates', 'customFields', `${fieldType}.vue.hbs`) + : path.join(__dirname, 'templates', 'customCrud', `${fieldType}.vue.hbs`); console.log(chalk.dim(`Using template: ${templatePath}`)); const context = { componentName, - columnName, resourceId, resource, - column + ...(hasColumn && { + column, + columnName: column.name, + }), }; try { @@ -43,6 +52,7 @@ async function generateVueContent(fieldType, { resource, column }) { } } + export async function generateComponentFile(componentFileName, fieldType, context, config) { const customDirRelative = 'custom'; @@ -50,7 +60,10 @@ export async function generateComponentFile(componentFileName, fieldType, contex const projectRoot = process.cwd(); const customDirPath = path.resolve(projectRoot, customDirRelative); const absoluteComponentPath = path.resolve(customDirPath, componentFileName); - + if (fsSync.existsSync(absoluteComponentPath)) { + console.log(chalk.yellow(`⚠️ Component file already exists: ${absoluteComponentPath}`)); + return {"alreadyExists": true, "path": absoluteComponentPath} + } try { await fs.mkdir(customDirPath, { recursive: true }); console.log(chalk.dim(`Ensured custom directory exists: ${customDirPath}`)); @@ -60,7 +73,7 @@ export async function generateComponentFile(componentFileName, fieldType, contex await fs.writeFile(absoluteComponentPath, fileContent, 'utf-8'); console.log(chalk.green(`✅ Generated component file: ${absoluteComponentPath}`)); - return absoluteComponentPath; + return {"alreadyExists": false, "path": absoluteComponentPath} } catch (error) { console.error(chalk.red(`❌ Error creating component file at ${absoluteComponentPath}:`)); @@ -70,3 +83,66 @@ export async function generateComponentFile(componentFileName, fieldType, contex throw error; } } + +export async function generateCrudInjectionComponent(componentFileName, crudType, context, config) { + const customDirRelative = 'custom'; + const projectRoot = process.cwd(); + const customDirPath = path.resolve(projectRoot, customDirRelative); + const absoluteComponentPath = path.resolve(customDirPath, componentFileName); + + if (fsSync.existsSync(absoluteComponentPath)) { + console.log(chalk.yellow(`⚠️ Component file already exists: ${absoluteComponentPath}`)); + return { alreadyExists: true, path: absoluteComponentPath }; + } + + try { + await fs.mkdir(customDirPath, { recursive: true }); + console.log(chalk.dim(`Ensured custom directory exists: ${customDirPath}`)); + + const fileContent = await generateVueContent(crudType, context); + + await fs.writeFile(absoluteComponentPath, fileContent, 'utf-8'); + console.log(chalk.green(`✅ Generated component file: ${absoluteComponentPath}`)); + + return { alreadyExists: false, path: absoluteComponentPath }; + } catch (error) { + console.error(chalk.red(`❌ Error creating component file at ${absoluteComponentPath}:`)); + throw error; + } +} + +export async function generateLoginOrGlobalComponentFile(componentFileName, injectionType, context) { + const customDirRelative = 'custom'; + const projectRoot = process.cwd(); + const customDirPath = path.resolve(projectRoot, customDirRelative); + const absoluteComponentPath = path.resolve(customDirPath, componentFileName); + + if (fsSync.existsSync(absoluteComponentPath)) { + console.log(chalk.yellow(`⚠️ Component file already exists: ${absoluteComponentPath}`)); + return { alreadyExists: true, path: absoluteComponentPath }; + } + + try { + await fs.mkdir(customDirPath, { recursive: true }); + console.log(chalk.dim(`Ensured custom directory exists: ${customDirPath}`)); + + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + let templatePath; + if (injectionType === 'afterLogin') { + templatePath = path.join(__dirname, 'templates', 'login', `${injectionType}.vue.hbs`); + } else { + templatePath = path.join(__dirname, 'templates', 'global', `${injectionType}.vue.hbs`); + } + + const fileContent = await renderHBSTemplate(templatePath, context); + + await fs.writeFile(absoluteComponentPath, fileContent, 'utf-8'); + console.log(chalk.green(`✅ Generated login injection component: ${absoluteComponentPath}`)); + + return { alreadyExists: false, path: absoluteComponentPath }; + } catch (error) { + console.error(chalk.red(`❌ Error creating login component at ${absoluteComponentPath}`)); + throw error; + } +} diff --git a/adminforth/commands/createCustomComponent/main.js b/adminforth/commands/createCustomComponent/main.js index 9927ed52..efb7fa55 100644 --- a/adminforth/commands/createCustomComponent/main.js +++ b/adminforth/commands/createCustomComponent/main.js @@ -1,10 +1,17 @@ -import { select, confirm, Separator } from '@inquirer/prompts'; -import chalk from 'chalk'; -import path from 'path'; // Import path +import { select, Separator, search, input } from '@inquirer/prompts'; +import chalk from 'chalk';// Import path +import path from 'path'; import { loadAdminForthConfig } from './configLoader.js'; // Helper to load config -import { generateComponentFile } from './fileGenerator.js'; // Helper to create the .vue file -import { updateResourceConfig } from './configUpdater.js'; // Helper to modify resource .ts file -// import { openFileInIde } from './ideHelper.js'; // Helper to open file +import { generateComponentFile, generateLoginOrGlobalComponentFile, generateCrudInjectionComponent } from './fileGenerator.js'; // Helper to create the .vue file +import { updateResourceConfig, injectLoginComponent, injectGlobalComponent, updateCrudInjectionConfig } from './configUpdater.js'; // Helper to modify resource .ts file + +function sanitizeLabel(input){ + return input + .replace(/[^a-zA-Z0-9\s]/g, '') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(''); +} export default async function createComponent(args) { console.log('This command will help you to generate boilerplate for component.\n'); @@ -64,13 +71,23 @@ async function handleFieldComponentCreation(config, resources) { const selectedResource = resources.find(r => r.resourceId === resourceId); console.log(chalk.grey(`Selected ❯ 🔤 Custom fields ❯ ${fieldType} ❯ ${selectedResource.label}`)); - const columnName = await select({ - message: 'Select column for which you want to create component:', - choices: [ - ...selectedResource.columns.map(c => ({ name: `${c.label} ${chalk.grey(`${c.name}`)}`, value: c.name })), - new Separator(), - { name: '🔙 BACK', value: '__BACK__' }, - ] + const columnName = await search({ + message: 'Select column for which you want to create component:', + source: async (input) => { + const searchTerm = input ? input.toLowerCase() : ''; + + const filteredColumns = selectedResource.columns.filter(c => { + const label = c.label || ''; + const name = c.name || ''; + return label.toLowerCase().includes(searchTerm) || name.toLowerCase().includes(searchTerm); + }); + + return [ + ...filteredColumns.map(c => ({ name: `${c.label} ${chalk.grey(`${c.name}`)}`, value: c.name })), + new Separator(), + { name: '🔙 BACK', value: '__BACK__' }, + ]; + }, }); if (columnName === '__BACK__') return handleFieldComponentCreation(config, resources); // Pass config back @@ -79,60 +96,226 @@ async function handleFieldComponentCreation(config, resources) { console.log(chalk.dim(`One-line alternative: |adminforth component fields.${fieldType}.${resourceId}.${columnName}|`)); - const existingComponentPath = null; - - if (existingComponentPath) { - const action = await select({ - message: 'You already have a component for this field, open it in editor?', - choices: [ - { name: '✏️ Open in IDE', value: 'open' }, - { name: '🔙 BACK', value: '__BACK__' }, - { name: '🚪 Exit', value: '__EXIT__' }, - ] - }); - if (action === 'open') { - // await openFileInIde(existingComponentPath); // Needs absolute path - console.log(`Opening ${existingComponentPath}... (Implementation needed)`); - } else if (action === '__BACK__') { - return handleFieldComponentCreation(config, resources); // Pass config back - } else { - process.exit(0); + const safeResourceLabel = sanitizeLabel(selectedResource.label) + const safeColumnLabel = sanitizeLabel(selectedColumn.label) + const componentFileName = `${safeResourceLabel}${safeColumnLabel}${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)}.vue`; // e.g., UserEmailShow.vue + const componentPathForConfig = `@@/${componentFileName}`; // Path relative to custom dir for config + + + try { + const { alreadyExists, path: absoluteComponentPath } = await generateComponentFile( + componentFileName, + fieldType, + { resource: selectedResource, column: selectedColumn }, + config + ); + if (!alreadyExists) { + console.log(chalk.dim(`Component generation successful: ${absoluteComponentPath}`)); + + await updateResourceConfig(selectedResource.resourceId, columnName, fieldType, componentPathForConfig); + console.log( + chalk.bold.greenBright('You can now open the component in your IDE:'), + chalk.underline.cyanBright(absoluteComponentPath) + ); + } + process.exit(0); +}catch (error) { + console.error(error); + console.error(chalk.red('\n❌ Component creation failed. Please check the errors above.')); + process.exit(1); +} +} + +async function handleCrudPageInjectionCreation(config, resources) { + console.log(chalk.grey('Selected ❯ 📄 CRUD Page Injection')); + + const crudType = await select({ + message: 'What view do you want to inject a custom component into?', + choices: [ + { name: '🔸 list', value: 'list' }, + { name: '📃 show', value: 'show' }, + { name: '✏️ edit', value: 'edit' }, + { name: '➕ create', value: 'create' }, + new Separator(), + { name: '🔙 BACK', value: '__BACK__' }, + ], + }); + if (crudType === '__BACK__') return createComponent([]); + + console.log(chalk.grey(`Selected ❯ 📄 CRUD Page Injection ❯ ${crudType}`)); + + const resourceId = await select({ + message: 'Select resource for which you want to inject the component:', + choices: [ + ...resources.map(r => ({ name: `${r.label} ${chalk.grey(`${r.resourceId}`)}`, value: r.resourceId })), + new Separator(), + { name: '🔙 BACK', value: '__BACK__' }, + ], + }); + if (resourceId === '__BACK__') return handleCrudPageInjectionCreation(config, resources); + + const selectedResource = resources.find(r => r.resourceId === resourceId); + console.log(chalk.grey(`Selected ❯ 📄 CRUD Page Injection ❯ ${crudType} ❯ ${selectedResource.label}`)); + + const injectionPosition = await select({ + message: 'Where exactly do you want to inject the component?', + choices: [ + { name: '⬆️ Before Breadcrumbs', value: 'beforeBreadcrumbs' }, + { name: '⬇️ After Breadcrumbs', value: 'afterBreadcrumbs' }, + { name: '📄 After Page', value: 'bottom' }, + { name: '⋯ threeDotsDropdownItems', value: 'threeDotsDropdownItems' }, + new Separator(), + { name: '🔙 BACK', value: '__BACK__' }, + ], + }); + if (injectionPosition === '__BACK__') return handleCrudPageInjectionCreation(config, resources); + + const isThin = await select({ + message: 'Will this component be thin enough to fit on the same page with list (so list will still shrink)?', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); + + const safeResourceLabel = sanitizeLabel(selectedResource.label) + const componentFileName = `${safeResourceLabel}${crudType.charAt(0).toUpperCase() + crudType.slice(1)}${injectionPosition.charAt(0).toUpperCase() + injectionPosition.slice(1)}.vue`; + const componentPathForConfig = `@@/${componentFileName}`; + + try { + const { alreadyExists, path: absoluteComponentPath } = await generateCrudInjectionComponent( + componentFileName, + injectionPosition, + { resource: selectedResource }, + config + ); + + if (!alreadyExists) { + console.log(chalk.dim(`Component generation successful: ${absoluteComponentPath}`)); + + await updateCrudInjectionConfig( + selectedResource.resourceId, + crudType, + injectionPosition, + componentPathForConfig, + isThin + ); + console.log( + chalk.bold.greenBright('You can now open the component in your IDE:'), + chalk.underline.cyanBright(absoluteComponentPath) + ); } - } else { - const safeResourceLabel = selectedResource.label.replace(/[^a-zA-Z0-9]/g, ''); - const safeColumnLabel = selectedColumn.label.replace(/[^a-zA-Z0-9]/g, ''); - const componentFileName = `${safeResourceLabel}${safeColumnLabel}${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)}.vue`; // e.g., UserEmailShow.vue - const componentPathForConfig = `@@/${componentFileName}`; // Path relative to custom dir for config - - let absoluteComponentPath; - try { - absoluteComponentPath = await generateComponentFile( - componentFileName, - fieldType, - { resource: selectedResource, column: selectedColumn }, - config - ); - console.log(chalk.dim(`Component generation successful: ${absoluteComponentPath}`)); - - await updateResourceConfig(selectedResource.resourceId, columnName, fieldType, componentPathForConfig); - console.log(chalk.green(`\n✅ Successfully created component ${componentPathForConfig} and updated configuration.`)); - - const openNow = await confirm({ - message: 'Open the new component file in your IDE?', - default: true - }); - if (openNow) { // await openFileInIde(absoluteComponentPath); // Use the absolute path here - console.log(`Opening ${absoluteComponentPath}... (Implementation needed)`); - } - - } catch (error) { - console.error(chalk.red('\n❌ Component creation failed. Please check the errors above.')); - process.exit(1); + process.exit(0); + } catch (error) { + console.error(error); + console.error(chalk.red('\n❌ Component creation failed. Please check the errors above.')); + process.exit(1); + } +} + + +async function handleLoginPageInjectionCreation(config) { + console.log('Selected ❯ 🔐 Login page injections'); + const injectionType = await select({ + message: 'Select injection type:', + choices: [ + { name: 'After Login and password inputs', value: 'afterLogin' }, + { name: '🔙 BACK', value: '__BACK__' }, + ], + }); + if (injectionType === '__BACK__') return createComponent([]); + + console.log(chalk.grey(`Selected ❯ 🔐 Login page injections ❯ ${injectionType}`)); + + const reason = await input({ + message: 'What will you need component for? (enter name)', + }); + + console.log(chalk.grey(`Selected ❯ 🔐 Login page injections ❯ ${injectionType} ❯ ${reason}`)); + + + try { + const safeName = sanitizeLabel(reason) + const componentFileName = `CustomLogin${safeName}.vue`; + + const context = { reason }; + + const { alreadyExists, path: absoluteComponentPath } = await generateLoginOrGlobalComponentFile( + componentFileName, + injectionType, + context + ); + if (!alreadyExists) { + console.log(chalk.dim(`Component generation successful: ${absoluteComponentPath}`)); + const configFilePath = path.resolve(process.cwd(), 'index.ts'); + console.log(chalk.dim(`Injecting component: ${configFilePath}, ${componentFileName}`)); + await injectLoginComponent(configFilePath, `@@/${componentFileName}`); + + console.log( + chalk.bold.greenBright('You can now open the component in your IDE:'), + chalk.underline.cyanBright(absoluteComponentPath) + ); } + process.exit(0); + }catch (error) { + console.error(error); + console.error(chalk.red('\n❌ Component creation failed. Please check the errors above.')); + process.exit(1); } + } -// --- TODO: Implement similar handlers for other component types (pass config) --- -async function handleCrudPageInjectionCreation(config, resources) { console.log('CRUD Page Injection creation not implemented yet.'); } -async function handleLoginPageInjectionCreation(config) { console.log('Login Page Injection creation not implemented yet.'); } -async function handleGlobalInjectionCreation(config) { console.log('Global Injection creation not implemented yet.'); } +async function handleGlobalInjectionCreation(config) { + console.log('Selected ❯ 🌍 Global page injections'); + + const injectionType = await select({ + message: 'Select global injection type:', + choices: [ + { name: 'User Menu', value: 'userMenu' }, + { name: 'Header', value: 'header' }, + { name: 'Sidebar', value: 'sidebar' }, + { name: 'Every Page Bottom', value: 'everyPageBottom' }, + { name: '🔙 BACK', value: '__BACK__' }, + ], + }); + + if (injectionType === '__BACK__') return createComponent([]); + + console.log(chalk.grey(`Selected ❯ 🌍 Global page injections ❯ ${injectionType}`)); + + const reason = await input({ + message: 'What will you need the component for? (enter name)', + }); + + console.log(chalk.grey(`Selected ❯ 🌍 Global page injections ❯ ${injectionType} ❯ ${reason}`)); + + try { + const safeName = sanitizeLabel(reason) + const componentFileName = `CustomGlobal${safeName}.vue`; + + const context = { reason }; + + const { alreadyExists, path: absoluteComponentPath } = await generateLoginOrGlobalComponentFile( + componentFileName, + injectionType, + context + ); + if (!alreadyExists) { + console.log(chalk.dim(`Component generation successful: ${absoluteComponentPath}`)); + + const configFilePath = path.resolve(process.cwd(), 'index.ts'); + + await injectGlobalComponent(configFilePath, injectionType, `@@/${componentFileName}`); + + console.log( + chalk.bold.greenBright('You can now open the component in your IDE:'), + chalk.underline.cyanBright(absoluteComponentPath) + ); + } + process.exit(0); + } catch (error) { + console.error(error); + console.error(chalk.red('\n❌ Component creation failed. Please check the errors above.')); + process.exit(1); + } +} diff --git a/adminforth/commands/createCustomComponent/templates/customCrud/afterBreadcrumbs.vue.hbs b/adminforth/commands/createCustomComponent/templates/customCrud/afterBreadcrumbs.vue.hbs new file mode 100644 index 00000000..ee9a95c8 --- /dev/null +++ b/adminforth/commands/createCustomComponent/templates/customCrud/afterBreadcrumbs.vue.hbs @@ -0,0 +1,30 @@ + + + diff --git a/adminforth/commands/createCustomComponent/templates/customCrud/beforeBreadcrumbs.vue.hbs b/adminforth/commands/createCustomComponent/templates/customCrud/beforeBreadcrumbs.vue.hbs new file mode 100644 index 00000000..ee9a95c8 --- /dev/null +++ b/adminforth/commands/createCustomComponent/templates/customCrud/beforeBreadcrumbs.vue.hbs @@ -0,0 +1,30 @@ + + + diff --git a/adminforth/commands/createCustomComponent/templates/customCrud/bottom.vue.hbs b/adminforth/commands/createCustomComponent/templates/customCrud/bottom.vue.hbs new file mode 100644 index 00000000..4b21f030 --- /dev/null +++ b/adminforth/commands/createCustomComponent/templates/customCrud/bottom.vue.hbs @@ -0,0 +1,61 @@ + + + diff --git a/adminforth/commands/createCustomComponent/templates/customCrud/threeDotsDropdownItems.vue.hbs b/adminforth/commands/createCustomComponent/templates/customCrud/threeDotsDropdownItems.vue.hbs new file mode 100644 index 00000000..0b256dbf --- /dev/null +++ b/adminforth/commands/createCustomComponent/templates/customCrud/threeDotsDropdownItems.vue.hbs @@ -0,0 +1,55 @@ + + + diff --git a/adminforth/commands/createCustomComponent/templates/global/everyPageBottom.vue.hbs b/adminforth/commands/createCustomComponent/templates/global/everyPageBottom.vue.hbs new file mode 100644 index 00000000..efce4cad --- /dev/null +++ b/adminforth/commands/createCustomComponent/templates/global/everyPageBottom.vue.hbs @@ -0,0 +1,11 @@ + + + diff --git a/adminforth/commands/createCustomComponent/templates/global/header.vue.hbs b/adminforth/commands/createCustomComponent/templates/global/header.vue.hbs new file mode 100644 index 00000000..1b4a5028 --- /dev/null +++ b/adminforth/commands/createCustomComponent/templates/global/header.vue.hbs @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/adminforth/commands/createCustomComponent/templates/global/sidebar.vue.hbs b/adminforth/commands/createCustomComponent/templates/global/sidebar.vue.hbs new file mode 100644 index 00000000..2f72ea5c --- /dev/null +++ b/adminforth/commands/createCustomComponent/templates/global/sidebar.vue.hbs @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/adminforth/commands/createCustomComponent/templates/global/userMenu.vue.hbs b/adminforth/commands/createCustomComponent/templates/global/userMenu.vue.hbs new file mode 100644 index 00000000..1b4a5028 --- /dev/null +++ b/adminforth/commands/createCustomComponent/templates/global/userMenu.vue.hbs @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/adminforth/commands/createCustomComponent/templates/login/afterLogin.vue.hbs b/adminforth/commands/createCustomComponent/templates/login/afterLogin.vue.hbs new file mode 100644 index 00000000..27916fcc --- /dev/null +++ b/adminforth/commands/createCustomComponent/templates/login/afterLogin.vue.hbs @@ -0,0 +1,12 @@ + + + + diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 3d032415..b2fc3257 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -7,7 +7,8 @@ import { import { suggestIfTypo } from "../modules/utils.js"; -import { AdminForthFilterOperators, AdminForthSortDirections } from "../types/Common.js"; +import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections } from "../types/Common.js"; +import { randomUUID } from "crypto"; export default class AdminForthBaseConnector implements IAdminForthDataSourceConnectorBase { @@ -121,8 +122,14 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon return { ok: false, error: `Value for operator '${filters.operator}' should be an array, in filter object: ${JSON.stringify(filters) }` }; } if (filters.value.length === 0) { - // nonsense - return { ok: false, error: `Filter has IN operator but empty value: ${JSON.stringify(filters)}` }; + // nonsense, and some databases might not accept IN [] + const colType = resource.dataSourceColumns.find((col) => col.name == (filters as IAdminForthSingleFilter).field)?.type; + if (colType === AdminForthDataTypes.STRING || colType === AdminForthDataTypes.TEXT) { + filters.value = [randomUUID()]; + return { ok: true, error: `` }; + } else { + return { ok: false, error: `Value for operator '${filters.operator}' should not be empty array, in filter object: ${JSON.stringify(filters) }` }; + } } filters.value = filters.value.map((val: any) => this.setFieldValue(fieldObj, val)); } else { diff --git a/adminforth/documentation/blog/2024-10-01-ai-blog/index.md b/adminforth/documentation/blog/2024-10-01-ai-blog/index.md index 98989b3b..6df70c5e 100644 --- a/adminforth/documentation/blog/2024-10-01-ai-blog/index.md +++ b/adminforth/documentation/blog/2024-10-01-ai-blog/index.md @@ -46,7 +46,8 @@ npx adminforth create-app --app-name ai-blog Add modules: ```bash -npm i @adminforth/upload @adminforth/rich-editor @adminforth/text-complete +cd ai-blog +npm i @adminforth/upload @adminforth/rich-editor @adminforth/text-complete @adminforth/chat-gpt slugify http-proxy @adminforth/image-generation-adapter-openai @adminforth/completion-adapter-open-ai-chat-gpt ``` @@ -169,7 +170,7 @@ model Post { //diff-add published Boolean //diff-add - author User? @relation(fields: [authorId], references: [id]) + author adminuser? @relation(fields: [authorId], references: [id]) //diff-add authorId String? //diff-add @@ -211,7 +212,7 @@ Open `index.ts` file in root directory and update it with the following content: ```ts title="./index.ts" import express from 'express'; import AdminForth, { Filters, Sorts } from 'adminforth'; -import userResource from './resources/user.js'; +import userResource from './resources/adminuser.js'; import postResource from './resources/posts.js'; import contentImageResource from './resources/content-image.js'; import httpProxy from 'http-proxy'; @@ -231,7 +232,7 @@ export const admin = new AdminForth({ auth: { usersResourceId: 'adminuser', // resource to get user during login usernameField: 'email', // field where username is stored, should exist in resource - passwordHashField: 'passwordHash', + passwordHashField: 'password_hash', }, customization: { brandName: 'My Admin', @@ -289,7 +290,9 @@ if (import.meta.url === `file://${process.argv[1]}`) { // api to server recent posts app.get('/api/posts', async (req, res) => { - const { offset = 0, limit = 100, slug = null } = req.query; + const offset = parseInt(req.query.offset as string) || 0; + const limit = parseInt(req.query.limit as string) || 100; + const slug = req.query.slug as string | null; const posts = await admin.resource('post').list( [Filters.EQ('published', true), ...(slug ? [Filters.LIKE('slug', slug)] : [])], limit, @@ -331,13 +334,14 @@ if (import.meta.url === `file://${process.argv[1]}`) { if (!await admin.resource('adminuser').get([Filters.EQ('email', 'adminforth@adminforth.dev')])) { await admin.resource('adminuser').create({ email: 'adminforth@adminforth.dev', - passwordHash: await AdminForth.Utils.generatePasswordHash('adminforth'), + role: 'superadmin', + password_hash: await AdminForth.Utils.generatePasswordHash('adminforth'), }); } }); admin.express.listen(port, () => { - console.log(`\n⚡ AdminForth is available at http://localhost:${port}\n`) + console.log(`\n⚡ AdminForth is available at http://localhost:${port}/admin\n`) }); } ``` @@ -377,7 +381,14 @@ export default { type: AdminForthDataTypes.STRING, }, { - name: 'createdAt', + name: 'role', + enum: [ + { value: 'superadmin', label: 'Super Admin' }, + { value: 'user', label: 'User' }, + ] + }, + { + name: 'created_at', type: AdminForthDataTypes.DATETIME, showIn: { edit: false, @@ -403,9 +414,9 @@ export default { AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM, ], }, - { name: 'passwordHash', backendOnly: true, showIn: { all: false } }, + { name: 'password_hash', backendOnly: true, showIn: { all: false } }, { - name: 'publicName', + name: 'public_name', type: AdminForthDataTypes.STRING, }, { name: 'avatar' }, @@ -425,7 +436,7 @@ export default { return { ok: true } }, }, - } + }, plugins: [ new UploadPlugin({ pathColumnName: 'avatar', @@ -462,6 +473,8 @@ import UploadPlugin from '@adminforth/upload'; import RichEditorPlugin from '@adminforth/rich-editor'; import ChatGptPlugin from '@adminforth/chat-gpt'; import slugify from 'slugify'; +import CompletionAdapterOpenAIChatGPT from "@adminforth/completion-adapter-open-ai-chat-gpt"; +import ImageGenerationAdapterOpenAI from '@adminforth/image-generation-adapter-openai'; export default { table: 'post', @@ -561,23 +574,25 @@ export default { { originalFilename, originalExtension }: {originalFilename: string, originalExtension: string } ) => `post-previews/${new Date().getFullYear()}/${randomUUID()}/${originalFilename}.${originalExtension}`, generation: { - provider: 'openai', countToGenerate: 2, - openAiOptions: { - model: 'gpt-4o', - apiKey: process.env.OPENAI_API_KEY, - }, + adapter: new ImageGenerationAdapterOpenAI({ + openAiApiKey: process.env.OPENAI_API_KEY as string, + model: 'gpt-image-1', + }), fieldsForContext: ['title'], + outputSize: '1536x1024' }, }), new RichEditorPlugin({ htmlFieldName: 'content', completion: { - provider: 'openai-chat-gpt', - params: { - apiKey: process.env.OPENAI_API_KEY, + adapter: new CompletionAdapterOpenAIChatGPT({ + openAiApiKey: process.env.OPENAI_API_KEY as string, model: 'gpt-4o', - }, + expert: { + temperature: 0.7 + } + }), expert: { debounceTime: 250, } @@ -618,11 +633,19 @@ export default { { name: 'id', primaryKey: true, + showIn: { + edit: false, + create: false, + }, fillOnCreate: () => randomUUID(), }, { name: 'createdAt', type: AdminForthDataTypes.DATETIME, + showIn: { + edit: false, + create: false, + }, fillOnCreate: () => (new Date()).toISOString(), }, { @@ -673,7 +696,7 @@ Set up your avatar (you can generate it with AI) and public name in user setting ![alt text](aiblogpost.png) -## Step 5: Create Nuxt project +## Step 6: Create Nuxt project Now let's initialize our seo-facing frontend. In the root directory of your admin app (`ai-blog`) and create a new folder `seo` and run: @@ -934,7 +957,7 @@ Open `http://localhost:3500` in your browser and you will see your blog with pos Go to `http://localhost:3500/admin` to add new posts. -## Step 6: Deploy +## Step 7: Deploy We will use Docker 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. @@ -951,7 +974,7 @@ Open `Dockerfile` in root project directory (`ai-blog`) and put in the following FROM node:20-slim EXPOSE 3500 WORKDIR /app -RUN apk add --no-cache supervisor +RUN apt-get update && apt-get install -y supervisor COPY package.json package-lock.json ./ RUN npm ci COPY seo/package.json seo/package-lock.json seo/ @@ -972,6 +995,8 @@ autostart=true autorestart=true stdout_logfile=/dev/stdout stderr_logfile=/dev/stderr +stdout_logfile_maxbytes = 0 +stderr_logfile_maxbytes = 0 [program:seo] command=sh -c "cd seo && node .output/server/index.mjs" @@ -980,6 +1005,8 @@ autostart=true autorestart=true stdout_logfile=/dev/stdout stderr_logfile=/dev/stderr +stdout_logfile_maxbytes = 0 +stderr_logfile_maxbytes = 0 [program:prisma] command=npm run migrate:prod @@ -987,6 +1014,8 @@ directory=/app autostart=true stdout_logfile=/dev/stdout stderr_logfile=/dev/stderr +stdout_logfile_maxbytes = 0 +stderr_logfile_maxbytes = 0 EOF @@ -1011,8 +1040,8 @@ terraform* Build and run your docker container locally: ```bash -sudo docker build -t my-ai-blog . -sudo docker run -p80:3500 -v ./prodDb:/app/db --env-file .env -it --name my-ai-blog -d my-ai-blog +docker build -t my-ai-blog . +docker run -p80:3500 -v ./prodDb:/app/db --env-file .env -it --name my-ai-blog -d my-ai-blog ``` Now you can open `http://localhost` in your browser and see your blog. @@ -1088,7 +1117,7 @@ data "aws_subnet" "default_subnet" { } resource "aws_security_group" "instance_sg" { - name = "my-ai-blog-instance-sg" + name = "my-aiblog-instance-sg" vpc_id = data.aws_vpc.default.id ingress { @@ -1118,14 +1147,14 @@ resource "aws_security_group" "instance_sg" { } resource "aws_key_pair" "deployer" { - key_name = "terraform-deployer-key" + key_name = "terraform-deployer-my-aiblog-key" public_key = file("~/.ssh/id_rsa.pub") # Path to your public SSH key } resource "aws_instance" "docker_instance" { ami = data.aws_ami.amazon_linux.id - instance_type = "t3a.micro" + instance_type = "t3a.small" subnet_id = data.aws_subnet.default_subnet.id vpc_security_group_ids = [aws_security_group.instance_sg.id] key_name = aws_key_pair.deployer.key_name @@ -1164,13 +1193,13 @@ resource "null_resource" "wait_for_user_data" { connection { type = "ssh" - user = "ubuntu" + user = "ec2-user" private_key = file("~/.ssh/id_rsa") host = aws_instance.docker_instance.public_ip } } - depends_on = [aws_instance.app_instance] + depends_on = [aws_instance.docker_instance] } @@ -1252,7 +1281,7 @@ terraform apply -auto-approve > ☝️ To check logs run `ssh -i ~/.ssh/id_rsa ec2-user@$(terraform output instance_public_ip)`, then `sudo docker logs -n100 -f aiblog` -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 `null_resource.build_image` and change `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). +Terraform config will build the Docker image locally and then copy it to the EC2 instance. This approach saves build resources (CPU/RAM) on the EC2 instance, though it increases network traffic (the image might be around 200MB). If you prefer to build the image directly on the EC2 instance, you can slightly adjust the configuration: remove `null_resource.build_image` and modify `null_resource.remote_commands` to perform the build remotely. However, note that building the image on a `t3.small` instance may still consume significant resources and can interfere with running applications. To avoid potential downtime or performance issues, building the image locally remains the recommended approach. ### Add HTTPs and CDN diff --git a/adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ecr-ci/ga-tf-ecr.jpg b/adminforth/documentation/blog/2025-02-19-compose-aws-ec2-ecr-terraform-github-actions/ga-tf-ecr.jpg similarity index 100% rename from adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ecr-ci/ga-tf-ecr.jpg rename to adminforth/documentation/blog/2025-02-19-compose-aws-ec2-ecr-terraform-github-actions/ga-tf-ecr.jpg diff --git a/adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ecr-ci/index.md b/adminforth/documentation/blog/2025-02-19-compose-aws-ec2-ecr-terraform-github-actions/index.md similarity index 98% rename from adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ecr-ci/index.md rename to adminforth/documentation/blog/2025-02-19-compose-aws-ec2-ecr-terraform-github-actions/index.md index 28b3b0ba..e46b60b6 100644 --- a/adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ecr-ci/index.md +++ b/adminforth/documentation/blog/2025-02-19-compose-aws-ec2-ecr-terraform-github-actions/index.md @@ -152,6 +152,15 @@ services: myadmin: image: ${MYADMIN_REPO}:latest + build: + context: ../adminforth-app + tags: + - ${MYADMIN_REPO}:latest + cache_from: + - type=registry,ref=${MYADMIN_REPO}:cache + cache_to: + - type=registry,ref=${MYADMIN_REPO}:cache,mode=max,compression=zstd,image-manifest=true,oci-mediatypes=true + pull_policy: always restart: always env_file: @@ -205,25 +214,12 @@ tfplan .env.secrets.prod ``` -## Step 6 - buildx bake file - -Create file `deploy/docker-bake.hcl`: +## Step 6 - file with secrets for local deploy -```hcl title="deploy/docker-bake.hcl" -variable "MYADMIN_REPO" { - default = "" -} -group "default" { - targets = ["myadmin"] -} +Create file `deploy/.env.secrets.prod` -target "myadmin" { - context = "../myadmin" - tags = ["${MYADMIN_REPO}:latest"] - cache-from = ["type=registry,ref=${MYADMIN_REPO}:cache"] - cache-to = ["type=registry,ref=${MYADMIN_REPO}:cache,mode=max,compression=zstd,image-manifest=true,oci-mediatypes=true"] - push = true -} +```bash +ADMINFORTH_SECRET= ``` @@ -469,7 +465,7 @@ resource "null_resource" "sync_files_and_run" { aws ecr get-login-password --region ${local.aws_region} --profile myaws | docker login --username AWS --password-stdin ${aws_ecr_repository.myadmin_repo.repository_url} echo "Running build" - env $(cat .env.ecr | grep -v "#" | xargs) docker buildx bake --progress=plain --push --allow=fs.read=.. + env $(cat .env.ecr | grep -v "#" | xargs) docker buildx bake --progress=plain --push --allow=fs.read=.. -f compose.yml # if you will change host, pleasee add -o StrictHostKeyChecking=no echo "Copy files to the instance" diff --git a/adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ci-registry/ga-tf-aws.jpg b/adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-github-actions-registry/ga-tf-aws.jpg similarity index 100% rename from adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ci-registry/ga-tf-aws.jpg rename to adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-github-actions-registry/ga-tf-aws.jpg diff --git a/adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ci-registry/index.md b/adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-github-actions-registry/index.md similarity index 97% rename from adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ci-registry/index.md rename to adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-github-actions-registry/index.md index 046adfb9..b82442a5 100644 --- a/adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ci-registry/index.md +++ b/adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-github-actions-registry/index.md @@ -75,8 +75,6 @@ This command already creates a `Dockerfile` and `.dockerignore` for you, so you create folder `deploy` and create file `compose.yml` inside: ```yml title="deploy/compose.yml" - - services: traefik: image: "traefik:v2.5" @@ -91,14 +89,19 @@ services: myadmin: image: localhost:5000/myadmin:latest + build: + context: ../adminforth-app + tags: + - localhost:5000/myadmin:latest + cache_from: + - type=registry,ref=localhost:5000/myadmin:cache + cache_to: + - type=registry,ref=localhost:5000/myadmin:cache,mode=max,compression=zstd,image-manifest=true,oci-mediatypes=true + pull_policy: always restart: always env_file: - .env.secrets.prod - environment: - - NODE_ENV=production - - DATABASE_URL=sqlite://.db.sqlite - - PRISMA_DATABASE_URL=file:.db.sqlite volumes: - myadmin-db:/code/db @@ -148,29 +151,16 @@ tfplan .env.secrets.prod ``` -## Step 6 - buildx bake file - -Create file `deploy/docker-bake.hcl`: - -```hcl title="deploy/docker-bake.hcl" -variable "REGISTRY_BASE" { - default = "appserver.local:5000" -} +## Step 6 - file with secrets for local deploy -group "default" { - targets = ["myadmin"] -} +Create file `deploy/.env.secrets.prod` -target "myadmin" { - context = "../myadmin" - tags = ["${REGISTRY_BASE}/myadmin:latest"] - cache-from = ["type=registry,ref=${REGISTRY_BASE}/myadmin:cache"] - cache-to = ["type=registry,ref=${REGISTRY_BASE}/myadmin:cache,mode=max,compression=zstd"] - push = true -} +```bash +ADMINFORTH_SECRET= ``` + ## Step 7 - main terraform file main.tf First of all install Terraform as described here [terraform installation](https://developer.hashicorp.com/terraform/install#linux). @@ -426,7 +416,7 @@ resource "null_resource" "sync_files_and_run" { echo '{"auths":{"appserver.local:5000":{"auth":"'$(echo -n "ci-user:$(cat ./.keys/registry.pure)" | base64 -w 0)'"}}}' > ~/.docker/config.json echo "Running build" - docker buildx bake --progress=plain --push --allow=fs.read=.. + docker buildx bake --progress=plain --push --allow=fs.read=.. -f compose.yml # compose temporarily it is not working https://github.com/docker/compose/issues/11072#issuecomment-1848974315 # docker compose --progress=plain -p app -f ./compose.yml build --push diff --git a/adminforth/documentation/docs/tutorial/04-deploy.md b/adminforth/documentation/docs/tutorial/04-deploy.md index da6abd63..7f2da57f 100644 --- a/adminforth/documentation/docs/tutorial/04-deploy.md +++ b/adminforth/documentation/docs/tutorial/04-deploy.md @@ -36,10 +36,13 @@ docker run -p 3500:3500 \ Now open your browser and go to `http://localhost:3500` to see your AdminForth application running in Docker container. +## Automating deployments with CI + +If you are looking for a professional way to deploy your AdminForth application, you can follow our blog post [how to deploy your AdminForth application with Terraform From GitHub actions](http://localhost:3000/blog/compose-aws-ec2-ecr-terraform-github-actions/) + ## Adding SSL (https) to AdminForth -There are lots of ways today to put your application behind SSL gateway. You might simply put AdminForth instance behind free Cloudflare CDN, -change 3500 port to 80 and Cloudflare will automatically add SSL layer and faster CDN for your application. +There are lots of ways today to put your application behind SSL gateway. You might simply put AdminForth instance behind free Cloudflare CDN, change 3500 port to 80 and Cloudflare will automatically add SSL layer and faster CDN for your application. However as a bonus here we will give you independent way to add free LetsEncrypt SSL layer to your AdminForth application. diff --git a/adminforth/documentation/docs/tutorial/05-Plugins/05-upload.md b/adminforth/documentation/docs/tutorial/05-Plugins/05-upload.md index 0172b273..1742c851 100644 --- a/adminforth/documentation/docs/tutorial/05-Plugins/05-upload.md +++ b/adminforth/documentation/docs/tutorial/05-Plugins/05-upload.md @@ -280,6 +280,8 @@ new UploadPlugin({ }), //diff-add fieldsForContext: ['title'], +//diff-add + outputSize: '1536x1024' // size of generated image }, ``` @@ -288,23 +290,26 @@ Here is how it works: ![alt text](demoImgGen-1.gif) -You can also pass additional parameters to OpenAI API call +You can also pass additional parameters to [OpenAI API call](https://platform.openai.com/docs/api-reference/images/createEdit) by using `extraParams` property: ```ts title="./apartments.ts" new ImageGenerationAdapterOpenAI({ //diff-add - openAiApiKey: process.env.OPENAI_API_KEY as string, + openAiApiKey: process.env.OPENAI_API_KEY as string, //diff-add - model: 'gpt-image-1', + model: 'gpt-image-1', //diff-add - extraParams: { + extraParams: { //diff-add - moderation: 'low', + moderation: 'low', //diff-add - quality: 'high', + quality: 'high', //diff-add - }), + }, + //diff-add + outputSize: '1536x1024' // size of generated image +}), ``` @@ -330,6 +335,7 @@ generation: { }, generationPrompt: "Remove text from the image", countToGenerate: 3, + outputSize: '1024x1024', } ``` diff --git a/adminforth/spa/package-lock.json b/adminforth/spa/package-lock.json index 207416b6..178e3bb9 100644 --- a/adminforth/spa/package-lock.json +++ b/adminforth/spa/package-lock.json @@ -14,7 +14,6 @@ "apexcharts": "^4.4.0", "dayjs": "^1.11.11", "debounce": "^2.1.0", - "flowbite": "^2.3.0", "flowbite-datepicker": "^1.2.6", "javascript-time-ago": "^2.5.11", "pinia": "^2.1.7", @@ -38,12 +37,13 @@ "eslint": "^8.57.0", "eslint-plugin-vue": "^9.23.0", "flag-icons": "^7.2.3", + "flowbite": "^3.1.2", "i18n-iso-countries": "^7.12.0", "npm-run-all2": "^6.1.2", "portfinder": "^1.0.32", "postcss": "^8.4.38", "sass": "^1.77.2", - "tailwindcss": "^3.4.3", + "tailwindcss": "^3.4.17", "typescript": "~5.4.0", "vite": "^5.2.13", "vue-i18n-extract": "^2.0.7", @@ -811,6 +811,64 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.17.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz", @@ -1090,8 +1148,7 @@ "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, "node_modules/@types/node": { "version": "20.12.12", @@ -1102,6 +1159,12 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", @@ -1834,12 +1897,13 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -2591,10 +2655,11 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2646,22 +2711,39 @@ "dev": true }, "node_modules/flowbite": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-2.3.0.tgz", - "integrity": "sha512-pm3JRo8OIJHGfFYWgaGpPv8E+UdWy0Z3gEAGufw+G/1dusaU/P1zoBLiQpf2/+bYAi+GBQtPVG86KYlV0W+AFQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-3.1.2.tgz", + "integrity": "sha512-MkwSgbbybCYgMC+go6Da5idEKUFfMqc/AmSjm/2ZbdmvoKf5frLPq/eIhXc9P+rC8t9boZtUXzHDgt5whZ6A/Q==", + "dev": true, + "license": "MIT", "dependencies": { "@popperjs/core": "^2.9.3", - "mini-svg-data-uri": "^1.4.3" + "flowbite-datepicker": "^1.3.1", + "mini-svg-data-uri": "^1.4.3", + "postcss": "^8.5.1" } }, "node_modules/flowbite-datepicker": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.2.6.tgz", - "integrity": "sha512-UbU/xXs9HFiwWfL4M1vpwIo8EpS0NUQSOvYnp0Z9u3N118nU7lPFGoUOq7su9d0aOJy9FssXzx1SZwN8MXhE1g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.3.2.tgz", + "integrity": "sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==", + "license": "MIT", "dependencies": { + "@rollup/plugin-node-resolve": "^15.2.3", "flowbite": "^2.0.0" } }, + "node_modules/flowbite-datepicker/node_modules/flowbite": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-2.5.2.tgz", + "integrity": "sha512-kwFD3n8/YW4EG8GlY3Od9IoKND97kitO+/ejISHSqpn3vw2i5K/+ZI8Jm2V+KC4fGdnfi0XZ+TzYqQb4Q1LshA==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.3", + "flowbite-datepicker": "^1.3.0", + "mini-svg-data-uri": "^1.4.3" + } + }, "node_modules/foreground-child": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", @@ -2715,7 +2797,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2838,7 +2919,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -2971,7 +3051,6 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, "dependencies": { "hasown": "^2.0.0" }, @@ -3009,11 +3088,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -3079,10 +3165,11 @@ } }, "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, + "license": "MIT", "bin": { "jiti": "bin/jiti.js" } @@ -3149,12 +3236,16 @@ } }, "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, "node_modules/lines-and-columns": { @@ -3227,12 +3318,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -3318,15 +3410,16 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3549,8 +3642,7 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { "version": "1.11.1", @@ -3701,9 +3793,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "funding": [ { "type": "opencollective", @@ -3720,8 +3812,8 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -3799,42 +3891,38 @@ } } }, - "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.11" + "postcss-selector-parser": "^6.1.1" }, "engines": { "node": ">=12.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, "peerDependencies": { "postcss": "^8.2.14" } }, "node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, + "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3931,7 +4019,6 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -3982,7 +4069,7 @@ "version": "4.17.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz", "integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==", - "dev": true, + "devOptional": true, "dependencies": { "@types/estree": "1.0.5" }, @@ -4307,7 +4394,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -4316,33 +4402,34 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz", - "integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "dev": true, + "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", - "chokidar": "^3.5.3", + "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.3.0", + "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.0", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", @@ -4391,6 +4478,7 @@ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, diff --git a/adminforth/spa/package.json b/adminforth/spa/package.json index cf2df1c4..78540e85 100644 --- a/adminforth/spa/package.json +++ b/adminforth/spa/package.json @@ -19,7 +19,6 @@ "apexcharts": "^4.4.0", "dayjs": "^1.11.11", "debounce": "^2.1.0", - "flowbite": "^2.3.0", "flowbite-datepicker": "^1.2.6", "javascript-time-ago": "^2.5.11", "pinia": "^2.1.7", @@ -43,12 +42,13 @@ "eslint": "^8.57.0", "eslint-plugin-vue": "^9.23.0", "flag-icons": "^7.2.3", + "flowbite": "^3.1.2", "i18n-iso-countries": "^7.12.0", "npm-run-all2": "^6.1.2", "portfinder": "^1.0.32", "postcss": "^8.4.38", "sass": "^1.77.2", - "tailwindcss": "^3.4.3", + "tailwindcss": "^3.4.17", "typescript": "~5.4.0", "vite": "^5.2.13", "vue-i18n-extract": "^2.0.7", diff --git a/adminforth/spa/src/afcl/ProgressBar.vue b/adminforth/spa/src/afcl/ProgressBar.vue index 72a79abe..9e7526de 100644 --- a/adminforth/spa/src/afcl/ProgressBar.vue +++ b/adminforth/spa/src/afcl/ProgressBar.vue @@ -24,8 +24,8 @@ interface Props { formatter?: (value: number) => string progressFormatter?: (value: number, percentage: number) => string showLabels?: boolean - showValues: boolean - showProgress: boolean + showValues?: boolean + showProgress?: boolean } const props = withDefaults(defineProps(), { diff --git a/adminforth/spa/src/utils.ts b/adminforth/spa/src/utils.ts index 27f37655..d3784795 100644 --- a/adminforth/spa/src/utils.ts +++ b/adminforth/spa/src/utils.ts @@ -30,9 +30,9 @@ export async function callApi({path, method, body=undefined}: { return null; } return await r.json(); - } catch(e){ + } catch(e) { adminforth.alert({variant:'danger', message: window.i18n?.global?.t('Something went wrong, please try again later'),}) - console.error(`error in callApi ${path}`,e); + console.error(`error in callApi ${path}`, e); } } diff --git a/adminforth/types/Adapters.ts b/adminforth/types/Adapters.ts index 7ead0b1c..f502af1c 100644 --- a/adminforth/types/Adapters.ts +++ b/adminforth/types/Adapters.ts @@ -1,6 +1,19 @@ export interface EmailAdapter { + + /** + * This method is called to validate the configuration of the adapter + * and should throw a clear user-readbale error if the configuration is invalid. + */ validate(): Promise; + /** + * This method should send an email using the adapter + * @param from - The sender's email address + * @param to - The recipient's email address + * @param text - The plain text version of the email + * @param html - The HTML version of the email + * @param subject - The subject of the email + */ sendEmail( from: string, to: string, @@ -15,8 +28,19 @@ export interface EmailAdapter { export interface CompletionAdapter { + /** + * This method is called to validate the configuration of the adapter + * and should throw a clear user-readbale error if the configuration is invalid. + */ validate(): void; + /** + * This method should return a text completion based on the provided content and stop sequence. + * @param content - The input text to complete + * @param stop - An array of stop sequences to indicate where to stop the completion + * @param maxTokens - The maximum number of tokens to generate + * @returns A promise that resolves to an object containing the completed text and other metadata + */ complete( content: string, stop: string[], @@ -30,10 +54,14 @@ export interface CompletionAdapter { export interface ImageGenerationAdapter { + /** + * This method is called to validate the configuration of the adapter + * and should throw a clear user-readbale error if the configuration is invalid. + */ validate(): void; /** - * Return 1 or 10, or Infinity if the adapter supports multiple images + * Return max number of images which model can generate in one request */ outputImagesMaxCountSupported(): number; @@ -47,6 +75,14 @@ export interface ImageGenerationAdapter { */ inputFileExtensionSupported(): string[]; + /** + * This method should generate an image based on the provided prompt and input files. + * @param prompt - The prompt to generate the image + * @param inputFiles - An array of input file paths (optional) + * @param n - The number of images to generate (default is 1) + * @param size - The size of the generated image (default is the lowest dimension supported) + * @returns A promise that resolves to an object containing the generated image URLs and any error message + */ generate({ prompt, inputFiles, @@ -68,11 +104,91 @@ export interface ImageGenerationAdapter { } - +/** + * This interface is used to implement OAuth2 authentication adapters. + */ export interface OAuth2Adapter { + /** + * This method should return navigatable URL to the OAuth2 provider authentication page. + */ getAuthUrl(): string; + + /** + * This method should return the token from the OAuth2 provider using the provided code and redirect URI. + * @param code - The authorization code received from the OAuth2 provider + * @param redirect_uri - The redirect URI used in the authentication request + * @returns A promise that resolves to an object containing the email address of the authenticated user + */ getTokenFromCode(code: string, redirect_uri: string): Promise<{ email: string }>; + + /** + * This method should return text (content) of SVG icon which will be used in the UI. + * Use official SVG icons with simplest possible conent, omit icons which have base64 encoded raster images inside. + */ getIcon(): string; + + /** + * This method should return the text to be displayed on the button in the UI + */ getButtonText?(): string; + + /** + * This method should return the name of the adapter + */ getName?(): string; } + + +export interface StorageAdapter { + /** + * This method should return the presigned URL for the given key capable of upload (adapter user will call PUT multipart form data to this URL within expiresIn seconds after link generation). + * By default file which will be uploaded on PUT should be marked for deletion. So if during 24h it is not marked for not deletion, it adapter should delete it forever. + * The PUT method should fail if the file already exists. + * + * Adapter user will always pass next parameters to the method: + * @param key - The key of the file to be uploaded e.g. "uploads/file.txt" + * @param expiresIn - The expiration time in seconds for the presigned URL + * @param contentType - The content type of the file to be uploaded, e.g. "image/png" + * + * @returns A promise that resolves to an object containing the upload URL and any extra parameters which should be sent with PUT multipart form data + */ + getUploadSignedUrl(key: string, contentType: string, expiresIn?: number): Promise<{ + uploadUrl: string; + uploadExtraParams?: Record; + }>; + + /** + * This method should return the URL for the given key capable of download (200 GET request with response body or 200 HEAD request without response body). + * If adapter configured to use public storage, this method should return the public URL of the file. + * If adapter configured to use private storage, this method should return the presigned URL for the file. + * + * @param key - The key of the file to be downloaded e.g. "uploads/file.txt" + * @param expiresIn - The expiration time in seconds for the presigned URL + */ + getDownloadUrl(key: string, expiresIn?: number): Promise; + + /** + * This method should mark the file for deletion. + * If file is marked for delation and exists more then 24h (since creation date) it should be deleted. + * This method should work even if the file does not exist yet (e.g. only presigned URL was generated). + * @param key - The key of the file to be uploaded e.g. "uploads/file.txt" + */ + markKeyForDeletation(key: string): Promise; + + + /** + * This method should mark the file to not be deleted. + * This method should be used to cancel the deletion of the file if it was marked for deletion. + * @param key - The key of the file to be uploaded e.g. "uploads/file.txt" + */ + markKeyForNotDeletation(key: string): Promise; + + + /** + * This method can start needed schedullers, cron jobs, etc. to clean up the storage. + */ + setupLifecycle(): Promise; + +} + + \ No newline at end of file diff --git a/dev-demo/resources/apartments.ts b/dev-demo/resources/apartments.ts index 24749035..08283634 100644 --- a/dev-demo/resources/apartments.ts +++ b/dev-demo/resources/apartments.ts @@ -302,7 +302,8 @@ export default { // return [`https://tmpbucket-adminforth.s3.eu-central-1.amazonaws.com/${record.apartment_source}`]; // }, generationPrompt: "Add a 10 kittyies to the appartment look, it should be foto-realistic, they should be different colors, sitting all around the appartment", - countToGenerate: 3, + countToGenerate: 1, + outputSize: '1024x1024', // rateLimit: { // limit: "2/1m", // errorMessage: