diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 99eb60aca..af5f4052e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,26 +1,32 @@ # VitNode Development Guidelines -VitNode is a comprehensive framework designed to simplify and accelerate application development with Next.js and Hono.js. Built as a monorepo solution managed by Turborepo, VitNode provides a structured environment that makes development faster and less complex. The framework includes an integrated AdminCP and plugin system to extend its core functionality. +VitNode is a comprehensive framework designed to simplify and accelerate application development with Next.js and Hono.js. Built as a monorepo solution managed by Turborepo, VitNode provides a structured environment that makes development faster and less complex. The framework includes an integrated AdminCP, plugin system, authentication, role management, and comprehensive developer tools. ## Global Rules - Write ESModule only - Always use snake_case for file names - Use pnpm as package manager -- Use Zod 3 for schema validation +- Use Zod 3 for schema validation and runtime validation - Use react-hook-form 7 for forms - Use Shadcn UI & Tailwind CSS 4 for UI - Respect Prettier configuration in `packages/eslint/prettierrc.mjs` and ESLint configuration in `packages/eslint/eslint.config.mjs` -- Use TypeScript 5, React 19 & Hono.js 4 +- Use TypeScript 5 with strict configuration, React 19 & Hono.js 4 +- Follow WCAG 2.1 AA compliance for accessibility +- Aim for Lighthouse scores of 95+ and Core Web Vitals optimization +- Implement proper error handling and validation at all levels ## Frontend Development (Next.js & React) -- Use Next.js 15 -- Use App Router and Server Components +- Use Next.js 15 with App Router and Server Components - Use server actions for form handling and data mutations from Server Components - Leverage Next.js Image component with proper sizing for core web vitals optimization - Navigation API is in `vitnode/lib/navigation` file. Avoid using `next/navigation` directly - Alert Dialog & Dialog content should always have title and description with React lazy loading content +- Implement dark/light mode support with system preference detection +- Ensure keyboard navigation support and screen reader compatibility +- Use proper semantic HTML and ARIA attributes +- Use XSS protection with content security policy ### Internationalization (i18n) @@ -30,10 +36,16 @@ VitNode is a comprehensive framework designed to simplify and accelerate applica ## Backend Development (Hono.js) -- Use @hono/zod-openapi or Zod 3 for schema validation -- Use PostgreSQL with Drizzle ORM +- Use @hono/zod-openapi or Zod 3 for schema validation and OpenAPI documentation +- Use PostgreSQL with Drizzle ORM for database operations - Use `t.serial().primaryKey()` for all database IDs - To get access to database, use `c.get('database')` by Hono.js context +- Implement rate limiting on API endpoints, especially authentication +- Use secure session management with configurable duration +- Implement proper error handling and logging +- Follow RESTful API design principles +- Support API versioning with backward compatibility +- Include comprehensive OpenAPI 3.0 specifications ## Documentation (\*.mdx files) diff --git a/.github/docs/prd.md b/.github/docs/prd.md index 227a8af26..7962b96e1 100644 --- a/.github/docs/prd.md +++ b/.github/docs/prd.md @@ -24,119 +24,261 @@ VitNode is designed for individual developers and small teams who need a structu ### Structure and Configuration -- Monorepo structure using Turborepo with `apps` and `plugins` directories -- Simple CLI tool for creating new projects (`create-vitnode-app`) -- Script to set up PostgreSQL database with initial data (roles, languages, etc.) -- Internationalization (i18n) support using next-intl (RTL not required for MVP) -- Dark/Light mode support +- Monorepo structure using Turborepo with `apps`, `packages`, and `plugins` directories +- CLI tool for creating new projects (`create-vitnode-app`) +- Database migration system using Drizzle ORM with PostgreSQL +- Internationalization (i18n) support using next-intl +- Dark/Light mode support with system preference detection +- Environment-based configuration management ### Plugin System -- Monorepo-based plugin architecture created using `npx create-vitnode-app@canary --plugin` +- Monorepo-based plugin architecture with standardized structure - Plugins can extend functionality by creating: - - New pages and routes - - API endpoints - - AdminCP pages - - SSO providers - - Email providers (SMTP, Resend) + - New pages and routes with automatic navigation integration + - API endpoints with OpenAPI documentation + - AdminCP pages with role-based access control + - Database schema extensions with automatic migrations + - Custom UI components and layouts + - Email providers (SMTP, Resend, custom) +- Plugin hooks and events system for inter-plugin communication ### CI/CD - Automated workflows using GitHub Actions: - - Code quality checks (linting, formatting) - - Test suite execution on pull requests and main branch - - Dependency security scanning - - Automated builds and deployments to staging/production environments - - Version tagging and changelog generation - - Docker image building and publishing + - Code quality checks (ESLint, Prettier, TypeScript) + - Test suite execution with Vitest and Playwright + - Dependency security scanning with npm audit + - Automated builds and deployments to Vercel - Database schema validation and migration checks + - Automated changelog generation and release notes ### Authentication & Authorization -- Authentication via credentials and SSO (Facebook, Google, GitHub) -- User registration and login functionality -- Password reset and email verification -- Session management in cookies and database: - - 3 months for users - - 24 hours for admins - - No session storage for guests -- Security features including password hashing, secure cookies, and protection against CSRF and XSS +- Multi-provider authentication system: + - Credentials (email/password) with hashing + - OAuth providers (Google, GitHub, Facebook, Discord) +- User registration with email verification +- Password reset with secure token generation +- Two-factor authentication (TOTP) +- Session management with secure cookies: + - Configurable session duration per user group + - Automatic session cleanup + - Cross-device session management +- Security features: + - CSRF protection with double-submit cookies + - XSS protection with content security policy + - Rate limiting on authentication endpoints + - Account lockout after failed attempts ### Role Management -- AdminCP interface for role CRUD operations -- Role assignment to users -- Role-based permission system with capabilities defined in the database -- Permissions enforced through middleware in API and AdminCP +- Hierarchical role system with inheritance +- AdminCP interface for comprehensive role management: + - Role CRUD operations with validation + - Permission matrix with granular controls + - Bulk role assignment and management +- Dynamic permission system: + - Resource-based permissions (read, write, delete, admin) + - Context-aware permissions (own content vs. all content) + - Plugin-defined custom permissions +- Role-based middleware for API and page protection ### User Management -- AdminCP interface for user CRUD operations -- Search and filter functionality for users -- User profile editing (password, email, avatar, role assignment) +- Comprehensive user administration: + - Advanced search and filtering (by role, status, registration date) + - Bulk operations (role assignment, status changes, deletion) + - User activity tracking and audit logs + - Profile management with avatar uploads +- User groups and organization support +- Flexible user profile fields with custom validation ### API and Documentation -- OpenAPI documentation for all API endpoints -- API versioning support (core functionality handled by framework, plugins can implement multiple versions) -- Comprehensive documentation using Fumadocs -- Documentation structured by feature with examples and best practices +- Full OpenAPI 3.0 specification with Swagger UI +- API versioning with backward compatibility +- Comprehensive documentation using Fumadocs: + - Interactive examples with code snippets + - Plugin development guides + - Deployment instructions + - Best practices and patterns +- Type-safe API client generation ### Developer Tools -- Integration with Next.js and Hono.js debugging tools -- Support for react-scan for component debugging -- Helper functions for form handling, API calls, and routing -- Clear error messages and debugging guidance +- Integrated development environment: + - Hot reload for both frontend and backend + - Database query logging and profiling + - API request/response logging + - Performance monitoring with Core Web Vitals +- Debugging tools: + - React Developer Tools integration + - Database query inspector + - Authentication flow debugger +- Code generation tools: + - Component scaffolding + - API endpoint generation + - Database schema generation from models + +### File Management + +- Configurable file upload system: + - Local filesystem storage + - Cloud storage providers (AWS S3, Google Cloud, Azure) + - Image processing and optimization + - File type validation and security scanning +- Media library with organization features +- CDN integration for optimal performance + +### Content Management + +- Flexible content types with custom fields +- WYSIWYG editor with plugin support +- Content versioning and revision history +- Workflow management (draft, review, published) +- SEO optimization tools + +## Technical Architecture + +### Frontend Stack + +- Next.js 15 with App Router +- React 19 with Server Components +- TypeScript 5 with strict configuration +- Tailwind CSS 4 with Shadcn UI components +- Zod 3 for runtime validation +- React Hook Form 7 for form management +- Next-intl for internationalization + +### Backend Stack + +- Hono.js 4 for API development +- Drizzle ORM with PostgreSQL +- Zod OpenAPI for API documentation + +### Development Tools + +- Turborepo for monorepo management +- Vitest for unit testing +- Playwright for end-to-end testing +- ESLint and Prettier for code quality +- Docker for containerization ## Features Planned for Future Releases -The following features are not included in the MVP but will be available in future iterations: +The following features are planned for upcoming releases: -- File uploads -- WYSIWYG editor -- WebSockets -- Caching -- Rate limiting -- Additional analytics features +- WebSocket support for real-time features +- Advanced caching strategies (Redis, Memcached) +- Enhanced rate limiting with Redis backend +- Advanced analytics and reporting +- Marketplace for community plugins +- Multi-tenant architecture support +- Advanced workflow automation +- AI-powered features (content generation, smart suggestions) ## Success Criteria ### Developer Experience -- Developers should be able to easily create and manage applications using the VitNode framework -- Measured by user feedback and time taken to create CRUD operations, forms, and plugins -- Positive user feedback regarding ease of use and framework utility +- Developers should create a basic CRUD application within 30 minutes +- Plugin development should take less than 2 hours for basic functionality +- Framework adoption measured by: + - GitHub stars and community engagement + - Plugin ecosystem growth + - Developer feedback scores (target: 4.5/5) ### Performance -- Complex pages should have a score of 90+ in Lighthouse -- Achieved through code splitting, lazy loading, image optimization, and static page generation with suspense +- Lighthouse scores of 95+ for all generated pages +- Time to First Byte (TTFB) under 200ms +- Largest Contentful Paint (LCP) under 1.5 seconds +- Cumulative Layout Shift (CLS) under 0.1 ### Accessibility -- UI should be accessible and follow WCAG 2.1 guidelines -- Components should be properly labeled and compatible with screen readers +- WCAG 2.1 AA compliance for all UI components +- Screen reader compatibility testing +- Keyboard navigation support +- Color contrast ratios meeting accessibility standards ### Deployment -- VitNode should deploy applications with minimal configuration to: - - Vercel (serverless) with Supabase (PostgreSQL) - - Docker (self-hosted) +- One-click deployment to major platforms: + - Vercel with Supabase/PlanetScale + - AWS with RDS + - Google Cloud Platform + - Self-hosted with Docker Compose +- Deployment time under 5 minutes for basic applications ### Documentation -- Complete and up-to-date documentation for all framework features -- Positive user feedback on documentation quality and usefulness -- Effective examples and best practices to facilitate framework usage +- Complete API documentation with interactive examples +- Step-by-step tutorials for common use cases +- Video tutorials for complex features +- Community-contributed examples and patterns +- Documentation satisfaction score of 4.7/5 ## Developer Workflow -The ideal developer workflow includes: +The recommended developer workflow: -1. `npx create-vitnode-app` to create a new project -2. `npm install` to install dependencies -3. `npm run docker:dev` to start database in Docker -4. `npm run dev` to start the development server -5. `npm run build` to build the application for production -6. `npm run start` to start the production server +1. **Project Creation** + + ```bash + npx create-vitnode-app@latest my-app + cd my-app + ``` + +2. **Development Setup** + + ```bash + pnpm install + pnpm db:push # Set up database schema + pnpm db:seed # Populate with initial data + ``` + +3. **Development** + + ```bash + pnpm dev # Start development servers + pnpm dev:docs # Start documentation server + ``` + +4. **Plugin Development** + + ```bash + pnpm create:plugin my-plugin + cd plugins/my-plugin + pnpm dev + ``` + +5. **Testing** + + ```bash + pnpm test # Run unit tests + pnpm test:e2e # Run end-to-end tests + pnpm test:coverage # Generate coverage report + ``` + +6. **Production Build** + + ```bash + pnpm build # Build all applications + pnpm start # Start production server + ``` + +7. **Deployment** + ```bash + pnpm deploy # Deploy to configured platform + ``` + +## Quality Assurance + +- Automated testing pipeline with 90%+ code coverage +- Performance monitoring with automated alerts +- Security scanning with dependency vulnerability checks +- Accessibility testing with automated tools +- Cross-browser compatibility testing +- Load testing for high-traffic scenarios diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index a76e545d2..e6177c842 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -36,65 +36,11 @@ jobs: - name: Build Scripts run: pnpm build:scripts - # - name: Build Packages - # run: pnpm build:packages - - # - name: Run config init - # run: pnpm config:init:skip-database - - name: Run build run: pnpm build - lint: - runs-on: ubuntu-latest - needs: build - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 10.12.1 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Check formatting run: pnpm lint - test: - runs-on: ubuntu-latest - needs: build - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - uses: pnpm/action-setup@v4 - name: Install pnpm - with: - version: 10.12.1 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Run tests run: pnpm test diff --git a/apps/docs/content/docs/ui/combobox-async.mdx b/apps/docs/content/docs/ui/combobox-async.mdx new file mode 100644 index 000000000..1cb17c88c --- /dev/null +++ b/apps/docs/content/docs/ui/combobox-async.mdx @@ -0,0 +1,91 @@ +--- +title: Combobox Async +description: Asynchronously load options for a combobox with search functionality. +--- + +## Usage + +```ts +import { z } from 'zod'; +import { AutoForm } from '@vitnode/core/components/form/auto-form'; +import { AutoFormComboboxAsync } from '@vitnode/core/components/form/fields/combobox-async'; +import { fetcherClient } from '@vitnode/core/lib/fetcher-client'; +``` + +```ts +const formSchema = z.object({ + categoryId: z.object({ value: z.string(), label: z.string() }), +}); +``` + +```tsx + ( + { + const res = await fetcherClient(categoriesModule, { + path: '/', + method: 'get', + module: 'categories', + args: { + query: { + search, + }, + }, + }); + const data = await res.json(); + + return data.edges.map(category => ({ + label: category.title, + value: category.id.toString(), + })); + }} + id="categoryId" + label="Category" + {...props} + /> + ), + }, + ]} +/> +``` + +## Props + +import { TypeTable } from 'fumadocs-ui/components/type-table'; + +', + default: '[]', + }, + placeholder: { + description: 'Placeholder text for the combobox input.', + type: 'string', + default: 'Select an option', + }, + searchPlaceholder: { + description: 'Placeholder text for the search input within the combobox.', + type: 'string', + default: 'Search...', + }, + }} +/> diff --git a/apps/docs/content/docs/ui/combobox.mdx b/apps/docs/content/docs/ui/combobox.mdx index 4efe4db06..74dd92dda 100644 --- a/apps/docs/content/docs/ui/combobox.mdx +++ b/apps/docs/content/docs/ui/combobox.mdx @@ -16,7 +16,43 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; ```ts -const test = 'test'; +import { z } from 'zod'; +import { AutoForm } from '@vitnode/core/components/form/auto-form'; +import { AutoFormCombobox } from '@vitnode/core/components/form/fields/combobox'; +``` + +```ts +const formSchema = z.object({ + type: z.enum(['option-one', 'option-two']), +}); +``` + +```tsx + ( + + ), + }, + ]} +/> ``` @@ -24,9 +60,138 @@ const test = 'test'; ```tsx -const test = 'test'; +'use client'; + +import * as React from 'react'; +import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'; + +import { cn } from '@vitnode/core/lib/utils'; +import { Button } from '@vitnode/core/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@vitnode/core/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@vitnode/core/components/ui/popover'; + +const frameworks = [ + { + value: 'next.js', + label: 'Next.js', + }, + { + value: 'sveltekit', + label: 'SvelteKit', + }, + { + value: 'nuxt.js', + label: 'Nuxt.js', + }, + { + value: 'remix', + label: 'Remix', + }, + { + value: 'astro', + label: 'Astro', + }, +]; + +export function ExampleCombobox() { + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + + return ( + + + + + + + + + No framework found. + + {frameworks.map(framework => ( + { + setValue(currentValue === value ? '' : currentValue); + setOpen(false); + }} + > + + {framework.label} + + ))} + + + + + + ); +} ``` + +## Props + +import { TypeTable } from 'fumadocs-ui/components/type-table'; + +', + default: '[]', + }, + placeholder: { + description: 'Placeholder text for the combobox input.', + type: 'string', + default: 'Select an option', + }, + searchPlaceholder: { + description: 'Placeholder text for the search input within the combobox.', + type: 'string', + default: 'Search...', + }, + }} +/> diff --git a/apps/web/src/locales/@vitnode/blog/en.json b/apps/web/src/locales/@vitnode/blog/en.json index 8f2d38937..65f92dfff 100644 --- a/apps/web/src/locales/@vitnode/blog/en.json +++ b/apps/web/src/locales/@vitnode/blog/en.json @@ -45,8 +45,12 @@ "title": "Create Post", "desc": "Write a new article for your blog.", "form": { - "title": "Title", - "content": "Content" + "title": { + "label": "Title", + "already_exists": "This post title already exists." + }, + "content": "Content", + "category": "Category" }, "submit": "Create Post" }, diff --git a/apps/web/src/locales/@vitnode/core/en.json b/apps/web/src/locales/@vitnode/core/en.json index 790cd9f8a..b89001b73 100644 --- a/apps/web/src/locales/@vitnode/core/en.json +++ b/apps/web/src/locales/@vitnode/core/en.json @@ -11,6 +11,8 @@ "cancel": "Cancel", "confirm": "Yes, leave" }, + "search_placeholder": "Search...", + "results_not_found": "No results found", "date": "{date, date}", "date_medium": "{date, date, medium}", "date_short": "{date, date, short}", diff --git a/apps/web/src/vitnode.config.ts b/apps/web/src/vitnode.config.ts index de613b89c..a1fa8f302 100644 --- a/apps/web/src/vitnode.config.ts +++ b/apps/web/src/vitnode.config.ts @@ -12,7 +12,6 @@ export const vitNodeConfig = buildConfig({ locales: ['en', 'pl'] as const, defaultLocale: 'en', }, - debug: true, theme: { defaultTheme: 'dark', }, diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 4f821f642..539d50a9e 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -106,6 +106,7 @@ "react-scan": "^0.3.4", "resend": "^4.5.2", "tailwind-merge": "^3.3.0", + "use-debounce": "^10.0.5", "vaul": "^1.1.2" } } diff --git a/packages/vitnode/src/components/form/fields/combobox-async.tsx b/packages/vitnode/src/components/form/fields/combobox-async.tsx new file mode 100644 index 000000000..cdcd0017e --- /dev/null +++ b/packages/vitnode/src/components/form/fields/combobox-async.tsx @@ -0,0 +1,157 @@ +import type { z } from 'zod'; + +import { useQuery } from '@tanstack/react-query'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import React from 'react'; +import { useDebouncedCallback } from 'use-debounce'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { FormControl, FormItem, FormMessage } from '@/components/ui/form'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +import type { ItemAutoFormComponentProps } from './item'; + +import { Skeleton } from '../../ui/skeleton'; +import { AutoFormDesc } from '../common/desc'; +import { AutoFormLabel } from '../common/label'; + +export function AutoFormComboboxAsync({ + label, + field, + description, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + shape: _s, + placeholder, + className, + id, + searchPlaceholder, + fetchData, + ...props +}: ItemAutoFormComponentProps & + Omit, 'role' | 'variant'> & { + description?: React.ReactNode; + fetchData: (params: { search: string }) => + | Promise< + { + label: string; + value: string; + }[] + > + | { + label: string; + value: string; + }[]; + id: string; + label?: React.ReactNode; + placeholder?: string; + searchPlaceholder?: string; + }) { + const t = useTranslations('core.global'); + const [search, setSearch] = React.useState(''); + const { data, isLoading } = useQuery({ + queryKey: [id, { search }], + queryFn: async () => { + return await fetchData({ search }); + }, + }); + + const handleChangeSearch = useDebouncedCallback((value: string) => { + setSearch(value); + }, 500); + + return ( + + {label && {label}} + + + + + + + + + + + { + handleChangeSearch(e.currentTarget.value); + }} + placeholder={searchPlaceholder ?? t('search_placeholder')} + /> + + + {isLoading ? ( +
+ + +
+ ) : ( + <> + {data?.length === 0 ? ( + {t('results_not_found')} + ) : ( + + {(data ?? []).map(({ label, value }) => ( + { + field.onChange({ + label, + value, + }); + }} + value={label} + > + {label} + + + ))} + + )} + + )} +
+
+
+
+ + {description && {description}} + +
+ ); +} diff --git a/packages/vitnode/src/components/form/fields/combobox.tsx b/packages/vitnode/src/components/form/fields/combobox.tsx index 7b1ec0cd2..626b63cc8 100644 --- a/packages/vitnode/src/components/form/fields/combobox.tsx +++ b/packages/vitnode/src/components/form/fields/combobox.tsx @@ -32,10 +32,10 @@ export function AutoFormCombobox({ field, description, shape, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - placeholder: _, + placeholder, className, labels = [], + searchPlaceholder, ...props }: ItemAutoFormComponentProps & Omit, 'role' | 'variant'> & { @@ -43,6 +43,7 @@ export function AutoFormCombobox({ label?: React.ReactNode; labels?: { label: string; value: string }[]; placeholder?: string; + searchPlaceholder?: string; }) { const t = useTranslations('core.global'); const baseValues = ( @@ -76,7 +77,7 @@ export function AutoFormCombobox({ > {field.value ? values.find(({ value }) => value === field.value)?.label - : t('select_option')} + : (placeholder ?? t('select_option'))} @@ -84,9 +85,11 @@ export function AutoFormCombobox({ - + - No language found. + {t('results_not_found')} {values.map(({ label, value }) => ( , + SelectedPath extends GetValidPathsForModule, + Method extends GetValidMethodForPath< + ModuleName, + SelectedPath, + M, + Routes, + Modules + > = GetValidMethodForPath, +>( + moduleReturn: BuildModuleReturn, + { + path, + method, + module, + args, + options, + withPagination = false, + }: FetcherParams & { + options?: Omit; + withPagination?: boolean; + }, +): Promise< + InferResponseType +> { + return coreFetcher(moduleReturn, { + path, + method, + module, + args, + options, + withPagination, + }); +} diff --git a/packages/vitnode/src/lib/fetcher.ts b/packages/vitnode/src/lib/fetcher.ts index 1604777ea..ee33935df 100644 --- a/packages/vitnode/src/lib/fetcher.ts +++ b/packages/vitnode/src/lib/fetcher.ts @@ -1,3 +1,4 @@ +import 'server-only'; import { cookies, headers } from 'next/headers'; import type { @@ -14,8 +15,8 @@ import type { InferResponseType, } from './fetcher/types'; -import { CONFIG } from './config'; -import { buildSearchParams, handleSetCookiesFetcher } from './fetcher/helpers'; +import { coreFetcher } from './fetcher/core'; +import { handleSetCookiesFetcher } from './fetcher/helpers-server'; export async function fetcher< M extends string, @@ -31,7 +32,7 @@ export async function fetcher< Modules > = GetValidMethodForPath, >( - { pluginId }: BuildModuleReturn, + moduleReturn: BuildModuleReturn, { path, method, @@ -48,66 +49,34 @@ export async function fetcher< ): Promise< InferResponseType > { - let currentPath: string = path; - - // Replace path parameters - if (args && 'params' in args && args.params) { - for (const [key, value] of Object.entries(args.params)) { - currentPath = currentPath.replaceAll(`{${key}}`, String(value)); - } - } - - // Ensure path starts with a slash - const formattedPath = currentPath.startsWith('/') - ? currentPath - : `/${currentPath}`; - - // Construct the base URL - const url = new URL( - `/api/${pluginId}/${module}${formattedPath}`, - CONFIG.backend.origin, - ); - - // Add query parameters if they exist - if (args && 'query' in args && args.query) { - const queryParams = args.query as Record; - const searchParams = buildSearchParams({ - ...args.query, - ...(withPagination && { - first: !queryParams.last ? (queryParams.first ?? '10') : undefined, - search: queryParams.search ?? '', - }), - }); - url.search = searchParams.toString(); - } - const [nextInternalHeaders, cookie] = await Promise.all([ headers(), cookies(), ]); - const response = await fetch(url, { - method: method.toUpperCase(), - headers: new Headers({ - 'Content-Type': 'application/json', + const response = await coreFetcher(moduleReturn, { + path, + method, + module, + args, + options, + withPagination, + additionalHeaders: { Cookie: cookie.toString(), ['user-agent']: nextInternalHeaders.get('user-agent') ?? 'node', ['x-forwarded-for']: nextInternalHeaders.get('x-forwarded-for') ?? '0.0.0.0', - }), - body: args && 'body' in args ? JSON.stringify(args.body) : undefined, - ...options, + }, }); - if (response.status >= 200 && response.status < 300 && allowSaveCookies) { - await handleSetCookiesFetcher(response); + // Handle cookies on server-side for successful responses + if ( + (response as Response).status >= 200 && + (response as Response).status < 300 && + allowSaveCookies + ) { + await handleSetCookiesFetcher(response as Response); } - return response as InferResponseType< - M, - Routes, - Modules, - ModuleName, - SelectedPath - >; + return response; } diff --git a/packages/vitnode/src/lib/fetcher/core.ts b/packages/vitnode/src/lib/fetcher/core.ts new file mode 100644 index 000000000..5b69ccd26 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/core.ts @@ -0,0 +1,130 @@ +import type { + BaseBuildModuleReturn, + BuildModuleReturn, +} from '@/api/lib/module'; +import type { Route } from '@/api/lib/route'; + +import type { + FetcherParams, + GetModulePaths, + GetValidMethodForPath, + GetValidPathsForModule, + InferResponseType, +} from './types'; + +import { CONFIG } from '../config'; +import { buildSearchParams } from './helpers'; + +interface CoreFetcherOptions< + M extends string, + Routes extends Route[], + Modules extends BaseBuildModuleReturn[], + ModuleName extends GetModulePaths, + SelectedPath extends GetValidPathsForModule, + Method extends GetValidMethodForPath< + ModuleName, + SelectedPath, + M, + Routes, + Modules + > = GetValidMethodForPath, +> { + additionalHeaders?: HeadersInit; + args?: FetcherParams< + M, + Routes, + Modules, + ModuleName, + SelectedPath, + Method + >['args']; + method: Method; + module: ModuleName; + options?: Omit; + path: SelectedPath; + withPagination?: boolean; +} + +export async function coreFetcher< + M extends string, + Routes extends Route[], + Modules extends BaseBuildModuleReturn[], + ModuleName extends GetModulePaths, + SelectedPath extends GetValidPathsForModule, + Method extends GetValidMethodForPath< + ModuleName, + SelectedPath, + M, + Routes, + Modules + > = GetValidMethodForPath, +>( + { pluginId }: BuildModuleReturn, + { + path, + method, + module, + args, + options, + additionalHeaders = {}, + withPagination = false, + }: CoreFetcherOptions, +): Promise< + InferResponseType +> { + let currentPath: string = path; + + // Replace path parameters + if (args && 'params' in args && args.params) { + for (const [key, value] of Object.entries( + args.params as Record, + )) { + currentPath = currentPath.replaceAll(`{${key}}`, String(value)); + } + } + + // Ensure path starts with a slash + const formattedPath = currentPath.startsWith('/') + ? currentPath + : `/${currentPath}`; + + // Construct the base URL + const url = new URL( + `/api/${pluginId}/${module}${formattedPath}`, + CONFIG.backend.origin, + ); + + // Add query parameters if they exist + if (args && 'query' in args && args.query) { + const queryParams = args.query as Record; + const searchParams = buildSearchParams({ + ...(args.query as Record), + ...(withPagination && { + first: !queryParams.last ? (queryParams.first ?? '10') : undefined, + search: queryParams.search ?? '', + }), + }); + url.search = searchParams.toString(); + } + + // Build headers + const headers = new Headers({ + 'Content-Type': 'application/json', + ...additionalHeaders, + }); + + const response = await fetch(url, { + method: method.toUpperCase(), + headers, + body: args && 'body' in args ? JSON.stringify(args.body) : undefined, + ...options, + }); + + return response as InferResponseType< + M, + Routes, + Modules, + ModuleName, + SelectedPath + >; +} diff --git a/packages/vitnode/src/lib/fetcher/helpers-server.ts b/packages/vitnode/src/lib/fetcher/helpers-server.ts new file mode 100644 index 000000000..effc127de --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/helpers-server.ts @@ -0,0 +1,24 @@ +import 'server-only'; +import { cookies } from 'next/headers'; + +import { cookieFromStringToObject } from './cookie-from-string-to-object'; + +export const handleSetCookiesFetcher = async (res: Response) => { + await Promise.all( + cookieFromStringToObject(res.headers.getSetCookie()).map(async cookie => { + const key = Object.keys(cookie)[0]; + const value = Object.values(cookie)[0]; + + if (typeof value !== 'string' || typeof key !== 'string') return; + + (await cookies()).set(key, value, { + domain: cookie.Domain, + path: cookie.Path, + expires: new Date(cookie.Expires), + secure: cookie.Secure, + httpOnly: cookie.HttpOnly, + sameSite: cookie.SameSite, + }); + }), + ); +}; diff --git a/packages/vitnode/src/lib/fetcher/helpers.ts b/packages/vitnode/src/lib/fetcher/helpers.ts index 8d154521f..d34372e0e 100644 --- a/packages/vitnode/src/lib/fetcher/helpers.ts +++ b/packages/vitnode/src/lib/fetcher/helpers.ts @@ -1,27 +1,3 @@ -import { cookies } from 'next/headers'; - -import { cookieFromStringToObject } from './cookie-from-string-to-object'; - -export const handleSetCookiesFetcher = async (res: Response) => { - await Promise.all( - cookieFromStringToObject(res.headers.getSetCookie()).map(async cookie => { - const key = Object.keys(cookie)[0]; - const value = Object.values(cookie)[0]; - - if (typeof value !== 'string' || typeof key !== 'string') return; - - (await cookies()).set(key, value, { - domain: cookie.Domain, - path: cookie.Path, - expires: new Date(cookie.Expires), - secure: cookie.Secure, - httpOnly: cookie.HttpOnly, - sameSite: cookie.SameSite, - }); - }), - ); -}; - export const buildSearchParams = (query: Record) => { const searchParams = new URLSearchParams(); diff --git a/packages/vitnode/src/lib/helpers/auto-form.test.ts b/packages/vitnode/src/lib/helpers/auto-form.test.ts index 3a36cde43..7d26ceea5 100644 --- a/packages/vitnode/src/lib/helpers/auto-form.test.ts +++ b/packages/vitnode/src/lib/helpers/auto-form.test.ts @@ -151,6 +151,55 @@ describe('auto-form helpers', () => { expect(defaults).toEqual({}); }); + it('should handle complex default values with conditional logic', () => { + const data = { + category: { + id: 123, + title: 'Technology', + }, + }; + + const schema = z.object({ + title: z.string().default('Default Title'), + categoryId: z.object({ value: z.string(), label: z.string() }).default( + data?.category + ? { + value: data.category.id.toString(), + label: data.category.title, + } + : { value: '', label: '' }, + ), + }); + + const defaults = getDefaultValues(schema); + expect(defaults).toEqual({ + title: 'Default Title', + categoryId: { + value: '123', + label: 'Technology', + }, + }); + }); + + it('should handle complex default values with falsy data', () => { + // Test with a simpler approach - using a function that returns the default value + const getDefaultCategoryId = () => ({ value: '', label: '' }); + + const schema = z.object({ + categoryId: z + .object({ value: z.string(), label: z.string() }) + .default(getDefaultCategoryId()), + }); + + const defaults = getDefaultValues(schema); + expect(defaults).toEqual({ + categoryId: { + value: '', + label: '', + }, + }); + }); + it('should return empty object for schema without shape', () => { const schema = z.string() as unknown as z.ZodObject; const defaults = getDefaultValues(schema); diff --git a/packages/vitnode/src/lib/helpers/auto-form.ts b/packages/vitnode/src/lib/helpers/auto-form.ts index bea947907..c69f8735a 100644 --- a/packages/vitnode/src/lib/helpers/auto-form.ts +++ b/packages/vitnode/src/lib/helpers/auto-form.ts @@ -63,22 +63,50 @@ export const getBaseType = (schema: z.ZodTypeAny): string => { }; /** - * Search for a "ZodDefult" in the Zod stack and return its value. + * Search for a "ZodDefault" in the Zod stack and return its value. */ export function getDefaultValueInZodStack(schema: z.ZodTypeAny): unknown { + // Check if this is a ZodDefault and return its value if (schema._def.typeName === z.ZodFirstPartyTypeKind.ZodDefault) { - return (schema as z.ZodDefault)._def.defaultValue(); + const defaultValue = ( + schema as z.ZodDefault + )._def.defaultValue(); + // If defaultValue is a function, call it to get the actual value + + return typeof defaultValue === 'function' ? defaultValue() : defaultValue; } - if ('innerType' in schema._def) { - return getDefaultValueInZodStack(schema._def.innerType as z.ZodTypeAny); + // Traverse through ZodEffects (like refinements) - check this first as it wraps the actual schema + if (schema._def.typeName === z.ZodFirstPartyTypeKind.ZodEffects) { + return getDefaultValueInZodStack( + (schema._def as { schema: z.ZodTypeAny }).schema, + ); } + + // Traverse through other types that have schema property if ('schema' in schema._def) { return getDefaultValueInZodStack( (schema._def as { schema: z.ZodTypeAny }).schema, ); } + // Traverse through ZodOptional, ZodNullable, etc. + if ('innerType' in schema._def) { + return getDefaultValueInZodStack(schema._def.innerType as z.ZodTypeAny); + } + + // Traverse through ZodArray's element type + if ( + 'type' in schema._def && + schema._def.typeName === z.ZodFirstPartyTypeKind.ZodArray + ) { + const innerDefault = getDefaultValueInZodStack( + schema._def.type as z.ZodTypeAny, + ); + + return innerDefault !== undefined ? [innerDefault] : undefined; + } + return undefined; } @@ -97,28 +125,23 @@ export function getDefaultValues>( for (const key of Object.keys(shape as object)) { const item = shape[key] as z.ZodAny; - if (getBaseType(item) === 'ZodObject') { + // First, try to get any default value from the current item (including nested defaults) + const defaultValue = getDefaultValueInZodStack(item); + + if (defaultValue !== undefined) { + (defaultValues as Record)[key] = defaultValue; + } else if (getBaseType(item) === 'ZodObject') { + // Only recurse into object shape if there's no default value at the current level const baseSchema = getBaseSchema(item); if (baseSchema && 'shape' in baseSchema._def) { const defaultItems = getDefaultValues( baseSchema as unknown as z.ZodObject, ); - if (defaultItems !== null) { - const obj: Record = {}; - - for (const defaultItemKey of Object.keys(defaultItems)) { - obj[defaultItemKey] = defaultItems[defaultItemKey]; - (defaultValues as Record)[key] = obj; - } + if (defaultItems && Object.keys(defaultItems).length > 0) { + (defaultValues as Record)[key] = defaultItems; } } - } else { - const defaultValue = getDefaultValueInZodStack(item); - - if (defaultValue !== undefined) { - (defaultValues as Record)[key] = defaultValue; - } } } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 790cd9f8a..b89001b73 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -11,6 +11,8 @@ "cancel": "Cancel", "confirm": "Yes, leave" }, + "search_placeholder": "Search...", + "results_not_found": "No results found", "date": "{date, date}", "date_medium": "{date, date, medium}", "date_short": "{date, date, short}", diff --git a/packages/vitnode/src/views/admin/views/core/test.tsx b/packages/vitnode/src/views/admin/views/core/test.tsx index edef1a55d..9fd56557e 100644 --- a/packages/vitnode/src/views/admin/views/core/test.tsx +++ b/packages/vitnode/src/views/admin/views/core/test.tsx @@ -45,7 +45,7 @@ export const TestView = () => { component: props => ( @@ -144,9 +144,10 @@ export const TestView = () => { }, ]} formSchema={formSchema} - onSubmit={values => { + onSubmit={async values => { // eslint-disable-next-line no-console console.log('Form submitted', values); + await new Promise(resolve => setTimeout(resolve, 3000)); }} /> diff --git a/plugins/blog/src/api/modules/categories/routes/create.route.ts b/plugins/blog/src/api/modules/categories/routes/create.route.ts index 73d1f0864..78c718afe 100644 --- a/plugins/blog/src/api/modules/categories/routes/create.route.ts +++ b/plugins/blog/src/api/modules/categories/routes/create.route.ts @@ -8,10 +8,7 @@ import { CONFIG_PLUGIN } from '@/const'; import { blog_categories } from '@/database/categories'; export const zodCreateCategorySchema = z.object({ - title: z - .string() - .min(3, 'Title must be at least 3 characters long') - .max(100, 'Title must not exceed 100 characters'), + title: z.string(), }); const zodCategoryResponseSchema = z.object({ diff --git a/plugins/blog/src/api/modules/categories/routes/get.route.ts b/plugins/blog/src/api/modules/categories/routes/get.route.ts index 7464deaaf..3a44ad14a 100644 --- a/plugins/blog/src/api/modules/categories/routes/get.route.ts +++ b/plugins/blog/src/api/modules/categories/routes/get.route.ts @@ -5,6 +5,7 @@ import { zodPaginationPageInfo, zodPaginationQuery, } from '@vitnode/core/api/lib/with-pagination'; +import { and, ilike } from 'drizzle-orm'; import { CONFIG_PLUGIN } from '@/const'; import { blog_categories } from '@/database/categories'; @@ -25,6 +26,7 @@ export const categoriesRoute = buildRoute({ query: zodPaginationQuery.extend({ order: z.enum(['asc', 'desc']).optional(), orderBy: z.enum(['updatedAt']).optional(), + search: z.string().optional(), }), }, responses: { @@ -50,14 +52,25 @@ export const categoriesRoute = buildRoute({ query, }, primaryCursor: blog_categories.id, - query: async ({ limit, where, orderBy }) => - await c + query: async ({ limit, where, orderBy }) => { + const searchCondition = query.search + ? ilike(blog_categories.title, `%${query.search}%`) + : undefined; + + const combinedWhere = searchCondition + ? where + ? and(where, searchCondition) + : searchCondition + : where; + + return await c .get('db') .select() .from(blog_categories) - .where(where) + .where(combinedWhere) .orderBy(orderBy) - .limit(limit), + .limit(limit); + }, table: blog_categories, orderBy: { column: query.orderBy diff --git a/plugins/blog/src/api/modules/posts/routes/create.route.ts b/plugins/blog/src/api/modules/posts/routes/create.route.ts index c35bca20f..6c297be2e 100644 --- a/plugins/blog/src/api/modules/posts/routes/create.route.ts +++ b/plugins/blog/src/api/modules/posts/routes/create.route.ts @@ -13,7 +13,7 @@ export const zodCreatePostSchema = z.object({ .string() .min(3, 'Title must be at least 3 characters long') .max(255, 'Title must not exceed 255 characters'), - content: z.string().min(10, 'Content must be at least 10 characters long'), + content: z.string(), categoryId: z.number(), }); diff --git a/plugins/blog/src/api/modules/posts/routes/get.route.ts b/plugins/blog/src/api/modules/posts/routes/get.route.ts index 3f0675645..a3054fddc 100644 --- a/plugins/blog/src/api/modules/posts/routes/get.route.ts +++ b/plugins/blog/src/api/modules/posts/routes/get.route.ts @@ -11,7 +11,7 @@ import { CONFIG_PLUGIN } from '@/const'; import { blog_categories } from '@/database/categories'; import { blog_posts } from '@/database/posts'; -const zodPostSchema = z.object({ +export const zodPostSchema = z.object({ id: z.number(), title: z.string(), titleSeo: z.string(), diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 8f2d38937..65f92dfff 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -45,8 +45,12 @@ "title": "Create Post", "desc": "Write a new article for your blog.", "form": { - "title": "Title", - "content": "Content" + "title": { + "label": "Title", + "already_exists": "This post title already exists." + }, + "content": "Content", + "category": "Category" }, "submit": "Create Post" }, diff --git a/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx b/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx index ec90cac3d..388ec66b5 100644 --- a/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx +++ b/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx @@ -23,14 +23,7 @@ export const CreateEditActionCategoriesAdmin = ({ const { push } = useRouter(); const pathname = usePathname(); const formSchema = z.object({ - title: z - .string() - .min(3, { - message: tCore('field_min_length', { - min: 3, - }), - }) - .default(data?.title ?? ''), + title: z.string().default(data?.title ?? ''), }); const onSubmit = async ( diff --git a/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx b/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx index 1ce894f85..b9a20a85b 100644 --- a/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx +++ b/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx @@ -1,18 +1,32 @@ +import type { UseFormReturn } from 'react-hook-form'; + import { AutoForm } from '@vitnode/core/components/form/auto-form'; +import { AutoFormComboboxAsync } from '@vitnode/core/components/form/fields/combobox-async'; import { AutoFormInput } from '@vitnode/core/components/form/fields/input'; import { AutoFormTextarea } from '@vitnode/core/components/form/fields/textarea'; +import { useDialog } from '@vitnode/core/components/ui/dialog'; +import { fetcherClient } from '@vitnode/core/lib/fetcher-client'; +import { usePathname, useRouter } from '@vitnode/core/lib/navigation'; import { useTranslations } from 'next-intl'; +import { toast } from 'sonner'; import { z } from 'zod'; -import type { zodCreatePostSchema } from '@/api/modules/posts/routes/create.route'; +import type { zodPostSchema } from '@/api/modules/posts/routes/get.route'; + +import { categoriesModule } from '@/api/modules/categories/categories.module'; + +import { createMutationApi, editMutationApi } from './mutation-api'; export const CreateEditActionPostsAdmin = ({ data, }: { - data?: z.infer & { id?: number }; + data?: z.infer & { id?: number }; }) => { const t = useTranslations('@vitnode/blog.admin.posts'); const tCore = useTranslations('core.global.errors'); + const { setOpen } = useDialog(); + const { push } = useRouter(); + const pathname = usePathname(); const formSchema = z.object({ title: z .string() @@ -22,18 +36,100 @@ export const CreateEditActionPostsAdmin = ({ }), }) .default(data?.title ?? ''), - content: z.string().min(1, { - message: tCore('field_required'), - }), + content: z.string().default(data?.content ?? ''), + categoryId: z + .object({ value: z.string(), label: z.string() }) + .refine(value => value.value !== '', { + message: tCore('field_required'), + }) + .default( + data?.category + ? { value: data.category.id.toString(), label: data.category.title } + : { value: '', label: '' }, + ), }); + const onSubmit = async ( + values: z.infer, + form: UseFormReturn>, + ) => { + let error = ''; + if (data?.id) { + const mutation = await editMutationApi({ + id: data.id, + ...values, + categoryId: parseInt(values.categoryId.value, 10), + }); + + if (mutation?.error) { + error = mutation.error; + } + } else { + const mutation = await createMutationApi({ + ...values, + categoryId: parseInt(values.categoryId.value, 10), + }); + + if (mutation?.error) { + error = mutation.error; + } + } + + if (error) { + if (error.includes('already exists')) { + form.setError('title', { + type: 'manual', + message: t('create.form.title.already_exists'), + }); + + return; + } + + toast.error(tCore('title'), { + description: tCore('internal_server_error'), + }); + + return; + } + setOpen?.(false); + push(pathname); + }; + return ( ( - + + ), + }, + { + id: 'categoryId', + component: props => ( + { + const res = await fetcherClient(categoriesModule, { + path: '/', + method: 'get', + module: 'categories', + args: { + query: { + search, + }, + }, + }); + const data = await res.json(); + + return data.edges.map(category => ({ + label: category.title, + value: category.id.toString(), + })); + }} + id="categoryId" + label={t('create.form.category')} + {...props} + /> ), }, { @@ -48,6 +144,7 @@ export const CreateEditActionPostsAdmin = ({ }, ]} formSchema={formSchema} + onSubmit={onSubmit} submitButtonProps={{ children: t(`${data ? 'edit' : 'create'}.submit`), }} diff --git a/plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.ts b/plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.ts new file mode 100644 index 000000000..9ef413f39 --- /dev/null +++ b/plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.ts @@ -0,0 +1,58 @@ +'use server'; + +import type { z } from 'zod'; + +import { fetcher } from '@vitnode/core/lib/fetcher'; +import { revalidatePath } from 'next/cache'; + +import type { zodCreatePostSchema } from '@/api/modules/posts/routes/create.route'; + +import { postsModule } from '@/api/modules/posts/posts.module'; + +export const createMutationApi = async ( + body: z.infer, +) => { + const res = await fetcher(postsModule, { + method: 'post', + module: 'posts', + path: '/', + args: { + body, + }, + }); + + if (res.status !== 201) { + return { error: await res.text() }; + } + + revalidatePath( + '/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts', + 'page', + ); +}; + +export const editMutationApi = async ({ + id, + ...body +}: z.infer & { id: number }) => { + const res = await fetcher(postsModule, { + method: 'put', + module: 'posts', + path: '/{id}', + args: { + params: { + id, + }, + body, + }, + }); + + if (res.status !== 200) { + return { error: await res.text() }; + } + + revalidatePath( + '/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts', + 'page', + ); +}; diff --git a/plugins/blog/src/views/admin/posts/posts-admin-view.tsx b/plugins/blog/src/views/admin/posts/posts-admin-view.tsx index e4850b4d2..1dddc2b39 100644 --- a/plugins/blog/src/views/admin/posts/posts-admin-view.tsx +++ b/plugins/blog/src/views/admin/posts/posts-admin-view.tsx @@ -6,6 +6,7 @@ import { getTranslations } from 'next-intl/server'; import { postsModule } from '@/api/modules/posts/posts.module'; import { DeleteAction } from './row-actions/delete/delete-action'; +import { EditAction } from './row-actions/edit-action'; export const PostsAdminView = async ({ searchParams, @@ -56,7 +57,12 @@ export const PostsAdminView = async ({ id: 'actions', label: '', className: 'w-10', - cell: ({ row }) => , + cell: ({ row }) => ( + <> + + + + ), }, ]} edges={data.edges.map(edge => ({ ...edge }))} diff --git a/plugins/blog/src/views/admin/posts/row-actions/edit-action.tsx b/plugins/blog/src/views/admin/posts/row-actions/edit-action.tsx new file mode 100644 index 000000000..4b177ba97 --- /dev/null +++ b/plugins/blog/src/views/admin/posts/row-actions/edit-action.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { Button } from '@vitnode/core/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@vitnode/core/components/ui/dialog'; +import { Loader } from '@vitnode/core/components/ui/loader'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@vitnode/core/components/ui/tooltip'; +import { PencilIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import React from 'react'; + +const CreateEditActionPostsAdmin = React.lazy(async () => + import('../actions/create-edit/create-edit').then(mod => ({ + default: mod.CreateEditActionPostsAdmin, + })), +); + +export const EditAction = ( + props: Required>, +) => { + const t = useTranslations('@vitnode/blog.admin.posts.edit'); + + return ( + + + + + + + + + {t('title')} + + + + + + {t('title')} + {props.data.title} + + + }> + + + + + ); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 476604fad..aabc801bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,9 @@ importers: tailwind-merge: specifier: ^3.3.0 version: 3.3.0 + use-debounce: + specifier: ^10.0.5 + version: 10.0.5(react@19.1.0) vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -6348,6 +6351,12 @@ packages: '@types/react': optional: true + use-debounce@10.0.5: + resolution: {integrity: sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + react: '*' + use-intl@4.1.0: resolution: {integrity: sha512-mQvDYFvoGn+bm/PWvlQOtluKCknsQ5a9F1Cj0hMfBjMBVTwnOqLPd6srhjvVdEQEQFVyHM1PfyifKqKYb11M9Q==} peerDependencies: @@ -13162,6 +13171,10 @@ snapshots: optionalDependencies: '@types/react': 19.1.6 + use-debounce@10.0.5(react@19.1.0): + dependencies: + react: 19.1.0 + use-intl@4.1.0(react@19.1.0): dependencies: '@formatjs/fast-memoize': 2.2.7