diff --git a/package-lock.json b/package-lock.json index 57498fac..8a01b96b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "globby": "^16.1.0", "jsonc-parser": "^3.3.1", "ky": "^1.14.2", + "lodash.kebabcase": "^4.1.1", "p-wait-for": "^6.0.0", "zod": "^4.3.5" }, @@ -26,6 +27,7 @@ "devDependencies": { "@stylistic/eslint-plugin": "^5.6.1", "@types/ejs": "^3.1.5", + "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.10.5", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", @@ -1656,6 +1658,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash.kebabcase": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/lodash.kebabcase/-/lodash.kebabcase-4.1.9.tgz", + "integrity": "sha512-kPrrmcVOhSsjAVRovN0lRfrbuidfg0wYsrQa5IYuoQO1fpHHGSme66oyiYA/5eQPVl8Z95OA3HG0+d2SvYC85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", @@ -4575,6 +4594,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/package.json b/package.json index a57fe2ec..9b0339b0 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,14 @@ "globby": "^16.1.0", "jsonc-parser": "^3.3.1", "ky": "^1.14.2", + "lodash.kebabcase": "^4.1.1", "p-wait-for": "^6.0.0", "zod": "^4.3.5" }, "devDependencies": { "@stylistic/eslint-plugin": "^5.6.1", "@types/ejs": "^3.1.5", + "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.10.5", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", diff --git a/src/cli/commands/project/create.ts b/src/cli/commands/project/create.ts new file mode 100644 index 00000000..73c00f65 --- /dev/null +++ b/src/cli/commands/project/create.ts @@ -0,0 +1,96 @@ +import { resolve } from "node:path"; +import { Command } from "commander"; +import { log, group, text, select } from "@clack/prompts"; +import type { Option } from "@clack/prompts"; +import chalk from "chalk"; +import kebabCase from "lodash.kebabcase"; +import { loadProjectEnv } from "@core/config.js"; +import { createProjectFiles, listTemplates } from "@core/project/index.js"; +import type { Template } from "@core/project/index.js"; +import { runTask, printBanner, onPromptCancel } from "../../utils/index.js"; + +async function create(): Promise { + printBanner(); + + // Load .env.local from project root (if in a project) + await loadProjectEnv(); + + const templates = await listTemplates(); + const templateOptions: Array> = templates.map((t) => ({ + value: t, + label: t.name, + hint: t.description, + })); + + const { template, name, description, projectPath } = await group( + { + template: () => + select({ + message: "Select a project template", + options: templateOptions, + }), + name: () => + text({ + message: "What is the name of your project?", + placeholder: "my-app-backend", + validate: (value) => { + if (!value || value.trim().length === 0) { + return "Project name is required"; + } + }, + }), + description: () => + text({ + message: "Project description (optional)", + placeholder: "A brief description of your project", + }), + projectPath: async ({ results }) => { + const suggestedPath = `./${kebabCase(results.name)}`; + return text({ + message: "Where should we create the base44 folder?", + placeholder: suggestedPath, + initialValue: suggestedPath, + }); + }, + }, + { + onCancel: onPromptCancel, + } + ); + + const resolvedPath = resolve(projectPath as string); + + // Create the project + await runTask( + "Creating project...", + async () => { + return await createProjectFiles({ + name: name.trim(), + description: description ? description.trim() : undefined, + path: resolvedPath, + template, + }); + }, + { + successMessage: "Project created successfully", + errorMessage: "Failed to create project", + } + ); + + log.success(`Project ${chalk.bold(name)} has been initialized!`); +} + +export const createCommand = new Command("create") + .description("Create a new Base44 project") + .action(async () => { + try { + await create(); + } catch (e) { + if (e instanceof Error) { + log.error(e.stack ?? e.message); + } else { + log.error(String(e)); + } + process.exit(1); + } + }); diff --git a/src/cli/commands/project/init.ts b/src/cli/commands/project/init.ts deleted file mode 100644 index 9e63b357..00000000 --- a/src/cli/commands/project/init.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { resolve } from "node:path"; -import { Command } from "commander"; -import { log } from "@clack/prompts"; -import chalk from "chalk"; -import { loadProjectEnv } from "@core/config.js"; -import { initProject } from "@core/project/index.js"; -import { runTask, textPrompt, printBanner } from "../../utils/index.js"; - -async function init(): Promise { - printBanner(); - - // Load .env.local from project root (if in a project) - await loadProjectEnv(); - - const name = await textPrompt({ - message: "What is the name of your project?", - placeholder: "my-app-backend", - validate: (value) => { - if (!value || value.trim().length === 0) { - return "Project name is required"; - } - }, - }); - - // Ask for description (optional) - const description: string | undefined = await textPrompt({ - message: "Project description (optional)", - placeholder: "A brief description of your project", - }); - - // Ask for project path with default - const defaultPath = "./"; - const projectPath = await textPrompt({ - message: "Where should we create the base44 folder?", - placeholder: defaultPath, - initialValue: defaultPath, - }); - - const resolvedPath = resolve(projectPath || defaultPath); - - // Create the project - await runTask( - "Creating project...", - async () => { - return await initProject({ - name: name.trim(), - description: description ? description.trim() : undefined, - path: resolvedPath, - }); - }, - { - successMessage: "Project created successfully", - errorMessage: "Failed to create project", - } - ); - - // Display success message with details - log.success(`Project ${chalk.bold(name)} has been initialized!`); -} - -export const initCommand = new Command("init") - .alias("create") - .description("Initialize a new Base44 project") - .action(async () => { - try { - await init(); - } catch (e) { - if (e instanceof Error) { - log.error(e.stack ?? e.message); - } else { - log.error(String(e)); - } - process.exit(1); - } - }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 1c8e985d..20d618ad 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,7 +6,7 @@ import { whoamiCommand } from "./commands/auth/whoami.js"; import { logoutCommand } from "./commands/auth/logout.js"; import { showProjectCommand } from "./commands/project/show-project.js"; import { entitiesPushCommand } from "./commands/entities/push.js"; -import { initCommand } from "./commands/project/init.js"; +import { createCommand } from "./commands/project/create.js"; import packageJson from "../../package.json"; const program = new Command(); @@ -24,7 +24,7 @@ program.addCommand(whoamiCommand); program.addCommand(logoutCommand); // Register project commands -program.addCommand(initCommand); +program.addCommand(createCommand); program.addCommand(showProjectCommand); // Register entities commands diff --git a/src/cli/utils/prompts.ts b/src/cli/utils/prompts.ts index f21ec815..5179fa1a 100644 --- a/src/cli/utils/prompts.ts +++ b/src/cli/utils/prompts.ts @@ -1,22 +1,10 @@ -import { text, isCancel, cancel } from "@clack/prompts"; -import type { TextOptions } from "@clack/prompts"; +import { cancel } from "@clack/prompts"; /** - * Handles prompt cancellation by exiting gracefully. + * Standard onCancel handler for prompt groups. + * Exits the process gracefully when the user cancels. */ -function handleCancel(value: T | symbol): asserts value is T { - if (isCancel(value)) { - cancel("Operation cancelled."); - process.exit(0); - } -} - -/** - * Wrapper around @clack/prompts text() that handles cancellation automatically. - * Returns the string value directly, exits process if cancelled. - */ -export async function textPrompt(options: TextOptions): Promise { - const value = await text(options); - handleCancel(value); - return value; -} +export const onPromptCancel = () => { + cancel("Operation cancelled."); + process.exit(0); +}; diff --git a/src/core/config.ts b/src/core/config.ts index c384b904..f14dfc0a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,8 +1,13 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; import { config } from "dotenv"; import { findProjectRoot } from "./project/index.js"; +// After bundling, import.meta.url points to dist/cli/index.js +// Templates are copied to dist/cli/templates/ +const __dirname = dirname(fileURLToPath(import.meta.url)); + // Static constants export const PROJECT_SUBDIR = "base44"; export const FUNCTION_CONFIG_FILE = "function.jsonc"; @@ -17,6 +22,10 @@ export function getAuthFilePath() { return join(getBase44Dir(), "auth", "auth.json"); } +export function getTemplatesDir() { + return join(__dirname, "templates"); +} + export function getProjectConfigPatterns() { return [ `${PROJECT_SUBDIR}/config.jsonc`, diff --git a/src/core/project/create.ts b/src/core/project/create.ts new file mode 100644 index 00000000..2d5a1341 --- /dev/null +++ b/src/core/project/create.ts @@ -0,0 +1,48 @@ +import { globby } from "globby"; +import { getProjectConfigPatterns } from "../config.js"; +import { createProject } from "./api.js"; +import { renderTemplate } from "./template.js"; +import type { Template } from "./schema.js"; + +export interface CreateProjectOptions { + name: string; + description?: string; + path: string; + template: Template; +} + +export interface CreateProjectResult { + projectDir: string; +} + +export async function createProjectFiles( + options: CreateProjectOptions +): Promise { + const { name, description, path: basePath, template } = options; + + // Check if project already exists + const existingConfigs = await globby(getProjectConfigPatterns(), { + cwd: basePath, + absolute: true, + }); + + if (existingConfigs.length > 0) { + throw new Error( + `A Base44 project already exists at ${existingConfigs[0]}. Please choose a different location.` + ); + } + + // Create the project via API to get the app ID + const { projectId } = await createProject(name, description); + + // Render the template to the destination path + await renderTemplate(template, basePath, { + name, + description, + projectId, + }); + + return { + projectDir: basePath, + }; +} diff --git a/src/core/project/index.ts b/src/core/project/index.ts index 92d3bcf4..32fc5404 100644 --- a/src/core/project/index.ts +++ b/src/core/project/index.ts @@ -2,4 +2,5 @@ export type * from "./baseResource.js"; export * from "./config.js"; export * from "./schema.js"; export * from "./api.js"; -export * from "./init.js"; +export * from "./create.js"; +export * from "./template.js"; diff --git a/src/core/project/init.ts b/src/core/project/init.ts deleted file mode 100644 index 0a887354..00000000 --- a/src/core/project/init.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { join } from "node:path"; -import { globby } from "globby"; -import { getProjectConfigPatterns, PROJECT_SUBDIR } from "../config.js"; -import { writeFile } from "../utils/fs.js"; -import { createProject } from "./api.js"; -import { renderConfigTemplate, renderEnvTemplate } from "./templates/index.js"; - -export interface InitProjectOptions { - name: string; - description?: string; - path: string; -} - -export interface InitProjectResult { - projectId: string; - projectDir: string; - configPath: string; - envPath: string; -} - -/** - * Initialize a new Base44 project. - * Creates the base44 directory, config.jsonc, and .env.local files. - */ -export async function initProject( - options: InitProjectOptions -): Promise { - const { name, description, path: basePath } = options; - - const projectDir = join(basePath, PROJECT_SUBDIR); - const configPath = join(projectDir, "config.jsonc"); - const envPath = join(projectDir, ".env.local"); - - // Check if project already exists - const existingConfigs = await globby(getProjectConfigPatterns(), { - cwd: basePath, - absolute: true, - }); - - if (existingConfigs.length > 0) { - throw new Error( - `A Base44 project already exists at ${existingConfigs[0]}. Please choose a different location.` - ); - } - - // Create the project via API to get the app ID - const { projectId } = await createProject(name, description); - - // Create config.jsonc from template - const configContent = await renderConfigTemplate({ name, description }); - await writeFile(configPath, configContent); - - // Create .env.local from template - const envContent = await renderEnvTemplate({ projectId }); - await writeFile(envPath, envContent); - - return { - projectId, - projectDir, - configPath, - envPath, - }; -} diff --git a/src/core/project/schema.ts b/src/core/project/schema.ts index 02d6bac3..c146b97c 100644 --- a/src/core/project/schema.ts +++ b/src/core/project/schema.ts @@ -1,5 +1,21 @@ import { z } from "zod"; +// Template schemas +export const TemplateSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + path: z.string(), +}); + +export const TemplatesConfigSchema = z.object({ + templates: z.array(TemplateSchema), +}); + +export type Template = z.infer; +export type TemplatesConfig = z.infer; + +// App config schemas const SiteConfigSchema = z.object({ buildCommand: z.string().optional(), serveCommand: z.string().optional(), diff --git a/src/core/project/template.ts b/src/core/project/template.ts new file mode 100644 index 00000000..6bc60f0f --- /dev/null +++ b/src/core/project/template.ts @@ -0,0 +1,66 @@ +import { join, isAbsolute } from "node:path"; +import { globby } from "globby"; +import ejs from "ejs"; +import { getTemplatesDir } from "../config.js"; +import { readJsonFile, writeFile, copyFile } from "../utils/fs.js"; +import { TemplatesConfigSchema } from "./schema.js"; +import type { Template } from "./schema.js"; + +export interface TemplateData { + name: string; + description?: string; + projectId: string; +} + +export async function listTemplates(): Promise { + const configPath = join(getTemplatesDir(), "templates.json"); + const parsed = await readJsonFile(configPath); + const result = TemplatesConfigSchema.parse(parsed); + return result.templates; +} + +/** + * Render a template directory to a destination path. + * - Files ending in .ejs are rendered with EJS and written without the .ejs extension + * - All other files are copied directly + */ +export async function renderTemplate( + template: Template, + destPath: string, + data: TemplateData +): Promise { + // Validate template path to prevent directory traversal + if (template.path.includes("..") || isAbsolute(template.path)) { + throw new Error(`Invalid template path: ${template.path}`); + } + + const templateDir = join(getTemplatesDir(), template.path); + + // Get all files in the template directory + const files = await globby("**/*", { + cwd: templateDir, + dot: true, + onlyFiles: true, + }); + + for (const file of files) { + const srcPath = join(templateDir, file); + + try { + if (file.endsWith(".ejs")) { + // Render EJS template and write without .ejs extension + const destFile = file.replace(/\.ejs$/, ""); + const destFilePath = join(destPath, destFile); + const rendered = await ejs.renderFile(srcPath, data); + await writeFile(destFilePath, rendered); + } else { + // Copy file directly + const destFilePath = join(destPath, file); + await copyFile(srcPath, destFilePath); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to process template file "${file}": ${message}`); + } + } +} diff --git a/src/core/project/templates/index.ts b/src/core/project/templates/index.ts deleted file mode 100644 index 4a22e8c8..00000000 --- a/src/core/project/templates/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import ejs from "ejs"; - -// After bundling, import.meta.url points to dist/cli/index.js -// Templates are copied to dist/cli/templates/ -const __dirname = dirname(fileURLToPath(import.meta.url)); -const TEMPLATES_DIR = join(__dirname, "templates"); - -const CONFIG_TEMPLATE_PATH = join(TEMPLATES_DIR, "config.jsonc.ejs"); -const ENV_TEMPLATE_PATH = join(TEMPLATES_DIR, "env.local.ejs"); - -interface ConfigTemplateData { - name: string; - description?: string; -} - -interface EnvTemplateData { - projectId: string; -} - -export async function renderConfigTemplate( - data: ConfigTemplateData -): Promise { - return ejs.renderFile(CONFIG_TEMPLATE_PATH, data); -} - -export async function renderEnvTemplate(data: EnvTemplateData): Promise { - return ejs.renderFile(ENV_TEMPLATE_PATH, data); -} diff --git a/src/core/utils/fs.ts b/src/core/utils/fs.ts index 2a9f837e..7d4c51b8 100644 --- a/src/core/utils/fs.ts +++ b/src/core/utils/fs.ts @@ -1,6 +1,7 @@ import { readFile as fsReadFile, writeFile as fsWriteFile, + copyFile as fsCopyFile, mkdir, unlink, access, @@ -29,6 +30,14 @@ export async function writeFile( await fsWriteFile(filePath, content, "utf-8"); } +export async function copyFile(src: string, dest: string): Promise { + const dir = dirname(dest); + if (!(await pathExists(dir))) { + await mkdir(dir, { recursive: true }); + } + await fsCopyFile(src, dest); +} + export async function readJsonFile(filePath: string): Promise { if (!(await pathExists(filePath))) { throw new Error(`File not found: ${filePath}`); diff --git a/templates/backend-and-client/.gitignore b/templates/backend-and-client/.gitignore new file mode 100644 index 00000000..93ff455d --- /dev/null +++ b/templates/backend-and-client/.gitignore @@ -0,0 +1,16 @@ +# Dependencies +node_modules + +# Build +dist + +# Environment +.env +.env.* +*.local + +# Editor +.vscode +.idea +.DS_Store +*.swp diff --git a/templates/backend-and-client/README.md b/templates/backend-and-client/README.md new file mode 100644 index 00000000..0fa84faa --- /dev/null +++ b/templates/backend-and-client/README.md @@ -0,0 +1,41 @@ +# Todo App + +A simple todo list app built with React and Base44 backend. + +## Structure + +``` +base44/ # Backend configuration +├── config.jsonc # Project settings +└── entities/ # Data schemas + └── task.jsonc # Task entity + +src/ # Frontend code +├── App.jsx # Main todo app +├── api/ # Base44 client +├── components/ui/ # UI components +└── lib/ # Utilities +``` + +## Development + +```bash +npm install +npm run dev +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `npm run dev` | Start dev server | +| `npm run build` | Build for production | +| `npm run preview` | Preview production build | + +## Base44 CLI + +```bash +base44 login # Authenticate +base44 entities push # Push entity schemas +base44 deploy # Deploy backend + hosting +``` diff --git a/templates/backend-and-client/base44/config.jsonc.ejs b/templates/backend-and-client/base44/config.jsonc.ejs new file mode 100644 index 00000000..fed116bb --- /dev/null +++ b/templates/backend-and-client/base44/config.jsonc.ejs @@ -0,0 +1,17 @@ +// Base44 Project Configuration +// JSONC enables inline documentation and discoverability directly in config files. +// Full-stack template with backend and client configuration. + +{ + "name": "<%= name %>"<% if (description) { %>, + "description": "<%= description %>"<% } %>, + + // Site/hosting configuration for the client application + // Docs: https://docs.base44.com/configuration/hosting + "site": { + "buildCommand": "npm run build", + "serveCommand": "npm run dev", + "outputDirectory": "./dist", + "installCommand": "npm ci" + } +} diff --git a/templates/backend-and-client/base44/entities/task.jsonc b/templates/backend-and-client/base44/entities/task.jsonc new file mode 100644 index 00000000..82b82567 --- /dev/null +++ b/templates/backend-and-client/base44/entities/task.jsonc @@ -0,0 +1,16 @@ +{ + "name": "Task", + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Task title" + }, + "completed": { + "type": "boolean", + "default": false, + "description": "Whether the task is completed" + } + }, + "required": ["title"] +} diff --git a/templates/backend-and-client/components.json b/templates/backend-and-client/components.json new file mode 100644 index 00000000..068bddc0 --- /dev/null +++ b/templates/backend-and-client/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "ui": "@/components/ui" + } +} diff --git a/templates/backend-and-client/index.html b/templates/backend-and-client/index.html new file mode 100644 index 00000000..01d83531 --- /dev/null +++ b/templates/backend-and-client/index.html @@ -0,0 +1,13 @@ + + + + + + + Todo App + + +
+ + + diff --git a/templates/backend-and-client/jsconfig.json b/templates/backend-and-client/jsconfig.json new file mode 100644 index 00000000..39ebe913 --- /dev/null +++ b/templates/backend-and-client/jsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "target": "esnext" + }, + "include": ["src"] +} diff --git a/templates/backend-and-client/package.json b/templates/backend-and-client/package.json new file mode 100644 index 00000000..91b53dfb --- /dev/null +++ b/templates/backend-and-client/package.json @@ -0,0 +1,24 @@ +{ + "name": "base44-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@base44/sdk": "^0.8.3", + "lucide-react": "^0.475.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "vite": "^6.1.0" + } +} diff --git a/templates/backend-and-client/postcss.config.js b/templates/backend-and-client/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/templates/backend-and-client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/templates/backend-and-client/src/App.jsx b/templates/backend-and-client/src/App.jsx new file mode 100644 index 00000000..71296446 --- /dev/null +++ b/templates/backend-and-client/src/App.jsx @@ -0,0 +1,148 @@ +import { useState, useEffect } from "react"; +import { base44 } from "@/api/base44Client"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Base44Logo } from "@/components/Base44Logo"; +import { Plus, Trash2, CheckCircle2 } from "lucide-react"; + +const Task = base44.entities.Task; + +export default function App() { + const [tasks, setTasks] = useState([]); + const [newTaskTitle, setNewTaskTitle] = useState(""); + const [isLoading, setIsLoading] = useState(true); + + const fetchTasks = async () => { + const data = await Task.list(); + setTasks(data); + setIsLoading(false); + }; + + useEffect(() => { + fetchTasks(); + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!newTaskTitle.trim()) return; + await Task.create({ title: newTaskTitle.trim(), completed: false }); + setNewTaskTitle(""); + fetchTasks(); + }; + + const toggleTask = async (id, completed) => { + await Task.update(id, { completed }); + fetchTasks(); + }; + + const deleteTask = async (id) => { + await Task.delete(id); + fetchTasks(); + }; + + const clearCompleted = async () => { + await Promise.all( + tasks.filter((t) => t.completed).map((t) => Task.delete(t.id)) + ); + fetchTasks(); + }; + + const completedCount = tasks.filter((t) => t.completed).length; + const totalCount = tasks.length; + + return ( +
+
+ {/* Header */} +
+

+ + + Base44 + Tasks + +

+ {totalCount > 0 && ( +

+ {completedCount} of {totalCount} completed +

+ )} +
+ + {/* Add Task Form */} +
+
+ setNewTaskTitle(e.target.value)} + placeholder="What needs to be done?" + className="flex-1 h-12 bg-white border-slate-200 rounded-xl shadow-sm" + /> + +
+
+ + {/* Task List */} +
+ {isLoading ? ( +
+
+
+ ) : tasks.length === 0 ? ( +
+

No tasks yet. Add one above!

+
+ ) : ( + tasks.map((task) => ( +
+ toggleTask(task.id, checked)} + className="w-5 h-5 rounded-md border-slate-300 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500" + /> + + {task.title} + + +
+ )) + )} +
+ + {/* Footer */} + {completedCount > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/templates/backend-and-client/src/api/base44Client.js.ejs b/templates/backend-and-client/src/api/base44Client.js.ejs new file mode 100644 index 00000000..c4c0d596 --- /dev/null +++ b/templates/backend-and-client/src/api/base44Client.js.ejs @@ -0,0 +1,5 @@ +import { createClient } from '@base44/sdk'; + +export const base44 = createClient({ + appId: '<%= projectId %>', +}); diff --git a/templates/backend-and-client/src/components/Base44Logo.jsx b/templates/backend-and-client/src/components/Base44Logo.jsx new file mode 100644 index 00000000..c80bf52e --- /dev/null +++ b/templates/backend-and-client/src/components/Base44Logo.jsx @@ -0,0 +1,15 @@ +export function Base44Logo({ className = "w-8 h-8" }) { + return ( + + + + ); +} diff --git a/templates/backend-and-client/src/components/ui/button.jsx b/templates/backend-and-client/src/components/ui/button.jsx new file mode 100644 index 00000000..f6ee99bc --- /dev/null +++ b/templates/backend-and-client/src/components/ui/button.jsx @@ -0,0 +1,23 @@ +import { forwardRef } from 'react'; + +const Button = forwardRef(({ className = '', variant, size, ...props }, ref) => { + const baseStyles = 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50'; + + const variantStyles = variant === 'ghost' + ? 'hover:bg-slate-100' + : 'bg-slate-900 text-white shadow hover:bg-slate-800'; + + const sizeStyles = size === 'icon' ? 'h-9 w-9' : 'h-9 px-4 py-2'; + + return ( + +)); + +Checkbox.displayName = 'Checkbox'; + +export { Checkbox }; diff --git a/templates/backend-and-client/src/components/ui/input.jsx b/templates/backend-and-client/src/components/ui/input.jsx new file mode 100644 index 00000000..33b32d07 --- /dev/null +++ b/templates/backend-and-client/src/components/ui/input.jsx @@ -0,0 +1,13 @@ +import { forwardRef } from 'react'; + +const Input = forwardRef(({ className = '', ...props }, ref) => ( + +)); + +Input.displayName = 'Input'; + +export { Input }; diff --git a/templates/backend-and-client/src/index.css b/templates/backend-and-client/src/index.css new file mode 100644 index 00000000..863c4216 --- /dev/null +++ b/templates/backend-and-client/src/index.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground antialiased; + } +} diff --git a/templates/backend-and-client/src/main.jsx b/templates/backend-and-client/src/main.jsx new file mode 100644 index 00000000..f2bf72b4 --- /dev/null +++ b/templates/backend-and-client/src/main.jsx @@ -0,0 +1,6 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from '@/App.jsx'; +import '@/index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/templates/backend-and-client/tailwind.config.js b/templates/backend-and-client/tailwind.config.js new file mode 100644 index 00000000..d04fdc36 --- /dev/null +++ b/templates/backend-and-client/tailwind.config.js @@ -0,0 +1,41 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + theme: { + extend: { + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + }, + }, + }, + plugins: [], +}; diff --git a/templates/backend-and-client/vite.config.js b/templates/backend-and-client/vite.config.js new file mode 100644 index 00000000..95d423e2 --- /dev/null +++ b/templates/backend-and-client/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/src/core/project/templates/env.local.ejs b/templates/backend-only/base44/.env.local.ejs similarity index 100% rename from src/core/project/templates/env.local.ejs rename to templates/backend-only/base44/.env.local.ejs diff --git a/src/core/project/templates/config.jsonc.ejs b/templates/backend-only/base44/config.jsonc.ejs similarity index 100% rename from src/core/project/templates/config.jsonc.ejs rename to templates/backend-only/base44/config.jsonc.ejs diff --git a/templates/templates.json b/templates/templates.json new file mode 100644 index 00000000..257d4ff1 --- /dev/null +++ b/templates/templates.json @@ -0,0 +1,16 @@ +{ + "templates": [ + { + "id": "backend-only", + "name": "Backend Only", + "description": "Create a Base44 backend project with entities, functions, and APIs", + "path": "backend-only" + }, + { + "id": "backend-and-client", + "name": "Backend & Client", + "description": "Full-stack project with Base44 backend, Vite and a React client application", + "path": "backend-and-client" + } + ] +} diff --git a/tsdown.config.mjs b/tsdown.config.mjs index 85863d1c..85e87974 100644 --- a/tsdown.config.mjs +++ b/tsdown.config.mjs @@ -7,5 +7,5 @@ export default defineConfig({ outDir: "dist/cli", clean: true, tsconfig: "tsconfig.json", - copy: ["src/core/project/templates"], + copy: ["templates"], });