diff --git a/apps/api/src/locales/@vitnode/blog/en.json b/apps/api/src/locales/@vitnode/blog/en.json new file mode 100644 index 000000000..65f92dfff --- /dev/null +++ b/apps/api/src/locales/@vitnode/blog/en.json @@ -0,0 +1,70 @@ +{ + "@vitnode/blog": { + "title": "Blog", + "admin": { + "nav": { + "posts": "Posts", + "categories": "Categories" + }, + "categories": { + "desc": "Manage categories for blog posts.", + "table": { + "title": "Title", + "updated_at": "Updated At" + }, + "delete": { + "title": "Delete Category", + "desc": "Are you sure you want to delete category? This action cannot be undone.", + "confirm": "Yes, delete this category", + "success": "Category has been deleted successfully." + }, + "create": { + "title": "Create Category", + "desc": "A new category for your blog posts.", + "form": { + "title": { + "label": "Title", + "already_exists": "This category title already exists." + } + }, + "submit": "Create" + }, + "edit": { + "title": "Edit Category", + "submit": "Save Changes" + } + }, + "posts": { + "desc": "Write and manage your blog posts.", + "table": { + "title": "Title", + "category": "Category", + "updated_at": "Updated At" + }, + "create": { + "title": "Create Post", + "desc": "Write a new article for your blog.", + "form": { + "title": { + "label": "Title", + "already_exists": "This post title already exists." + }, + "content": "Content", + "category": "Category" + }, + "submit": "Create Post" + }, + "edit": { + "title": "Edit Post", + "submit": "Save Changes" + }, + "delete": { + "title": "Delete Post", + "desc": "Are you sure you want to delete post? This action cannot be undone.", + "confirm": "Yes, delete this post", + "success": "Post has been deleted successfully." + } + } + } + } +} diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json new file mode 100644 index 000000000..96a4a7254 --- /dev/null +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -0,0 +1,217 @@ +{ + "core": { + "global": { + "theme_switcher": "Toggle Theme", + "language_switcher": "Switch Language", + "toggle_sidebar": "Toggle Sidebar", + "no_results": { + "title": "No results found", + "desc": "Try adjusting your search or filter criteria." + }, + "are_you_sure_want_to_leave_form": { + "title": "Are you sure you want to leave this form?", + "desc": "Your changes will not be saved.", + "cancel": "Cancel", + "confirm": "Yes, leave" + }, + "confirm_action": { + "title": "Are you sure?", + "desc": "This action cannot be undone.", + "cancel": "Cancel", + "confirm": "Yes, confirm" + }, + "search_placeholder": "Search...", + "results_not_found": "No results found", + "date": "{date, date}", + "date_medium": "{date, date, medium}", + "date_short": "{date, date, short}", + "register": "Register", + "login": "Login", + "save": "Save", + "submit": "Submit", + "cancel": "Cancel", + "optional": "Optional", + "loading": "Loading...", + "or": "or", + "back_home": "Back to home", + "go_back": "Go back", + "select_option": "Select an option", + "select_options": "Select options", + "go_to_prev_page": "Go to previous page", + "go_to_next_page": "Go to next page", + "errors": { + "title": "Oops! Something went wrong.", + "internal_server_error": "Internal server error.", + "field_required": "This field is required.", + "field_min_length": "This field must be at least {min} characters.", + "captcha_internal_error": "Captcha validation failed. Please try again later.", + "404": { + "title": "Page Not Found", + "desc": "Oops! The page you're looking for doesn't exist." + }, + "500": { + "title": "Internal Server Error", + "desc": "Sorry, we're experiencing technical difficulties on our server." + }, + "400": { + "title": "Bad Request", + "desc": "The request couldn't be processed due to invalid parameters." + }, + "403": { + "title": "Forbidden", + "desc": "You don't have permission to access this resource." + } + }, + "user_bar": { + "log_out": "Log out", + "admin_cp": "Admin CP" + } + }, + "auth": { + "sso": { + "or": "Or continue With", + "access_denied": "You have denied access to the application or the request has expired. Please try again." + }, + "sign_up": { + "desc": "Hello there! Create your account to get started.", + "already_have_account": "You already have an account? Sign in.", + "submit": "Register", + "username": { + "label": "Username", + "min_length": "Username must be at least 3 characters long.", + "max_length": "Username must be at most 32 characters long.", + "exists": "Username already exists.", + "your_user_code": "Your user code: " + }, + "email": { + "label": "Email", + "invalid": "Invalid email address.", + "exists": "Email already exists." + }, + "password": { + "label": "Password", + "invalid": "Password is too weak.", + "requirements": { + "label": "Password should contain:", + "min_length": "At least 8 characters", + "uppercase": "At least one uppercase letter", + "number": "At least one number", + "special_char": "At least one special character" + } + }, + "terms": { + "label": "Accept terms and conditions", + "required": "You must accept the terms and conditions.", + "desc": "You agree to our Legal documents & Policies." + }, + "newsletter": { + "label": "Newsletter", + "desc": "Receive the latest news and updates." + }, + "email_confirmation": { + "title": "Check your email", + "desc": "We've sent a confirmation link to your email address", + "check_spam": "If you don't see the email in your inbox, please check your spam folder." + } + }, + "sign_in": { + "desc": "Welcome back! Sign in to your account.", + "do_not_have_account": "Don't have an account? Sign up.", + "email": { + "label": "Email", + "invalid": "Invalid email address." + }, + "password": { + "label": "Password", + "required": "Password is required." + }, + "errors": { + "access_denied": { + "title": "Invalid credentials", + "desc": "The email address or password was incorrect. Please try again (make sure your caps lock is off)." + } + }, + "submit": "Login" + } + } + }, + "admin": { + "dashboard": { + "dev_mode": "Development Mode", + "version": "Version: {version}" + }, + "global": { + "nav": { + "core": "Core", + "dashboard": "Dashboard", + "users": { + "title": "Users", + "list": "User List" + }, + "user_bar": { + "home_page": "Home Page", + "debug": "Debug Panel", + "log_out": "Log Out" + } + } + }, + "user": { + "list": { + "desc": "Manage users of your application.", + "user": "User", + "createdAt": "Created At", + "emailNotVerified": "Email Not Verified" + } + }, + "debug": { + "title": "Debug Panel", + "desc": "Check logs, errors, and other debug information.", + "actions": { + "clear_cache": { + "label": "Clear Cache", + "title": "Are you sure you want to clear the cache?", + "desc": "This action will remove all cached data, which may affect performance temporarily.", + "success": "Cache cleared successfully.", + "confirm": "Yes, clear cache" + } + }, + "logs": { + "title": "System Logs", + "created_at": "Created At", + "plugin": "Plugin", + "content": "Content", + "type": "Type", + "types": { + "warn": "Warning", + "error": "Error", + "debug": "Debug" + }, + "status_code": "Status Code", + "more": { + "title": "More Details", + "desc": "Log ID: ", + "log_overview": { + "title": "Log Overview", + "log_type": "Log Type", + "status_code": "Status Code", + "log_id": "Log ID", + "created_at": "Created At", + "plugin": "Plugin", + "user": "User" + }, + "request_information": { + "title": "Request Information", + "ip_address": "IP Address", + "request_method": "Request Method", + "request_url": "Request URL", + "user_agent": "User Agent" + }, + "log_content": { + "title": "Log Content", + "full_log": "Full Log Message" + } + } + } + } + } +} diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index 8988fdf8a..b94db000f 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -1,3 +1,4 @@ +import { NodemailerEmailAdapter } from '@vitnode/core/api/adapters/email/nodemailer'; import { buildApiConfig } from '@vitnode/core/vitnode.config'; import * as dotenv from 'dotenv'; import { drizzle } from 'drizzle-orm/postgres-js'; @@ -12,10 +13,19 @@ export const POSTGRES_URL = export const vitNodeApiConfig = buildApiConfig({ plugins: [], + pathToMessages: async path => await import(`./locales/${path}`), dbProvider: drizzle({ connection: POSTGRES_URL, casing: 'camelCase', }), + email: { + adapter: NodemailerEmailAdapter({ + from: process.env.NODE_MAILER_FROM, + host: process.env.NODE_MAILER_HOST, + password: process.env.NODE_MAILER_PASSWORD, + user: process.env.NOD_EMAILER_USER, + }), + }, metadata: { title: 'VitNode API', shortTitle: 'VitNode', diff --git a/apps/docs/src/app/[locale]/(main)/(home)/page.tsx b/apps/docs/src/app/[locale]/(main)/(home)/page.tsx index 705a91568..7aa8558b3 100644 --- a/apps/docs/src/app/[locale]/(main)/(home)/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(home)/page.tsx @@ -19,7 +19,7 @@ export const metadata: Metadata = { export default function HomePage() { return (
-
+

Extendable Framework for diff --git a/apps/docs/src/vitnode.api.config.ts b/apps/docs/src/vitnode.api.config.ts index f9675ff13..7d2cc75c0 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -11,6 +11,7 @@ export const POSTGRES_URL = process.env.POSTGRES_URL || 'postgresql://root:root@localhost:5432/vitnode'; export const vitNodeApiConfig = buildApiConfig({ + pathToMessages: async path => await import(`./locales/${path}`), captcha: { type: 'cloudflare_turnstile', siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts index 333637483..ba53fa6d2 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts @@ -6,6 +6,7 @@ export const POSTGRES_URL = process.env.POSTGRES_URL || 'postgresql://root:root@localhost:5432/vitnode'; export const vitNodeApiConfig = buildApiConfig({ + pathToMessages: async path => await import(`./locales/${path}`), metadata: { title: 'VitNode', shortTitle: 'VitNode', diff --git a/packages/create-vitnode-app/src/create/create-vitnode.ts b/packages/create-vitnode-app/src/create/create-vitnode.ts index 3353c8f6e..70b023d51 100644 --- a/packages/create-vitnode-app/src/create/create-vitnode.ts +++ b/packages/create-vitnode-app/src/create/create-vitnode.ts @@ -217,11 +217,26 @@ export const createVitNode = async ({ cwd: root, }); - if (mode !== 'onlyApi') { - spinner.text = 'Initializing VitNode files...'; + spinner.text = 'Initializing VitNode files...'; + if (mode === 'apiMonorepo') { + await Promise.all([ + initFilesVitnode({ + packageManager, + cwd: monorepoStructure.web, + }), + initFilesVitnode({ + packageManager, + cwd: monorepoStructure.api, + }), + initFilesVitnode({ + packageManager, + cwd: root, + }), + ]); + } else { initFilesVitnode({ packageManager, - cwd: mode === 'apiMonorepo' || monorepo ? monorepoStructure.web : root, + cwd: root, }); } diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 855de85f1..62da96082 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -27,12 +27,12 @@ "drizzle-kit": "0.31.x", "drizzle-orm": "^0.44.x", "hono": "4.8.x", + "motion": "^12.x.x", "next": "15.3.x", "next-intl": "4.x.x", "react": "19.1.x", "react-dom": "19.1.x", "react-hook-form": "^7.x.x", - "motion": "^12.x.x", "typescript": "^5.8.x", "zod": "4.x.x" }, @@ -126,6 +126,7 @@ "resend": "^4.7.0", "tailwind-merge": "^3.3.1", "use-debounce": "^10.0.5", + "use-intl": "^4.3.4", "vaul": "^1.1.2" } } diff --git a/packages/vitnode/scripts/plugin.ts b/packages/vitnode/scripts/plugin.ts index fdbc25e45..86c7109d5 100644 --- a/packages/vitnode/scripts/plugin.ts +++ b/packages/vitnode/scripts/plugin.ts @@ -1,6 +1,12 @@ /* eslint-disable no-console */ import chokidar from 'chokidar'; -import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, +} from 'fs'; import { basename, join, relative } from 'path'; import { @@ -38,38 +44,135 @@ export const processPlugin = ({ initMessage }: { initMessage: string }) => { // Transform plugin name for path usage const pluginPathName = pluginName.replace(/\//g, '-').replace(/@/g, ''); - const mainDest = join( - repoRoot, - 'apps', - 'docs', - 'src', - 'app', - '[locale]', - '(main)', - join('(plugins)', `(${pluginPathName})`), - ); - const adminDest = join( - repoRoot, - 'apps', - 'docs', - 'src', - 'app', - '[locale]', - 'admin', - '(auth)', - join('(plugins)', `(${pluginPathName})`), - ); - const langDest = join(repoRoot, 'apps', 'docs', 'src', 'locales', pluginName); + // Detect app types by checking for config files + const detectAppType = (appPath: string) => { + const hasWebConfig = existsSync(join(appPath, 'src', 'vitnode.config.ts')); + const hasApiConfig = existsSync( + join(appPath, 'src', 'vitnode.api.config.ts'), + ); + + if (hasApiConfig && !hasWebConfig) return 'api'; + if (hasWebConfig) return 'web'; + + return null; + }; + + // Check if we're in a monorepo by looking for apps directories + const appsDir = join(repoRoot, 'apps'); + const isMonorepo = existsSync(appsDir); + + const sources: SourceConfig[] = []; + + if (isMonorepo) { + // Monorepo: scan all apps and detect their types + const appDirs = existsSync(appsDir) + ? readdirSync(appsDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + : []; + + for (const appName of appDirs) { + const appPath = join(repoRoot, 'apps', appName); + const appType = detectAppType(appPath); + + if (appType === 'web') { + // Web app: copy app, app_admin, and locales + const mainDest = join( + appPath, + 'src', + 'app', + '[locale]', + '(main)', + join('(plugins)', `(${pluginPathName})`), + ); + const adminDest = join( + appPath, + 'src', + 'app', + '[locale]', + 'admin', + '(auth)', + join('(plugins)', `(${pluginPathName})`), + ); + const langDest = join(appPath, 'src', 'locales', pluginName); + + sources.push( + { + sourceDir: join(pluginDir, 'src', 'app_admin'), + destinationDir: adminDest, + }, + { + sourceDir: join(pluginDir, 'src', 'app'), + destinationDir: mainDest, + }, + { + sourceDir: join(pluginDir, 'src', 'locales'), + destinationDir: langDest, + }, + ); + } else if (appType === 'api') { + // API app: copy only locales + const apiLangDest = join(appPath, 'src', 'locales', pluginName); + + sources.push({ + sourceDir: join(pluginDir, 'src', 'locales'), + destinationDir: apiLangDest, + }); + } + } + } else { + // Standalone project: check if we're running from within an app directory + // or if we need to copy to the current working directory + const cwd = process.cwd(); + const projectType = detectAppType(cwd); + + if (projectType === 'web') { + // Web project: copy all files to current working directory + const mainDest = join( + cwd, + 'src', + 'app', + '[locale]', + '(main)', + join('(plugins)', `(${pluginPathName})`), + ); + const adminDest = join( + cwd, + 'src', + 'app', + '[locale]', + 'admin', + '(auth)', + join('(plugins)', `(${pluginPathName})`), + ); + const langDest = join(cwd, 'src', 'locales', pluginName); + + sources.push( + { + sourceDir: join(pluginDir, 'src', 'app_admin'), + destinationDir: adminDest, + }, + { + sourceDir: join(pluginDir, 'src', 'app'), + destinationDir: mainDest, + }, + { + sourceDir: join(pluginDir, 'src', 'locales'), + destinationDir: langDest, + }, + ); + } else if (projectType === 'api') { + // API project: copy only locales to current working directory + const langDest = join(cwd, 'src', 'locales', pluginName); + + sources.push({ + sourceDir: join(pluginDir, 'src', 'locales'), + destinationDir: langDest, + }); + } + } // tell the copier about both trees - const sources: SourceConfig[] = [ - { - sourceDir: join(pluginDir, 'src', 'app_admin'), - destinationDir: adminDest, - }, - { sourceDir: join(pluginDir, 'src', 'app'), destinationDir: mainDest }, - { sourceDir: join(pluginDir, 'src', 'locales'), destinationDir: langDest }, - ]; // Create destination directories if they don't exist and source directories are not empty for (const { sourceDir, destinationDir } of sources) { @@ -144,39 +247,50 @@ export const processPlugin = ({ initMessage }: { initMessage: string }) => { persistent: true, }); - const getDestinationPath = (srcPath: string): string => { + const getDestinationPaths = (srcPath: string): string[] => { // collect all matching sourceConfigs - const candidates = sources.filter(({ sourceDir }) => - srcPath.startsWith(sourceDir), - ); + const candidates = sources.filter(({ sourceDir }) => { + // Ensure exact directory matching by checking if the path starts with sourceDir + // followed by a path separator (or is exactly the sourceDir) + const normalizedSrcPath = srcPath.replace(/\\/g, '/'); + const normalizedSourceDir = sourceDir.replace(/\\/g, '/'); + + return ( + normalizedSrcPath === normalizedSourceDir || + normalizedSrcPath.startsWith(normalizedSourceDir + '/') + ); + }); if (candidates.length === 0) { throw new Error(`No matching source directory for: ${srcPath}`); } - // pick the one with the longest sourceDir (most specific) - const sourceConfig = candidates.reduce((best, cur) => - cur.sourceDir.length > best.sourceDir.length ? cur : best, - ); - - // now append the relative path - const relativePath = relative(sourceConfig.sourceDir, srcPath); + // Return all matching destination paths instead of just one + return candidates.map(sourceConfig => { + const relativePath = relative(sourceConfig.sourceDir, srcPath); - return join(sourceConfig.destinationDir, relativePath); + return join(sourceConfig.destinationDir, relativePath); + }); }; watcher .on('add', filePath => { - const destPath = getDestinationPath(filePath); - copyFileWrapper(filePath, destPath); + const destPaths = getDestinationPaths(filePath); + destPaths.forEach(destPath => { + copyFileWrapper(filePath, destPath); + }); }) .on('change', filePath => { - const destPath = getDestinationPath(filePath); - copyFileWrapper(filePath, destPath); + const destPaths = getDestinationPaths(filePath); + destPaths.forEach(destPath => { + copyFileWrapper(filePath, destPath); + }); }) .on('unlink', filePath => { - const destPath = getDestinationPath(filePath); - removeFile(destPath); + const destPaths = getDestinationPaths(filePath); + destPaths.forEach(destPath => { + removeFile(destPath); + }); }) .on('error', error => { console.error('\x1b[31mWatcher error:\x1b[0m', error); diff --git a/packages/vitnode/scripts/prepare-database.ts b/packages/vitnode/scripts/prepare-database.ts index ccd074031..da82196a1 100644 --- a/packages/vitnode/scripts/prepare-database.ts +++ b/packages/vitnode/scripts/prepare-database.ts @@ -160,10 +160,14 @@ export const prepareDatabase = async ({ if (flag === '--web') { steps.push({ label: 'Prepare plugins files...', - action: preparePluginsFiles, + action: async () => await preparePluginsFiles(flag), }); } else if (flag === '--api') { steps.push( + { + label: 'Prepare plugins files...', + action: async () => await preparePluginsFiles(flag), + }, { label: 'Generate migrations...', action: generateDatabaseMigrations, @@ -181,7 +185,7 @@ export const prepareDatabase = async ({ steps.push( { label: 'Prepare plugins files...', - action: preparePluginsFiles, + action: async () => await preparePluginsFiles(flag), }, { label: 'Generate migrations...', diff --git a/packages/vitnode/scripts/prepare-plugins-files.ts b/packages/vitnode/scripts/prepare-plugins-files.ts index 37c4c2510..156b8bef6 100644 --- a/packages/vitnode/scripts/prepare-plugins-files.ts +++ b/packages/vitnode/scripts/prepare-plugins-files.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import { existsSync } from 'fs'; +import { existsSync, readdirSync } from 'fs'; import { readFile } from 'fs/promises'; import { join, relative } from 'path'; @@ -13,8 +13,31 @@ import { type SourceConfig, } from './shared/file-utils'; -export const preparePluginsFiles = async () => { - const config = await getConfig({}); +export const preparePluginsFiles = async (flag?: string) => { + // Detect which config file to load based on flag or auto-detection + const cwd = process.cwd(); + const hasWebConfig = existsSync(join(cwd, 'src', 'vitnode.config.ts')); + const hasApiConfig = existsSync(join(cwd, 'src', 'vitnode.api.config.ts')); + + let config: { plugins: { pluginId: string }[] }; + + if (flag === '--api' && hasApiConfig) { + // Force API config when --api flag is used + config = await getConfig({ type: 'api.config' }); + } else if (flag === '--web' && hasWebConfig) { + // Force web config when --web flag is used + config = await getConfig({}); + } else if (hasApiConfig && !hasWebConfig) { + // API config only (auto-detect) + config = await getConfig({ type: 'api.config' }); + } else if (hasWebConfig) { + // Web config (may also have API config but web takes precedence in auto-detect) + config = await getConfig({}); + } else { + // No config found, use empty plugins array + config = { plugins: [] }; + } + const plugins: string[] = [ ...config.plugins.map(plugin => plugin.pluginId), '@vitnode/core', @@ -81,43 +104,121 @@ export const preparePluginsFiles = async () => { // Transform plugin name for path usage const pluginPathName = pluginName.replace(/\//g, '-').replace(/@/g, ''); - // All projects (both monorepo apps and standalone) use the same structure - const mainDest = join( - baseDir, - 'src', - 'app', - '[locale]', - '(main)', - join('(plugins)', `(${pluginPathName})`), - ); - - const adminDest = join( - baseDir, - 'src', - 'app', - '[locale]', - 'admin', - '(auth)', - join('(plugins)', `(${pluginPathName})`), - ); - - const langDest = join(baseDir, 'src', 'locales', pluginName); + // Detect app types by checking for config files + const detectAppType = (appPath: string) => { + const hasWebConfig = existsSync( + join(appPath, 'src', 'vitnode.config.ts'), + ); + const hasApiConfig = existsSync( + join(appPath, 'src', 'vitnode.api.config.ts'), + ); + + if (hasApiConfig && !hasWebConfig) return 'api'; + if (hasWebConfig) return 'web'; + + return null; + }; + + // Check if we're in a monorepo by looking for apps directories + const appsDir = join(repoRoot, 'apps'); + const isMonorepo = existsSync(appsDir); // Define source configurations for this plugin - const sources: SourceConfig[] = [ - { - sourceDir: join(pluginPath, 'src', 'app_admin'), - destinationDir: adminDest, - }, - { - sourceDir: join(pluginPath, 'src', 'app'), - destinationDir: mainDest, - }, - { - sourceDir: join(pluginPath, 'src', 'locales'), - destinationDir: langDest, - }, - ]; + const sources: SourceConfig[] = []; + + if (isMonorepo) { + // Monorepo: scan all apps and detect their types + const appDirs = existsSync(appsDir) + ? readdirSync(appsDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + : []; + + for (const appName of appDirs) { + const appPath = join(repoRoot, 'apps', appName); + const appType = detectAppType(appPath); + + if (appType === 'web') { + // Web app: copy app, app_admin, and locales + const mainDest = join( + appPath, + 'src', + 'app', + '[locale]', + '(main)', + join('(plugins)', `(${pluginPathName})`), + ); + const adminDest = join( + appPath, + 'src', + 'app', + '[locale]', + 'admin', + '(auth)', + join('(plugins)', `(${pluginPathName})`), + ); + const langDest = join(appPath, 'src', 'locales', pluginName); + + sources.push( + { + sourceDir: join(pluginPath, 'src', 'app_admin'), + destinationDir: adminDest, + }, + { + sourceDir: join(pluginPath, 'src', 'app'), + destinationDir: mainDest, + }, + { + sourceDir: join(pluginPath, 'src', 'locales'), + destinationDir: langDest, + }, + ); + } else if (appType === 'api') { + // API app: copy only locales + const apiLangDest = join(appPath, 'src', 'locales', pluginName); + + sources.push({ + sourceDir: join(pluginPath, 'src', 'locales'), + destinationDir: apiLangDest, + }); + } + } + } else { + // Standalone project: use current directory as base + const mainDest = join( + baseDir, + 'src', + 'app', + '[locale]', + '(main)', + join('(plugins)', `(${pluginPathName})`), + ); + const adminDest = join( + baseDir, + 'src', + 'app', + '[locale]', + 'admin', + '(auth)', + join('(plugins)', `(${pluginPathName})`), + ); + const langDest = join(baseDir, 'src', 'locales', pluginName); + + sources.push( + { + sourceDir: join(pluginPath, 'src', 'app_admin'), + destinationDir: adminDest, + }, + { + sourceDir: join(pluginPath, 'src', 'app'), + destinationDir: mainDest, + }, + { + sourceDir: join(pluginPath, 'src', 'locales'), + destinationDir: langDest, + }, + ); + } // Copy files for each source directory for (const { sourceDir, destinationDir } of sources) { diff --git a/packages/vitnode/scripts/scripts.ts b/packages/vitnode/scripts/scripts.ts index e1559440c..6c957d714 100644 --- a/packages/vitnode/scripts/scripts.ts +++ b/packages/vitnode/scripts/scripts.ts @@ -51,7 +51,7 @@ switch (command) { break; case 'prepare-plugins': - await preparePluginsFiles(); + await preparePluginsFiles(flag); console.log(`${initMessage} \x1b[32mPlugins prepared successfully.\x1b[0m`); process.exit(0); diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index e146ca113..f97de73b4 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -59,11 +59,13 @@ export function VitNodeAPI({ app.use( '*', globalMiddleware({ + pathToMessages: vitNodeApiConfig.pathToMessages, email: vitNodeApiConfig.email, metadata: vitNodeApiConfig.metadata, authorization: vitNodeApiConfig.authorization, dbProvider: vitNodeApiConfig.dbProvider, captcha: vitNodeApiConfig.captcha, + plugins: vitNodeApiConfig.plugins, }), ); app.use(async (c, next) => { diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 096abf673..fa4a230a8 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -63,6 +63,8 @@ export interface EnvVariablesVitNode { shortTitle?: string; title: string; }; + pathToMessages: (path: string) => Promise<{ default: object }>; + plugins: { id: string }[]; }; db: Pick['dbProvider']; email: { @@ -98,9 +100,16 @@ export const globalMiddleware = ({ email, dbProvider, captcha, + plugins, + pathToMessages, }: Pick< VitNodeApiConfig, - 'authorization' | 'captcha' | 'dbProvider' | 'email' + | 'authorization' + | 'captcha' + | 'dbProvider' + | 'email' + | 'pathToMessages' + | 'plugins' > & Pick) => { return async (c: Context, next: Next) => { @@ -152,6 +161,7 @@ export const globalMiddleware = ({ c.set('email', new EmailModel(c)); c.set('core', { + pathToMessages, metadata, email, authorization: { @@ -168,6 +178,9 @@ export const globalMiddleware = ({ cookieSecure: authorization?.cookieSecure ?? true, }, captcha, + plugins: plugins.map(plugin => ({ + id: plugin.pluginId, + })), }); const user = await new SessionModel(c).getUser(); diff --git a/packages/vitnode/src/api/models/email.ts b/packages/vitnode/src/api/models/email.ts index 534476216..fbe61b3b8 100644 --- a/packages/vitnode/src/api/models/email.ts +++ b/packages/vitnode/src/api/models/email.ts @@ -19,7 +19,7 @@ export interface EmailApiPlugin { } export interface EmailModelSendArgs { - content: React.ReactNode; + content: (props: { locale: string }) => React.ReactNode; html?: string; replyTo?: string; subject: string; @@ -42,15 +42,40 @@ export class EmailModel { }); } + const locale = 'en'; + const pluginIds: string[] = [ + '@vitnode/core', + ...this.c.get('core').plugins.map(plugin => plugin.id), + ]; + + const messagesPromises = pluginIds.map(async pluginId => { + try { + const path = `${pluginId}/${locale}.json`; + const messages = await core.pathToMessages(path); + + return messages.default; + } catch { + return {}; + } + }); + + const allMessages = await Promise.all(messagesPromises); + const messages = allMessages.reduce( + (acc, curr) => ({ ...acc, ...curr }), + {}, + ) as Record; + const htmlContent = html ?? DefaultTemplateEmail({ - children: content, + children: content({ locale }), metadata: { ...core.metadata, url: CONFIG.web.href, }, logo: core.email?.options?.logo, + locale, + messages, }); try { diff --git a/packages/vitnode/src/api/modules/users/routes/test.route.ts b/packages/vitnode/src/api/modules/users/routes/test.route.ts index 21f595b0d..3f54fe468 100644 --- a/packages/vitnode/src/api/modules/users/routes/test.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/test.route.ts @@ -32,7 +32,7 @@ export const testRoute = buildRoute({ await c.get('email').send({ to: 'ithereplay@gmail.com', subject: 'Test Email', - content: 'This is a test email', + content: ({ locale }) => `This is a test email in ${locale} locale.`, }); // throw new Error('Test error'); diff --git a/packages/vitnode/src/emails/default-template.tsx b/packages/vitnode/src/emails/default-template.tsx index a4b69d546..dfb6f3a72 100644 --- a/packages/vitnode/src/emails/default-template.tsx +++ b/packages/vitnode/src/emails/default-template.tsx @@ -12,17 +12,19 @@ import { Tailwind, Text, } from '@react-email/components'; -import { createTranslator } from 'next-intl'; +import { createTranslator } from 'use-intl'; import { CONFIG } from '../lib/config'; interface DefaultTemplateEmailProps { children: React.ReactNode; head?: React.ReactNode; + locale: string; logo?: { className?: string; src: Blob | string; }; + messages: Record; metadata: { shortTitle?: string; title: string; @@ -37,18 +39,13 @@ export default function DefaultTemplateEmail({ children, logo, metadata, + messages, + locale, }: DefaultTemplateEmailProps) { - const intl = createTranslator({ - messages: { - email: { - previewText: 'This is a preview text for the email template.', - }, - }, - locale: 'en', - }); + const t = createTranslator({ locale, messages }); return ( - + {head} {previewText && {previewText}} - Join Us for an Exciting Event! - {intl('email.previewText')} + Join Us for an Exciting Event! - {t('core.auth.sign_in.desc')} Hello @@ -121,4 +118,6 @@ DefaultTemplateEmail.PreviewProps = { logo: { src: 'https://www.reactemailtemplate.com/_next/static/media/reactemailtemplate-logo.b3fb12d9.png', }, + messages: {}, + locale: 'en', } satisfies DefaultTemplateEmailProps; diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 62d01382b..8c9ba4dde 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -54,6 +54,7 @@ export interface VitNodeApiConfig { shortTitle?: string; title: string; }; + pathToMessages: (path: string) => Promise<{ default: object }>; plugins: BuildPluginApiReturn[]; rateLimiter?: Omit; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a967b1d02..0778e0733 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,9 @@ importers: use-debounce: specifier: ^10.0.5 version: 10.0.5(react@19.1.0) + use-intl: + specifier: ^4.3.4 + version: 4.3.4(react@19.1.0) vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)