diff --git a/.env.development b/.env.development index 3b9e7b2..8390f66 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,3 @@ -VITE_API_BASE_URL=http://localhost:8000/dev/api -VITE_ENV=dev \ No newline at end of file +VITE_API_BASE_URL=https://be.starters.rayonstudios.com/api/v1 +VITE_ENV=dev +VITE_HCAPTCHA_SITE_KEY=cd86c190-7a30-4042-9468-018cd91ef63c \ No newline at end of file diff --git a/.env.production b/.env.production index 57b59aa..ba57d70 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,3 @@ -VITE_API_BASE_URL=http://localhost:8000/api -VITE_ENV=production \ No newline at end of file +VITE_API_BASE_URL=https://be.starters.rayonstudios.com/api +VITE_ENV=production +VITE_HCAPTCHA_SITE_KEY=cd86c190-7a30-4042-9468-018cd91ef63c \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 227d154..eddf588 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -15,5 +15,10 @@ module.exports = { "warn", { allowConstantExport: true }, ], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/ban-ts-comment": "off", + "no-useless-escape": "off", + "no-empty": "off", }, }; diff --git a/.firebaserc b/.firebaserc index c67b671..d01250d 100644 --- a/.firebaserc +++ b/.firebaserc @@ -1,16 +1,16 @@ { "targets": { - "applied-abbey-378009": { + "rayon-gcp-starter": { "hosting": { "prod": [ - "rayon-react-starter" + "rayon-gcp-starter" ] } }, - "physikomatics-be": { + "rayon-gcp-starter": { "hosting": { "dev": [ - "rayon-react-starter-dev" + "rayon-gcp-starter" ] } } diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml new file mode 100644 index 0000000..6250fc9 --- /dev/null +++ b/.github/workflows/dev-deploy.yml @@ -0,0 +1,25 @@ +name: Deploy to Firebase Hosting - Dev +"on": + push: + branches: + - dev + workflow_dispatch: + +jobs: + build_and_deploy: + permissions: write-all + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - run: yarn install --frozen-lockfile && npm run build:dev + env: + CI: false + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ secrets.SECRETJSON_DEV }}" + channelId: live + projectId: rayon-gcp-starter + target: dev diff --git a/.github/workflows/firebase-hosting-merge-dev.yml b/.github/workflows/firebase-hosting-merge-dev.yml deleted file mode 100644 index f06361e..0000000 --- a/.github/workflows/firebase-hosting-merge-dev.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Deploy to Firebase Hosting on merge (dev) -"on": - push: - branches: - - dev -jobs: - build_and_deploy: - permissions: write-all - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - persist-credentials: false - - - name: Reconfigure git to use HTTP authentication - run: > - git config --global url."https://github.com/".insteadOf - ssh://git@github.com/ - - - uses: actions/setup-node@v4 - with: - node-version: 18 - - - run: yarn install --frozen-lockfile && npm run build:dev - env: - CI: false - - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: "${{ secrets.GITHUB_TOKEN }}" - firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_DEV }}" - channelId: live - projectId: physikomatics-be - target: dev diff --git a/.github/workflows/firebase-hosting-merge-main.yml b/.github/workflows/firebase-hosting-merge-main.yml deleted file mode 100755 index 25c1934..0000000 --- a/.github/workflows/firebase-hosting-merge-main.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Deploy to Firebase Hosting on merge (main) -"on": - push: - branches: - - main -jobs: - build_and_deploy: - permissions: write-all - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - persist-credentials: false - - - name: Reconfigure git to use HTTP authentication - run: > - git config --global url."https://github.com/".insteadOf - ssh://git@github.com/ - - - uses: actions/setup-node@v4 - with: - node-version: 18 - - - run: yarn install --frozen-lockfile && npm run build:prod - env: - CI: false - - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: "${{ secrets.GITHUB_TOKEN }}" - firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_PROD }}" - channelId: live - projectId: applied-abbey-378009 - target: prod diff --git a/.github/workflows/pr-preview-dev.yml b/.github/workflows/pr-preview-dev.yml new file mode 100644 index 0000000..548c71d --- /dev/null +++ b/.github/workflows/pr-preview-dev.yml @@ -0,0 +1,23 @@ +name: Preview deploy to Firebase Hosting on PR (dev) +on: + pull_request: + branches: + - dev + +jobs: + build_and_preview: + permissions: write-all + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - run: yarn install --frozen-lockfile && npm run build:dev + env: + CI: false + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ secrets.SECRETJSON_DEV }}" + projectId: rayon-gcp-starter + target: dev diff --git a/.github/workflows/pr-preview-prod.yml b/.github/workflows/pr-preview-prod.yml new file mode 100644 index 0000000..2c6c521 --- /dev/null +++ b/.github/workflows/pr-preview-prod.yml @@ -0,0 +1,23 @@ +name: Preview deploy to Firebase Hosting on PR (main) +on: + pull_request: + branches: + - main + +jobs: + build_and_preview: + permissions: write-all + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - run: yarn install --frozen-lockfile && npm run build:prod + env: + CI: false + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ secrets.SECRETJSON_PROD }}" + projectId: rayon-gcp-starter + target: prod diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml new file mode 100755 index 0000000..5693f22 --- /dev/null +++ b/.github/workflows/prod-deploy.yml @@ -0,0 +1,25 @@ +name: Deploy to Firebase Hosting - Prod +"on": + push: + branches: + - main + workflow_dispatch: + +jobs: + build_and_deploy: + permissions: write-all + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - run: yarn install --frozen-lockfile && npm run build:dev + env: + CI: false + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ secrets.SECRETJSON_PROD }}" + channelId: live + projectId: rayon-gcp-starter + target: prod diff --git a/README.md b/README.md index 53b753d..ace0fee 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rayon React Starter is an opinionated starter kit designed to scaffold React projects quickly with a comprehensive and well-structured environment. Built with a modern tech stack and batteries included, it helps you start building your project in no time. -[Live Demo](https://rayon-react-starter.web.app/) +[Live Demo](https://fe.starters.rayonstudios.com/) ## Tech Stack @@ -12,7 +12,8 @@ Rayon React Starter is an opinionated starter kit designed to scaffold React pro - **Redux Toolkit**: State management with slices and thunks - **Ant Design**: Elegant and consistent UI components - **Tailwind CSS**: Utility-first CSS framework for rapid UI development -- **Axios**: Promise-based HTTP client for making requests +- **OpenAPI Fetch**: Type-safe API client generated from OpenAPI schemas +- **Axios**: Promise-based HTTP client for making requests (used as the fetch implementation for OpenAPI Fetch) ## Features @@ -29,6 +30,8 @@ Rayon React Starter is an opinionated starter kit designed to scaffold React pro - ๐Ÿ›  **Built-in Utilities**: Handy utility functions, hooks, and components to cover common use cases - ๐Ÿงน **Prettier and ESLint Config**: Enforce code style and quality with Prettier and ESLint configurations - ๐Ÿš€ **CI/CD with Firebase Hosting**: Continuous Integration and Deployment setup using Firebase Hosting and Github Actions for seamless deployment +- ๐Ÿ“Š **ServerPaginatedTable**: Automatic server-side pagination, filtering, and sorting for data tables +- ๐Ÿ“ **OpenAPI Type Generation**: Automatic type generation from OpenAPI schemas for type-safe API calls ## Getting Started @@ -44,7 +47,7 @@ Ensure you have the following installed: 1. Clone the repository: ```bash git clone https://github.com/rayonstudios/rayon_react_starter - cd rayon-react-starter + cd rayon_react_starter ``` 2. Install dependencies: ```bash @@ -57,26 +60,102 @@ Ensure you have the following installed: The application should now be running on http://localhost:5173 -## Folde Structure +## Environment Configuration + +The project supports different environments: + +```bash +# Development mode +yarn dev # Run with development configuration +yarn build:dev # Build with development configuration + +# Production mode +yarn prod # Run with production configuration +yarn build:prod # Build with production configuration +``` + +## API Integration + +### OpenAPI Type Generation + +The project includes a script to generate TypeScript types from an OpenAPI schema: + +```bash +yarn gen-api-types:dev # Generate types from development API +yarn gen-api-types:prod # Generate types from production API +``` + +The script fetches the OpenAPI schema from the API endpoint (configured via `VITE_API_BASE_URL` in the environment files) and generates TypeScript types in `src/lib/types/openapi-fetch.d.ts`. + +### Making API Calls + +The project uses `openapi-fetch` with Axios as the fetch implementation for type-safe API calls: + +```typescript +import apiClient, { withApiResponseHandling } from '@/lib/openapi-fetch.config'; + +// Example API call with type safety +const { data } = await withApiResponseHandling( + apiClient.GET('/api/users/{id}', { + params: { path: { id: userId } } + }) +); +``` + +## Folder Structure ```bash โ”œโ”€โ”€ lib/ # Reusable, feature-independent code +โ”‚ โ”œโ”€โ”€ components/ # Shared UI components +โ”‚ โ”œโ”€โ”€ hooks/ # Custom React hooks +โ”‚ โ”œโ”€โ”€ types/ # TypeScript type definitions +โ”‚ โ””โ”€โ”€ utils/ # Utility functions โ”œโ”€โ”€ modules/ # Feature-dependent code +โ”‚ โ”œโ”€โ”€ auth/ # Authentication related components and logic +โ”‚ โ””โ”€โ”€ ... # Other feature modules โ”œโ”€โ”€ pages/ # Page components -โ””โ”€โ”€ ... +โ””โ”€โ”€ scripts/ # Build and utility scripts ``` - Use **snake_case** for file and folder names to maintain consistency. - Feature-based folder structure ensures a clean separation of concerns for better scalability and maintainability. +## Common Components + +### ServerPaginatedTable + +`ServerPaginatedTable` is a powerful component for handling server-side paginated data: + +```typescript +import { ServerPaginatedTable } from '@/lib/components/table/server_paginated_table'; + +// Example usage + +``` + +The component automatically handles: +- Pagination +- Sorting +- Filtering +- Loading states +- Error handling + ## Best Practices - Reusable code should generally reside in the `lib` folder. - Feature-dependent code should be organized under `modules`. -Pages components should be placed under the `pages` folder. +- Pages components should be placed under the `pages` folder. +- Use TypeScript for type safety across the application. +- Follow the established patterns for state management with Redux Toolkit. +- Use OpenAPI Fetch for API calls to ensure type safety. +- Use ServerPaginatedTable for displaying data from API endpoints with pagination. ## Roadmap - [ ] Detailed Developer Documentation - [ ] Add unit, integration and e2e tests support -- [ ] Convert to a modular CLI based tools +- [ ] Convert to a modular CLI based tool diff --git a/index.html b/index.html index c00a48b..47e70af 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -15,7 +15,7 @@ Rayon React Starter diff --git a/package.json b/package.json index 282b095..d46f382 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,16 @@ { "name": "rayon_react_starter", "private": true, - "version": "0.0.3", + "version": "2.1", + "description": "Rayon React Starter is an opinionated starter kit designed to scaffold React projects quickly with a comprehensive and well-structured environment. Built with a modern tech stack and batteries included, it helps you start building your project in no time.", "type": "module", "scripts": { - "dev": "vite", - "prod": "vite --mode production", - "build:prod": "tsc && vite build", - "build:dev": "tsc && vite build --mode development", + "dev": "npm run gen-types:dev && vite", + "prod": "npm run gen-types:prod && vite --mode production", + "build:prod": "npm run gen-types:prod && tsc && vite build", + "build:dev": "npm run gen-types:dev && tsc && vite build --mode development", + "gen-types:dev": "tsx scripts/gen-openapi-types.ts development", + "gen-types:prod": "tsx scripts/gen-openapi-types.ts production", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "watch:types": "tsc -w", @@ -17,6 +20,7 @@ "@ahooksjs/use-url-state": "^3.5.1", "@ant-design/colors": "^7.0.2", "@ant-design/icons": "^5.2.6", + "@hcaptcha/react-hcaptcha": "^1.12.0", "@reduxjs/toolkit": "^2.1.0", "@types/lodash": "^4.17.0", "@types/qs": "^6.9.16", @@ -28,6 +32,7 @@ "dayjs": "^1.11.10", "i18next": "^23.10.1", "lucide": "^0.447.0", + "openapi-fetch": "^0.13.5", "qs": "^6.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -40,6 +45,7 @@ }, "devDependencies": { "@types/color": "^3.0.6", + "@types/node": "^22.14.0", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@typescript-eslint/eslint-plugin": "^6.14.0", @@ -52,10 +58,12 @@ "eslint-plugin-react-refresh": "^0.4.5", "husky": ">=6", "lint-staged": ">=10", + "openapi-typescript": "^7.6.1", "postcss": "^8.4.33", "prettier": "^3.3.3", "tailwindcss": "^3.4.1", - "typescript": "^5.2.2", + "tsx": "^4.19.3", + "typescript": "^5.8.3", "vite": "^5.0.8", "vite-tsconfig-paths": "^4.3.2" }, diff --git a/scripts/gen-openapi-types.ts b/scripts/gen-openapi-types.ts new file mode 100644 index 0000000..3de87d9 --- /dev/null +++ b/scripts/gen-openapi-types.ts @@ -0,0 +1,143 @@ +#!/usr/bin/env tsx +import * as fs from "fs"; +import * as http from "http"; +import * as https from "https"; +import openapiTS, { astToString } from "openapi-typescript"; +import * as path from "path"; +import ts from "typescript"; +import { URL } from "url"; + +const DATE = ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier("Date") +); // `Date` +const FILE = ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier("File") +); // `Blob +const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null` + +// Get environment type from command line arguments +const envType = process.argv[2]; + +if (!envType) { + console.error( + "Usage: tsx gen-openapi-types.ts (development|production)" + ); + process.exit(1); +} + +const envFile = path.join(process.cwd(), `.env.${envType}`); + +// Check if the environment file exists +if (!fs.existsSync(envFile)) { + console.error(`Error: Environment file .env.${envType} does not exist`); + process.exit(1); +} + +// Read the environment file +const envContent = fs.readFileSync(envFile, "utf8"); + +// Extract API base URL from environment file +const apiBaseUrlMatch = envContent.match(/VITE_API_BASE_URL=(.+)/); +const apiBaseUrl = apiBaseUrlMatch ? apiBaseUrlMatch[1].trim() : null; + +if (!apiBaseUrl) { + console.error(`Error: VITE_API_BASE_URL not found in .env.${envType}`); + process.exit(1); +} + +console.log(`Using API base URL: ${apiBaseUrl}`); + +// Fetch OpenAPI schema +const openApiUrl = `${apiBaseUrl}/openapi.json`; +console.log(`Fetching OpenAPI schema from: ${openApiUrl}`); + +const tempFilePath = path.join(process.cwd(), "openapi.json"); +const outputDir = path.join(process.cwd(), "src", "lib", "types"); +const outputPath = path.join(outputDir, "openapi-fetch.d.ts"); + +// Create output directory if it doesn't exist +if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); +} + +// Function to download the OpenAPI schema +function downloadSchema(): Promise { + return new Promise((resolve, reject) => { + const urlObj = new URL(openApiUrl); + const client = urlObj.protocol === "https:" ? https : http; + + const req = client.get(openApiUrl, (res) => { + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`Failed to fetch OpenAPI schema: ${res.statusCode}`)); + return; + } + + const fileStream = fs.createWriteStream(tempFilePath); + res.pipe(fileStream); + + fileStream.on("finish", () => { + fileStream.close(); + resolve(); + }); + }); + + req.on("error", (error) => { + reject(error); + }); + }); +} + +// Main execution +async function main(): Promise { + try { + // Download schema + await downloadSchema(); + + // Generate TypeScript types + console.log("Generating TypeScript types"); + const openApiSchema = fs.readFileSync(tempFilePath, "utf8"); + const ast = await openapiTS(openApiSchema, { + transform(schemaObject) { + // handle date-time type + if (schemaObject.format === "date-time") { + return { + schema: schemaObject.nullable + ? ts.factory.createUnionTypeNode([DATE, NULL]) + : DATE, + questionToken: true, + }; + } + + // handle File type + if (schemaObject.format === "binary") { + return { + schema: schemaObject.nullable + ? ts.factory.createUnionTypeNode([FILE, NULL]) + : FILE, + questionToken: true, + }; + } + }, + }); + const contents = astToString(ast); + fs.writeFileSync(outputPath, contents); + + // Clean up + fs.unlinkSync(tempFilePath); + + console.log( + `TypeScript types successfully generated at src/lib/types/openapi-fetch.d.ts` + ); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + + // Clean up temp file if it exists + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + process.exit(1); + } +} + +main(); diff --git a/src/lib/axios.config.ts b/src/lib/axios.config.ts index 3b56446..fd6c2be 100644 --- a/src/lib/axios.config.ts +++ b/src/lib/axios.config.ts @@ -17,8 +17,8 @@ const axios = axiosApi.create({ axios.interceptors.request.use((reqConfig) => { const token = localStorage.getItem("accessToken"); - if (token && !reqConfig.headers.Authorization) { - reqConfig.headers.Authorization = `Bearer ${token}`; + if (token && !reqConfig.headers.toJSON().authorization) { + reqConfig.headers.authorization = `Bearer ${token}`; } return reqConfig; }); @@ -45,10 +45,19 @@ axios.interceptors.response.use(undefined, async (error) => { }${error.message}`; error.message = msg; - if (error.response?.status === 401) { - if (!["/auth/login", "/auth/refresh-token"].includes(error.config.url!)) { + if ( + store.getState().auth.status !== "unauthenticated" && + error.response?.status === 401 + ) { + if ( + !["auth/login", "auth/refresh"].some((url) => + error.config.url.includes(url) + ) + ) { // retry request after refreshing token - const { accessToken, refreshToken } = await authService.refreshToken(); + const { accessToken, refreshToken } = await authService.refreshToken( + localStorage.getItem("refreshToken")! + ); localStorage.setItem("accessToken", accessToken); localStorage.setItem("refreshToken", refreshToken); error.config.headers.Authorization = `Bearer ${accessToken}`; diff --git a/src/lib/components/captcha/captcha.tsx b/src/lib/components/captcha/captcha.tsx new file mode 100644 index 0000000..d4545bb --- /dev/null +++ b/src/lib/components/captcha/captcha.tsx @@ -0,0 +1,17 @@ +import HCaptcha from "@hcaptcha/react-hcaptcha"; +import React from "react"; + +interface Props { + onChange?: (token: string) => void; +} + +const Captcha: React.FC = ({ onChange }) => { + return ( + onChange?.(token)} + /> + ); +}; + +export default Captcha; diff --git a/src/lib/components/custom-modal/custom-modal.tsx b/src/lib/components/custom-modal/custom-modal.tsx index 301aff5..04af1ab 100644 --- a/src/lib/components/custom-modal/custom-modal.tsx +++ b/src/lib/components/custom-modal/custom-modal.tsx @@ -7,7 +7,7 @@ interface Props { } const CustomModal: React.FC> = ({ - loading, + loading = false, submitLoading, ...props }) => { diff --git a/src/lib/components/ellipsis-text/ellipsis-text.tsx b/src/lib/components/ellipsis-text/ellipsis-text.tsx new file mode 100644 index 0000000..619ee0e --- /dev/null +++ b/src/lib/components/ellipsis-text/ellipsis-text.tsx @@ -0,0 +1,16 @@ +import { Tooltip, Typography } from "antd"; +import React from "react"; + +interface Props { + showTooltip?: boolean; +} + +const EllipsisText: React.FC< + Props & React.ComponentProps +> = ({ showTooltip = true, ...props }) => { + const node = ; + + return showTooltip ? {node} : node; +}; + +export default EllipsisText; diff --git a/src/lib/components/file-picker/file-picker.tsx b/src/lib/components/file-picker/file-picker.tsx new file mode 100644 index 0000000..fac9cbd --- /dev/null +++ b/src/lib/components/file-picker/file-picker.tsx @@ -0,0 +1,78 @@ +import { useRootContextValues } from "@/lib/contexts/root.context"; +import { fileNameFromUrl } from "@/lib/utils/misc.utils"; +import { CloseCircleOutlined, UploadOutlined } from "@ant-design/icons"; +import { Button, Space, Typography } from "antd"; +import React, { useMemo } from "react"; + +type ConsistentFile = { + src: string; + name: string; +}; + +type AnyFile = File | string; + +type Props = { + value?: AnyFile[]; + count?: number; + onChange?: (files: AnyFile[]) => void; + accept?: string; + loading?: boolean; +}; + +function toConsistentFile(file: AnyFile): ConsistentFile { + return { + src: typeof file === "string" ? file : URL.createObjectURL(file), + name: typeof file === "string" ? fileNameFromUrl(file)! : file.name, + }; +} + +const FilePicker: React.FC = ({ + value, + count, + onChange, + accept, + loading, +}) => { + const { openFile } = useRootContextValues(); + const _value = useMemo(() => value?.map(toConsistentFile) || [], [value]); + + return ( +
+ + {_value.map((file, ix) => ( + + {file && ( + file.src && window.open(file.src, "_blank")} + className="text-xs" + > + {file.name || "Unnamed File"} + + )} + + onChange?.(_value.filter((_, _ix) => ix !== _ix) as any) + } + /> + + ))} +
+ ); +}; + +export default FilePicker; diff --git a/src/lib/components/hover/hover.tsx b/src/lib/components/hover/hover.tsx new file mode 100644 index 0000000..4ba2f67 --- /dev/null +++ b/src/lib/components/hover/hover.tsx @@ -0,0 +1,49 @@ +import React, { + CSSProperties, + ReactElement, + useCallback, + useState, +} from "react"; + +interface HoverProps { + element?: string | React.ElementType; + style?: CSSProperties; + hoverStyle?: CSSProperties; + children?: ReactElement; + onHover?: (isHovered: boolean) => void; +} + +export default function Hover({ + element, + style = {}, + hoverStyle = {}, + children, + onHover, +}: HoverProps) { + const [appliedStyle, setAppliedStyle] = useState(style); + + const onMouseOver = useCallback(() => { + setAppliedStyle({ ...style, ...hoverStyle }); + typeof onHover === "function" && onHover(true); + }, [style, hoverStyle, onHover]); + + const onMouseOut = useCallback(() => { + setAppliedStyle(style); + typeof onHover === "function" && onHover(false); + }, [style, onHover]); + + const elementType = element || "div"; + const props = { + style: { + ...(children?.props?.style || {}), + ...appliedStyle, + }, + onMouseOver, + onMouseOut, + }; + const childContent = element ? children : children?.props?.children; + + return typeof element === "string" + ? React.createElement(elementType, props, childContent) + : React.cloneElement(children as ReactElement, props); +} diff --git a/src/lib/components/image-picker/image-picker.tsx b/src/lib/components/image-picker/image-picker.tsx new file mode 100644 index 0000000..24ebc1f --- /dev/null +++ b/src/lib/components/image-picker/image-picker.tsx @@ -0,0 +1,147 @@ +import { useRootContextValues } from "@/lib/contexts/root.context"; +import { isImage } from "@/lib/utils/image.utils"; +import { arrayExtend } from "@/lib/utils/misc.utils"; +import { cn } from "@/lib/utils/styles.utils"; +import { CloseCircleFilled } from "@ant-design/icons"; +import { useUpdateEffect } from "ahooks"; +import { List } from "antd"; +import { useEffect, useMemo, useState } from "react"; +import Hover from "../hover/hover"; +import ServerImg, { placeholderImg } from "../server-img/server-img"; + +function urlToImg(url: string) { + return { + src: url, + type: "image/", + }; +} + +type ImagePickerProps = { + count: number; + width?: number; + height?: number; + gutter?: number; + listProps?: React.ComponentProps; + imgProps?: React.ComponentProps; + onChange?: (images: any[]) => void; + value?: string | string[]; + editable?: boolean; +}; + +export default function ImagePicker({ + count, + width = 120, + height = 120, + gutter = 16, + listProps = {}, + imgProps = {}, + onChange = () => {}, + value, + editable = true, +}: ImagePickerProps) { + const _value = useMemo( + () => + Array.isArray(value) + ? value + .filter((item) => !!item) + .map((item) => (typeof item === "string" ? urlToImg(item) : item)) + : typeof value === "string" + ? [urlToImg(value)] + : value, + [value] + ); + + const { openFile } = useRootContextValues(); + const [images, setImages] = useState( + _value ? arrayExtend(_value, count) : Array(count).fill({}) + ); + + useUpdateEffect(() => { + onChange && onChange(images.filter(isImage).map((img) => img.src)); + }, [images]); + + useEffect(() => { + if ( + Array.isArray(_value) && + JSON.stringify(arrayExtend(_value, count)) !== JSON.stringify(images) + ) + setImages(arrayExtend(_value, count)); + }, [_value]); + + const fillImagesArr = (files: File[], start: number) => { + const newImages = [...images]; + let count = 0, + i = 0; + while (count < newImages.length && i < files.length) { + const ix = (start + count) % newImages.length; + if (!isImage(newImages[ix])) { + newImages[ix] = files[i]; + newImages[ix].src = URL.createObjectURL(files[i]!); + i++; + } + count++; + } + setImages(newImages); + }; + + const onClose = (i: number) => { + const newImages = images.map((img, ix) => (ix === i ? {} : img)); + setImages(newImages); + }; + + return ( + { + const isImageValid = isImage(img as any); + return ( + + !isImageValid && + editable && + openFile({ + onChange: (files) => fillImagesArr(files, i), + accept: "image/*", + }) + } + > + + {isImageValid && editable && ( + + onClose(i)} + className={cn( + "text-danger absolute text-base transition-transform duration-300", + imgProps.loader?.shape === "circle" + ? "right-[24px] top-[1px]" + : "right-0 top-[-8px]" + )} + /> + + )} + + ); + }} + {...listProps} + /> + ); +} diff --git a/src/lib/components/server-img/server-img.tsx b/src/lib/components/server-img/server-img.tsx new file mode 100644 index 0000000..bbd93e7 --- /dev/null +++ b/src/lib/components/server-img/server-img.tsx @@ -0,0 +1,107 @@ +import { randString } from "@/lib/utils/string.utils"; +import { cn } from "@/lib/utils/styles.utils"; +import { AvatarProps, Image, Skeleton, Spin } from "antd"; +import { useRef, useState } from "react"; + +export const placeholderImg = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2Ibzx0tc44fYX/16lV6NDFLXH+YL32jwiACRBiEbf5KcXoTIsQSpzXx4N28Ja4BQoK7rgXiydbHjx/P25TaQAJEGAguWy0+2Q8PD6/Ki4R8EVl+bzBOnZY95fq9rj9zAkTI2SxdidBHqG9+skdw43borCXO/ZcJdraPWdv22uIEiLA4q7nvvCug8WTqzQveOH26fodo7g6uFe/a17W3+nFBAkRYENRdb1vkkz1CH9cPsVy/jrhr27PqMYvENYNlHAIesRiBYwRy0V+8iXP8+/fvX11Mr7L7ECueb/r48eMqm7FuI2BGWDEG8cm+7G3NEOfmdcTQw4h9/55lhm7DekRYKQPZF2ArbXTAyu4kDYB2YxUzwg0gi/41ztHnfQG26HbGel/crVrm7tNY+/1btkOEAZ2M05r4FB7r9GbAIdxaZYrHdOsgJ/wCEQY0J74TmOKnbxxT9n3FgGGWWsVdowHtjt9Nnvf7yQM2aZU/TIAIAxrw6dOnAWtZZcoEnBpNuTuObWMEiLAx1HY0ZQJEmHJ3HNvGCBBhY6jtaMoEiJB0Z29vL6ls58vxPcO8/zfrdo5qvKO+d3Fx8Wu8zf1dW4p/cPzLly/dtv9Ts/EbcvGAHhHyfBIhZ6NSiIBTo0LNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiEC/wGgKKC4YMA4TAAAAABJRU5ErkJggg=="; + +type ServerImgProps = { + loader?: { + type: "skeleton" | "spin" | React.ReactNode; + shape: AvatarProps["shape"]; + }; + defaultWidth?: number; + defaultHeight?: number; + fallback?: string; + id?: string; + onLoad?: () => void; + preview?: boolean; + style?: React.CSSProperties; + className?: string; + src?: string; +}; + +export default function ServerImg({ + loader = { type: "skeleton", shape: "square" }, + defaultWidth = 400, + defaultHeight = 250, + fallback = placeholderImg, + preview = true, + ...props +}: ServerImgProps) { + const [loading, setLoading] = useState(true); + const id = useRef(props.id || randString(8)).current; + + const onLoad = () => { + setLoading(false); + if (typeof props.onLoad === "function") props.onLoad(); + }; + + return ( + <> + {loading ? ( + loader.type === "skeleton" ? ( + + ) : loader.type === "spin" ? ( +
+ +
+ ) : ( + loader + ) + ) : null} +
+ +
+ + ); +} diff --git a/src/lib/components/server-paginated-select/lazy-select.tsx b/src/lib/components/server-paginated-select/lazy-select.tsx index a49c380..bfcdd30 100644 --- a/src/lib/components/server-paginated-select/lazy-select.tsx +++ b/src/lib/components/server-paginated-select/lazy-select.tsx @@ -1,6 +1,6 @@ import { Select, Skeleton } from "antd"; -import { useCallback, useEffect, useRef } from "react"; import { debounce } from "lodash"; +import { useCallback, useEffect, useRef } from "react"; type LazySelectProps = { style?: React.CSSProperties; @@ -12,11 +12,8 @@ type LazySelectProps = { debouncing?: number; loading?: boolean; skeletonProps?: any; - optionContainerStyle?: React.CSSProperties; - containerStyle?: React.CSSProperties; valueResolver?: (item: any) => any; idResolver?: (value: any) => any; - optionProps?: any; onChange?: (value: any) => void; value?: any; defaultValue?: any; @@ -32,11 +29,8 @@ function LazySelect({ debouncing = 800, loading, skeletonProps, - optionContainerStyle, - containerStyle, valueResolver, //how to get value from item when onChange is called idResolver, //how to get unique id from above value (valueResolver) - optionProps, onChange, value, defaultValue, diff --git a/src/lib/components/server-paginated-select/server-paginated-select.tsx b/src/lib/components/server-paginated-select/server-paginated-select.tsx index fe7287c..aee19a4 100644 --- a/src/lib/components/server-paginated-select/server-paginated-select.tsx +++ b/src/lib/components/server-paginated-select/server-paginated-select.tsx @@ -18,7 +18,7 @@ type ServerPaginatedSelectProps = React.ComponentProps & { value?: any; dataSource?: (data: any[]) => any[]; searchDebouncing?: number; - fetchDefaultValue: (value: any) => Promise; + fetchDefaultValue?: (value: any) => Promise; }; export default function ServerPaginatedSelect({ @@ -65,8 +65,8 @@ export default function ServerPaginatedSelect({ const list = Array.isArray(data) ? data : Array.isArray(data.docs) - ? data.docs - : []; + ? data.docs + : []; if (!pageSize || list.length < pageSize) setNoMore(true); diff --git a/src/lib/components/server-paginated-table/server-paginated-table.tsx b/src/lib/components/server-paginated-table/server-paginated-table.tsx index 3107a48..5d2a2e6 100644 --- a/src/lib/components/server-paginated-table/server-paginated-table.tsx +++ b/src/lib/components/server-paginated-table/server-paginated-table.tsx @@ -2,12 +2,14 @@ import axios from "@/lib/axios.config"; import { GenericObject } from "@/lib/types/misc"; import { isNullish } from "@/lib/utils/misc.utils"; import useUrlState from "@ahooksjs/use-url-state"; +import { ReloadOutlined } from "@ant-design/icons"; import { useDeepCompareEffect, useUpdateEffect } from "ahooks"; -import { Table, TableProps } from "antd"; +import { Button, Table, TableProps, Tooltip } from "antd"; import { AnyObject } from "antd/es/_util/type"; +import { ColumnsType } from "antd/es/table"; import { capitalize, pickBy } from "lodash"; import qs from "qs"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import FiltersButton from "./filters-button"; function generateColsFromData(data: T[] = []) { @@ -26,16 +28,16 @@ function generateColsFromData(data: T[] = []) { typeof item === "string" ? item : JSON.stringify(item), }); } - return cols; + return cols as ColumnsType; } export type GetHelpers = { data: T[]; - fetchData: Function; - setData: Function; - setLoading: Function; - setTableParams: Function; - setTotal: Function; + fetchData: () => void; + setData: React.Dispatch>; + setLoading: React.Dispatch>; + setTableParams: (params: GenericObject) => void; + setTotal: React.Dispatch>; }; export type ServerPaginatedTableProps = TableProps & { @@ -50,11 +52,12 @@ export type ServerPaginatedTableProps = TableProps & { showSizeChanger?: boolean; showQuickJumper?: boolean; showTotal?: (total: number, range: [number, number]) => string; + showRefresh?: boolean; }; export default function ServerPaginatedTable({ url, - pageSize = 20, + pageSize = 10, dataSource, onDocsChange, columns, @@ -65,30 +68,57 @@ export default function ServerPaginatedTable({ showSizeChanger = true, showQuickJumper = true, showTotal = (total, range) => `${range[0]} - ${range[1]} of ${total} items`, + showRefresh = true, ...props }: ServerPaginatedTableProps) { + const defaultSortCol = useMemo( + () => columns?.find((col) => col.defaultSortOrder), + [columns] + ); + const defaultSortField = + (defaultSortCol as any)?.dataIndex || defaultSortCol?.key || ""; + const initialState = { + current: 1, + pageSize, + ...filters?.reduce( + (acc, filter) => ({ ...acc, [`filter.${filter.key}`]: "" }), + {} + ), + ["sort.field"]: defaultSortField, + ["sort.order"]: defaultSortCol?.defaultSortOrder || "", + }; const [data, setData] = useState([]); const [loading, setLoading] = useState(false); - const [tableParams, setTableParams] = useUrlState( - { - current: 1, - pageSize, - ...filters?.reduce( - (acc, filter) => ({ ...acc, [`filter.${filter.key}`]: "" }), - {} - ), - }, - { parseOptions: { parseNumbers: true } } - ); + const [tableParams, setTableParams] = useUrlState(initialState, { + parseOptions: { parseNumbers: true }, + }); const [total, setTotal] = useState(0); - const handleTableChange: ServerPaginatedTableProps["onChange"] = (val) => { + const handleTableChange: ServerPaginatedTableProps["onChange"] = ( + pagination, + _, + sorters + ) => { + const sortObj = (Array.isArray(sorters) ? sorters[0] : sorters) || {}; + + // if sorter is removed from a non-default column, set it to default + if ( + defaultSortField && + sortObj.field !== defaultSortField && + !sortObj.order + ) { + sortObj.field = defaultSortField; + sortObj.order = defaultSortCol?.defaultSortOrder; + } + setTableParams({ ...tableParams, - current: val.current, - pageSize: val.pageSize, + ["sort.field"]: sortObj?.field || "", + ["sort.order"]: sortObj?.order || "", + current: pagination.current, + pageSize: pagination.pageSize, }); - if (tableParams.pageSize !== val?.pageSize) { + if (tableParams.pageSize !== pagination?.pageSize) { setData([]); } }; @@ -111,7 +141,9 @@ export default function ServerPaginatedTable({ pickBy( tableParams, (_, key) => - ["current", "pageSize"].includes(key) || key.startsWith("filter.") + ["current", "pageSize"].includes(key) || + key.startsWith("filter.") || + key.startsWith("sort.") ), ]); @@ -126,18 +158,25 @@ export default function ServerPaginatedTable({ const [_url, _query = ""] = url.split("?"); const query: GenericObject = qs.parse(_query); + // pagination if (pageSize) { - query.per_page = pageSize; - query.page = current - 1; + query.limit = pageSize; + query.page = current; } + // filters Object.entries(restParams).forEach(([key, value]) => { if (!key.startsWith("filter.")) return; - if (!isNullish(value)) query[key.split("filter.")[1]] = value; + if (!isNullish(value)) query[key.split("filter.")[1]!] = value; }); + // sorting + if (restParams["sort.field"] && !isNullish(restParams["sort.order"])) { + query.sortField = restParams["sort.field"]; + query.sortOrder = restParams["sort.order"] === "ascend" ? "asc" : "desc"; + } axios .get(`${_url}?${qs.stringify(query)}`) - .then(({ data }) => { + .then(({ data: { data } }) => { const list = ( Array.isArray(data) ? data @@ -146,9 +185,7 @@ export default function ServerPaginatedTable({ setData( list.map((item, ix: number) => { - // @ts-ignore - item.key = item.id ?? ix.toString(); - return item; + return { ...item, key: item.id ?? ix.toString() }; }) ); @@ -170,6 +207,20 @@ export default function ServerPaginatedTable({ }); }, [data, fetchData, setData, setLoading, setTableParams, setTotal]); + const cols = useMemo(() => { + const colsList = Array.isArray(columns) + ? columns + : generateColsFromData(data); + return colsList.map((col) => ({ + ...col, + sortOrder: + col.sorter && + tableParams["sort.field"] === ((col as any).dataIndex || col.key) + ? tableParams["sort.order"] || undefined + : col.defaultSortOrder, + })); + }, [columns, data]); + return (
@@ -186,14 +237,21 @@ export default function ServerPaginatedTable({ onFiltersChange={onFiltersChange} /> ) : null} + {showRefresh && ( + + + + )}
+ logout().unwrap().catch(console.error), + okType: "danger", }); }, []); @@ -114,11 +113,14 @@ function Header() { { key: "settings", label: "Settings", + icon: , onClick: onSettingsClicked, }, { key: "logout", label: "Logout", + danger: true, + icon: , disabled: logoutLoading, onClick: onLogout, }, @@ -127,8 +129,7 @@ function Header() { trigger={["click"]} > - - {profile.name} + diff --git a/src/lib/layouts/dashboard-layout/main-menu.tsx b/src/lib/layouts/dashboard-layout/main-menu.tsx index ad5b84d..9781c82 100644 --- a/src/lib/layouts/dashboard-layout/main-menu.tsx +++ b/src/lib/layouts/dashboard-layout/main-menu.tsx @@ -1,7 +1,7 @@ import { store, useAppSelector } from "@/lib/redux/store"; import { getRoutePath } from "@/lib/router/router"; import { RouterConfig, useRouterConfig } from "@/lib/router/router-config"; -import { useRole } from "@/modules/auth/hooks/role.hooks"; +import { Role, useRole } from "@/modules/auth/hooks/role.hooks"; import { authActions } from "@/modules/auth/slices/auth.slice"; import { Menu } from "antd"; import { useCallback } from "react"; @@ -26,9 +26,12 @@ export default function MainMenu({ closeOnNavigate = false, ...props }) { (routes: RouterConfig[], prefix = "", basePath = "") => { return routes.map((route, i) => { if (!route.menuItem) return null; + + if (route.allowedRoles?.includes(Role.ADMIN)) + route.allowedRoles.push(Role.SUPER_ADMIN); if ( Array.isArray(route.allowedRoles) && - !route.allowedRoles.includes(role!) + !route.allowedRoles.includes(role as Role) ) return null; diff --git a/src/lib/openapi-fetch.config.ts b/src/lib/openapi-fetch.config.ts new file mode 100644 index 0000000..c65cfa4 --- /dev/null +++ b/src/lib/openapi-fetch.config.ts @@ -0,0 +1,100 @@ +import { globalErrorHandler } from "@/lib/utils/error.utils"; +import { AxiosError } from "axios"; +import createClient from "openapi-fetch"; +import axios from "./axios.config"; +import { paths } from "./types/openapi-fetch"; + +export const PERMISSION_ERR_MSG = + "You don't have permission to perform this action. Please contact your organization admin."; + +// Create enhanced client by wrapping Axios as the fetch implementation +const apiClient = createClient({ + baseUrl: import.meta.env.VITE_API_BASE_URL, + // Use Axios as the fetch implementation with correct signature + fetch: async (input: Request) => { + try { + // Extract request details from the Request object + const url = input.url; + const method = input.method.toLowerCase(); + const headers = Object.fromEntries(input.headers.entries()); + + // Handle request body based on content type + let data = undefined; + if (input.body) { + const contentType = input.headers.get("Content-Type"); + if (contentType?.includes("application/json")) { + data = await input.json(); + } else if (contentType?.includes("multipart/form-data")) { + data = await input.formData(); + } else { + data = input.body; + } + } + + // Convert fetch API request to Axios format + const axiosConfig = { + url: url.replace(import.meta.env.VITE_API_BASE_URL, ""), // Remove base URL as axios config already has it + method, + headers, + data, + }; + + // Use our configured axios instance to make the request + const response = await axios.request(axiosConfig); + + // Convert Axios response format back to fetch Response format + return new Response(JSON.stringify(response.data), { + status: response.status || 200, + statusText: response.statusText || "", + }); + } catch (error) { + // Axios error handling is already done in axios.config.ts + // Here we just convert it to a Response object for openapi-fetch + const axiosError = error as AxiosError; + return new Response( + JSON.stringify( + axiosError?.response?.data ?? { error: "Network Error" } + ), + { + status: axiosError?.response?.status || 500, + statusText: axiosError?.response?.statusText || "Error", + } + ); + } + }, +}); + +apiClient.use({ + async onError({ error }) { + globalErrorHandler(error); + }, +}); + +export async function withApiResponseHandling( + request: Promise<{ + data?: { data: T; error: string | null }; + error?: E; + response: Response; + }> +): Promise<{ + data: NonNullable; + response: Response; +}> { + const { data, error, response } = await request; + + if (error || data?.error || !data?.data) { + throw { + message: (error as any)?.error ?? error ?? data?.error, + name: "AxiosError", + }; + } + + const result = { + data: data.data, + response, + }; + + return result; +} + +export default apiClient; diff --git a/src/lib/redux/createSubscriptions.ts b/src/lib/redux/createSubscriptions.ts index 41e97e2..355c997 100644 --- a/src/lib/redux/createSubscriptions.ts +++ b/src/lib/redux/createSubscriptions.ts @@ -16,7 +16,7 @@ const createSubscriptons = ( ) => { const { name, reducer } = slice; slice.reducer = function (state, action) { - for (let key in subObj) { + for (const key in subObj) { if (action.type === `${name}/_register_${key}_unsubscriber_`) { return { ...state, [`__unsubscriber_${key}_`]: action.payload }; } @@ -25,7 +25,7 @@ const createSubscriptons = ( }; const res: GenericObject = {}; - for (let key in subObj) { + for (const key in subObj) { res[`${key}Unsub`] = createAsyncThunk( `${name}/unsub_${key}`, (payload, thunksOptions) => { @@ -41,7 +41,7 @@ const createSubscriptons = ( `${name}/sub_${key}`, (payload, thunkOptions) => { store.dispatch(res[`${key}Unsub`]()).then(() => { - const unsubscriber = subObj[key](payload, thunkOptions as any); + const unsubscriber = subObj[key]?.(payload, thunkOptions as any); if (typeof unsubscriber === "function") store.dispatch({ type: `${name}/_register_${key}_unsubscriber_`, diff --git a/src/lib/redux/enhancers/status.enhancer.ts b/src/lib/redux/enhancers/status.enhancer.ts index 2eaf3b5..c9867f7 100644 --- a/src/lib/redux/enhancers/status.enhancer.ts +++ b/src/lib/redux/enhancers/status.enhancer.ts @@ -9,6 +9,7 @@ import { } from "@reduxjs/toolkit"; import { Reducer } from "react"; import { ThunkStatus } from "../../types/misc"; +import { RootState, useAppSelector } from "../store"; //@ts-ignore export const statusHandlerEnahncer: StoreEnhancer<{}, {}> = @@ -23,7 +24,7 @@ export const statusHandlerEnahncer: StoreEnhancer<{}, {}> = //get slicename and type value from action.type const split = action.type.split("/"); - const sliceName = split[0]; + const sliceName = split[0]!; const type = split[1]; let status: ThunkStatus | undefined; @@ -44,3 +45,13 @@ export const statusHandlerEnahncer: StoreEnhancer<{}, {}> = return cs(statusHandlerReducer, initialState, enhancer); }; + +export const useIsLoading = < + S extends keyof RootState, + K extends keyof RootState[S], +>( + slice: S, + key: K +): boolean => { + return useAppSelector((state) => state[slice][key] === ThunkStatus.LOADING); +}; diff --git a/src/lib/redux/store.ts b/src/lib/redux/store.ts index 4230fbf..7d2a44e 100644 --- a/src/lib/redux/store.ts +++ b/src/lib/redux/store.ts @@ -1,5 +1,7 @@ import { authSlice } from "@/modules/auth/slices/auth.slice"; import { profileSlice } from "@/modules/auth/slices/profile.slice"; +import { fileSlice } from "@/modules/file/slices/file.slice"; +import { userSlice } from "@/modules/user/slices/user.slice"; import { configureStore } from "@reduxjs/toolkit"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import { statusHandlerEnahncer } from "./enhancers/status.enhancer"; @@ -10,6 +12,8 @@ export const store = configureStore({ reducer: { auth: authSlice.reducer, profile: profileSlice.reducer, + user: userSlice.reducer, + file: fileSlice.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }).concat( diff --git a/src/lib/router/router-config.tsx b/src/lib/router/router-config.tsx index 2bda16f..18477f3 100644 --- a/src/lib/router/router-config.tsx +++ b/src/lib/router/router-config.tsx @@ -1,8 +1,17 @@ -import { Role } from "@/modules/auth/types/profile.type"; +import { Role } from "@/modules/auth/hooks/role.hooks"; import NotFound from "@/pages/404/404"; +import ForgotPassword from "@/pages/auth/forgot-password"; import Login from "@/pages/auth/login"; -import Home from "@/pages/home/home"; -import { DashboardOutlined } from "@ant-design/icons"; +import ResetPassword from "@/pages/auth/reset-password"; +import Posts from "@/pages/posts/posts"; +import SamplePage from "@/pages/sample-page/sample-page"; +import Settings from "@/pages/settings/settings"; +import Users from "@/pages/users/users"; +import { + BookOutlined, + DashboardOutlined, + UserOutlined, +} from "@ant-design/icons"; import { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useAppSelector } from "../redux/store"; @@ -32,49 +41,74 @@ export const useRouterConfig = (): RouterConfig[] => { { layoutType: "dashboard", authType: "private", - component: , + component: , menuItem: { - title: t("sidebar:home"), - icon: , + title: t("sidebar:posts"), + icon: , }, route: { path: "/", }, + }, + { + layoutType: "dashboard", + authType: "private", + component: , + menuItem: { + title: t("sidebar:users"), + icon: , + }, + route: { + path: "/users", + }, + allowedRoles: [Role.SUPER_ADMIN], + }, + { + layoutType: "dashboard", + authType: "private", + component: , + menuItem: { + title: "Parent", + icon: , + }, + route: { + path: "/parent", + }, subRoutes: [ { layoutType: "dashboard", authType: "private", - component: , + component: , menuItem: { - title: "Home 2", + title: "Child 1", icon: , }, route: { - path: "home-2", + path: "child-1", }, }, { layoutType: "dashboard", authType: "private", - component: , + component: , menuItem: { - title: "Home 3", + title: "Child 2", icon: , }, route: { - path: "home-3", + path: "child-2", }, subRoutes: [ { layoutType: "dashboard", authType: "private", - component: , + component: , menuItem: { - title: "Home 3.1", + title: "Grand Child", icon: , }, route: { - path: "home-3.1", + path: "grand-child", }, }, ], @@ -84,34 +118,33 @@ export const useRouterConfig = (): RouterConfig[] => { { layoutType: "dashboard", authType: "private", - component: , - menuItem: { - title: "Page 2", - icon: , - }, + component: , route: { - path: "/page-2", + path: "/settings", }, }, { - layoutType: "dashboard", - authType: "private", - component: , - allowedRoles: [Role.ADMIN], - menuItem: { - title: "Admin Only Page", - icon: , + layoutType: "auth", + authType: "public", + component: , + route: { + path: "/login", }, + }, + { + layoutType: "auth", + authType: "public", + component: , route: { - path: "/admin-only", + path: "/forgot-password", }, }, { layoutType: "auth", authType: "public", - component: , + component: , route: { - path: "/login", + path: "/reset-password", }, }, { diff --git a/src/lib/router/router.tsx b/src/lib/router/router.tsx index 4a18a1a..dbd7a80 100644 --- a/src/lib/router/router.tsx +++ b/src/lib/router/router.tsx @@ -5,15 +5,17 @@ import DashboardLayout from "@/lib/layouts/dashboard-layout/dashboard-layout"; import EmptyLayout from "@/lib/layouts/empty-layout"; import { useAppDispatch } from "@/lib/redux/store"; import { useAuth } from "@/modules/auth/hooks/auth.hooks"; -import { useRole } from "@/modules/auth/hooks/role.hooks"; +import { Role, useRole } from "@/modules/auth/hooks/role.hooks"; import { authActions } from "@/modules/auth/slices/auth.slice"; import { profileActions } from "@/modules/auth/slices/profile.slice"; +import NotFound from "@/pages/404/404"; import _ from "lodash"; import React, { PropsWithChildren, ReactNode, useEffect, useLayoutEffect, + useRef, useState, } from "react"; import { @@ -87,7 +89,7 @@ export default function Router() { const dispatch = useAppDispatch(); const [ar, cr] = routeRenderer(routerConfig); dispatch( - authActions.setComputedRoutes(cr.map((r) => _.pick(r, ["route", "key"]))) + authActions.setComputedRoutes(cr?.map((r) => _.pick(r, ["route", "key"]))) ); return ( @@ -163,27 +165,40 @@ const RoleCheckWrapper: React.FC< PropsWithChildren & { allowedRoles: RouterConfig["allowedRoles"] } > = ({ children, allowedRoles }) => { const [isRendered, setIsRendered] = useState(false); + const isAllowed = useRef(true); const role = useRole(); const dispatch = useAppDispatch(); const { status: authStatus } = useAuth(); - const navigate = useNavigate(); + + if (allowedRoles?.includes(Role.ADMIN)) allowedRoles.push(Role.SUPER_ADMIN); useEffect(() => { if (authStatus === "authenticated") { dispatch(profileActions.fetch()); } - }, [authStatus]); + }, []); useLayoutEffect(() => { if (authStatus === "authenticated" && !role) { setIsRendered(false); - } else if (Array.isArray(allowedRoles) && !allowedRoles.includes(role!)) { - navigate("/not-found"); - setIsRendered(false); + } else if ( + Array.isArray(allowedRoles) && + !allowedRoles.includes(role as Role) + ) { + isAllowed.current = false; + setIsRendered(true); } else { setIsRendered(true); } }, [allowedRoles, role, authStatus]); - return isRendered ? children : ; + return isRendered ? ( + isAllowed.current ? ( + children + ) : ( + + ) + ) : ( + + ); }; diff --git a/src/lib/translations/en-US.json b/src/lib/translations/en-US.json index 7b3bdfe..0775462 100644 --- a/src/lib/translations/en-US.json +++ b/src/lib/translations/en-US.json @@ -9,11 +9,17 @@ }, "sidebar": { - "home": "Home" + "home": "Home", + "users": "Users", + "posts": "Posts" }, "logout": { "title": "Logout", "message": "Are you sure you want to logout?" + }, + + "settings": { + "title": "Settings" } } diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts new file mode 100644 index 0000000..7fc0937 --- /dev/null +++ b/src/lib/types/api.ts @@ -0,0 +1,37 @@ +import { components, operations } from "./openapi-fetch"; + +type OperationParameters = NonNullable< + operations[T]["parameters"] +>; +type OperationRequestBody = NonNullable< + operations[T]["requestBody"] +>; +type OperationResponse = NonNullable< + operations[T]["responses"] +>; + +export type ApiSchemas = components["schemas"]; + +export type ApiQueryParams = NonNullable< + OperationParameters["query" extends keyof OperationParameters + ? "query" + : never] +>; + +export type ApiBody = NonNullable< + OperationRequestBody extends { content: { "application/json": any } } + ? OperationRequestBody["content"]["application/json"] + : OperationRequestBody extends { + content: { "multipart/form-data": any }; + } + ? NonNullable>["content"]["multipart/form-data"] + : never +>; + +export type ApiResponse = NonNullable< + OperationResponse extends { + "200": { content: { "application/json": { data: any } } }; + } + ? OperationResponse["200"]["content"]["application/json"]["data"] + : never +>; diff --git a/src/lib/types/misc.ts b/src/lib/types/misc.ts index c2579a7..c0e5765 100644 --- a/src/lib/types/misc.ts +++ b/src/lib/types/misc.ts @@ -9,4 +9,7 @@ export enum ThunkStatus { } export type GenericObject = Record; + export type KeyValuePair = Record; + +export type Modify = Omit & R; diff --git a/src/lib/types/openapi-fetch.d.ts b/src/lib/types/openapi-fetch.d.ts new file mode 100644 index 0000000..a316745 --- /dev/null +++ b/src/lib/types/openapi-fetch.d.ts @@ -0,0 +1,1455 @@ +export interface paths { + "/users/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["UserFetch"]; + put?: never; + post?: never; + delete: operations["UserRemove"]; + options?: never; + head?: never; + patch: operations["UserUpdate"]; + trace?: never; + }; + "/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["UserFetchList"]; + put?: never; + post: operations["UserCreate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/profile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["ProfileFetch"]; + put?: never; + post?: never; + delete: operations["ProfileDelete"]; + options?: never; + head?: never; + patch: operations["ProfileUpdate"]; + trace?: never; + }; + "/posts/{postId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["PostFetch"]; + put?: never; + post?: never; + delete: operations["PostRemove"]; + options?: never; + head?: never; + patch: operations["PostUpdate"]; + trace?: never; + }; + "/posts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["PostFetchList"]; + put?: never; + post: operations["PostCreate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications/webhooks/handle-trigger": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["NotificationHandleTrigger"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications/general": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["NotificationSendGeneral"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["NotificationFetchList"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications/mark-read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["NotificationMarkRead"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["MiscGetStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/openapi.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["MiscGetOpenApiSpec"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/reload-secrets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["MiscReloadSecrets"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["FileSave"]; + delete: operations["FileRemove"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files/webhooks/handle-img-resize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["FileHandleImageResize"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["AuthLogin"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/signup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["AuthSignup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/verify-email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["AuthVerifyEmail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/signout-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["AuthSignout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/forgot-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["AuthForgotPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/reset-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["AuthResetPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/change-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["AuthChangePassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/resend-verification": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["AuthResendVerification"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["AuthRefresh"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description From https://github.com/sindresorhus/type-fest/ + * Matches any valid JSON value. */ + JsonValue: (string | number | boolean | components["schemas"]["JsonObject"] | components["schemas"]["JsonArray"]) | null; + /** @description From https://github.com/sindresorhus/type-fest/ + * Matches a JSON object. + * This type can be useful to enforce some input to be JSON-compatible or as a super-type to be extended from. */ + JsonObject: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** @description From https://github.com/sindresorhus/type-fest/ + * Matches a JSON array. */ + JsonArray: Record; + /** @description From T, pick a set of properties whose keys are in the union K */ + "Pick_User.Exclude_keyofUser.password_hash-or-refresh_token_version__": { + name: string; + created_at?: string | (Date | undefined); + updated_at?: string | (Date | undefined); + id?: string; + role?: string; + email_verified?: boolean; + email: string; + photo?: string; + fcm_tokens?: string[]; + bio?: string; + /** Format: double */ + unread_noti_count?: number; + photo_sizes?: components["schemas"]["JsonValue"]; + }; + /** @description Construct a type with the properties of T except for those in type K. */ + "Omit_User.password_hash-or-refresh_token_version_": components["schemas"]["Pick_User.Exclude_keyofUser.password_hash-or-refresh_token_version__"]; + SanitizedUser: components["schemas"]["Omit_User.password_hash-or-refresh_token_version_"]; + APIResponse_SanitizedUser_: { + data: components["schemas"]["SanitizedUser"] | null; + error: string | null; + }; + PaginationSortResponse_SanitizedUser_: { + /** Format: double */ + total?: number; + list: components["schemas"]["SanitizedUser"][]; + }; + APIResponse_PaginationSortResponse_SanitizedUser__: { + data: components["schemas"]["PaginationSortResponse_SanitizedUser_"] | null; + error: string | null; + }; + /** @enum {string} */ + Role: "user" | "admin" | "super-admin"; + /** @enum {string} */ + "SortFields_SanitizedUser.created_at-or-updated_at-or-name_": "created_at" | "updated_at" | "name"; + UserSortFields: components["schemas"]["SortFields_SanitizedUser.created_at-or-updated_at-or-name_"]; + /** @enum {string} */ + SortOrder: "asc" | "desc"; + UserFetchList: { + sortOrder?: components["schemas"]["SortOrder"]; + sortField?: components["schemas"]["UserSortFields"]; + /** Format: double */ + limit?: number; + /** Format: double */ + page?: number; + search?: string; + role?: components["schemas"]["Role"]; + }; + "Expand_Optional_UserMutable.bio-or-photo__": { + photo?: string; + bio?: string; + name: string; + role: string; + email: string; + }; + UserCreate: components["schemas"]["Expand_Optional_UserMutable.bio-or-photo__"]; + /** @description Make all properties in T optional */ + "Partial_Omit_UserMutable.email__": { + name?: string; + role?: string; + photo?: string; + bio?: string; + }; + UserUpdate: components["schemas"]["Partial_Omit_UserMutable.email__"]; + "Expand_PostUnlinked-and-_author-SanitizedUser__": { + created_at?: string | (Date | undefined); + updated_at?: string | (Date | undefined); + id?: string; + title: string; + slug: string; + text: string; + author_id: string; + /** Format: double */ + views?: number; + labels: string[]; + author: components["schemas"]["SanitizedUser"]; + }; + PostType: components["schemas"]["Expand_PostUnlinked-and-_author-SanitizedUser__"]; + APIResponse_PostType_: { + data: components["schemas"]["PostType"] | null; + error: string | null; + }; + PaginationSortResponse_PostType_: { + /** Format: double */ + total?: number; + list: components["schemas"]["PostType"][]; + }; + APIResponse_PaginationSortResponse_PostType__: { + data: components["schemas"]["PaginationSortResponse_PostType_"] | null; + error: string | null; + }; + /** @enum {string} */ + "SortFields_Post.created_at-or-updated_at-or-title-or-views_": "created_at" | "updated_at" | "title" | "views"; + PostSortFields: components["schemas"]["SortFields_Post.created_at-or-updated_at-or-title-or-views_"]; + PostFetchList: { + sortOrder?: components["schemas"]["SortOrder"]; + sortField?: components["schemas"]["PostSortFields"]; + /** Format: double */ + limit?: number; + /** Format: double */ + page?: number; + search?: string; + author_id?: string; + labels?: string[]; + populate?: boolean; + }; + "Expand_Omit_PostMutable.author_id__": { + title: string; + labels: string[]; + text: string; + }; + PostCreate: components["schemas"]["Expand_Omit_PostMutable.author_id__"]; + Expand_Partial_PostMutable__: { + title?: string; + labels?: string[]; + text?: string; + author_id?: string; + }; + PostUpdate: components["schemas"]["Expand_Partial_PostMutable__"]; + Message: { + message: string; + }; + APIResponse_Message_: { + data: components["schemas"]["Message"] | null; + error: string | null; + }; + /** @enum {string} */ + "NotificationEvent.GENERAL": "general"; + /** @description Type of `Prisma.DbNull`. + * + * You cannot use other instances of this class. Please use the `Prisma.DbNull` value. */ + "Prisma.NullTypes.DbNull": Record; + /** @description Type of `Prisma.JsonNull`. + * + * You cannot use other instances of this class. Please use the `Prisma.JsonNull` value. */ + "Prisma.NullTypes.JsonNull": Record; + "Prisma.NullableJsonNullValueInput": components["schemas"]["Prisma.NullTypes.DbNull"] | components["schemas"]["Prisma.NullTypes.JsonNull"]; + /** @description Matches any valid value that can be used as an input for operations like + * create and update as the value of a JSON field. Unlike \`JsonValue\`, this + * type allows read-only arrays and read-only object properties and disallows + * \`null\` at the top level. + * + * \`null\` cannot be used as the value of a JSON field because its meaning + * would be ambiguous. Use \`Prisma.JsonNull\` to store the JSON null value or + * \`Prisma.DbNull\` to clear the JSON value and set the field to the database + * NULL value instead. */ + InputJsonValue: string | number | boolean | components["schemas"]["InputJsonObject"] | components["schemas"]["InputJsonArray"] | Record; + /** @description Matches a JSON object. + * Unlike \`JsonObject\`, this type allows undefined and read-only properties. */ + InputJsonObject: { + [key: string]: components["schemas"]["InputJsonValue"]; + }; + /** @description Matches a JSON array. + * Unlike \`JsonArray\`, readonly arrays are assignable to this type. */ + InputJsonArray: Record; + /** @description Construct a type with a set of properties K of type T */ + "Record_string.any_": { + [key: string]: unknown; + }; + GenericObject: components["schemas"]["Record_string.any_"]; + "Expand_Omit_NotificationMutable.event_-and-_roles_63_-Role-Array--userIds_63_-string-Array--metadata_63_-GenericObject__": { + title: string; + body: string; + image?: string; + link?: string; + metadata?: ((components["schemas"]["Prisma.NullableJsonNullValueInput"] | components["schemas"]["InputJsonValue"]) & components["schemas"]["GenericObject"]) & components["schemas"]["GenericObject"]; + roles?: components["schemas"]["Role"][]; + userIds?: string[]; + }; + NotificationSendGeneral: components["schemas"]["Expand_Omit_NotificationMutable.event_-and-_roles_63_-Role-Array--userIds_63_-string-Array--metadata_63_-GenericObject__"]; + /** @enum {string} */ + "NotificationEvent.SIGN_UP": "sign-up"; + /** @enum {string} */ + "NotificationEvent.NEW_POST": "new-post"; + NotificationPayload: { + /** Format: double */ + timestamp?: number; + } & ({ + data: components["schemas"]["NotificationSendGeneral"]; + event: components["schemas"]["NotificationEvent.GENERAL"]; + } | { + data: { + email: string; + name: string; + }; + event: components["schemas"]["NotificationEvent.SIGN_UP"]; + } | { + data: { + title: string; + author: string; + }; + event: components["schemas"]["NotificationEvent.NEW_POST"]; + }); + "Expand_Prisma.notificationsCreateManyInput-and-_metadata_63_-GenericObject__": { + id?: string; + created_at?: string | (Date | undefined); + updated_at?: string | (Date | undefined); + title: string; + body: string; + image?: string; + link?: string; + metadata?: (components["schemas"]["Prisma.NullableJsonNullValueInput"] | components["schemas"]["InputJsonValue"]) & components["schemas"]["GenericObject"]; + event: string; + }; + Notification: components["schemas"]["Expand_Prisma.notificationsCreateManyInput-and-_metadata_63_-GenericObject__"]; + PaginationSortResponse_Notification_: { + /** Format: double */ + total?: number; + list: components["schemas"]["Notification"][]; + }; + APIResponse_PaginationSortResponse_Notification__: { + data: components["schemas"]["PaginationSortResponse_Notification_"] | null; + error: string | null; + }; + /** @enum {string} */ + "SortFields_Notification.created_at_": "created_at"; + NotificationSortFields: components["schemas"]["SortFields_Notification.created_at_"]; + NotificationFetchList: { + sortOrder?: components["schemas"]["SortOrder"]; + sortField?: components["schemas"]["NotificationSortFields"]; + /** Format: double */ + limit?: number; + /** Format: double */ + page?: number; + }; + /** @description Construct a type with a set of properties K of type T */ + "Record_string.string_": { + [key: string]: string; + }; + FileWithImgVariants: { + img_sizes?: components["schemas"]["Record_string.string_"]; + url: string; + }; + APIResponse_FileWithImgVariants_: { + data: components["schemas"]["FileWithImgVariants"] | null; + error: string | null; + }; + FileDelete: { + url: string; + }; + /** @enum {string} */ + "Prisma.ModelName": "otps" | "posts" | "users" | "notifications" | "userNotifications"; + /** @enum {string} */ + IMAGE_SIZE: "small" | "medium" | "large"; + Resizeconfig: { + sizes: components["schemas"]["IMAGE_SIZE"][]; + img_field: string; + record_id: string; + model: components["schemas"]["Prisma.ModelName"]; + }; + FileWebhookHandleResize: { + resize_config: components["schemas"]["Resizeconfig"]; + url: string; + }; + AuthLoginResponse: { + refreshToken: string; + accessToken: string; + user: components["schemas"]["SanitizedUser"]; + }; + APIResponse_AuthLoginResponse_: { + data: components["schemas"]["AuthLoginResponse"] | null; + error: string | null; + }; + AuthLogin: { + password: string; + email: string; + }; + AuthVerifyEmail: { + email: string; + otp: string; + }; + AuthForgotPass: { + hcaptcha_token?: string; + email: string; + }; + AuthResetPass: { + password: string; + otp: string; + email: string; + }; + AuthChangePass: { + newPassword: string; + oldPassword: string; + }; + /** @description From T, pick a set of properties whose keys are in the union K */ + "Pick_AuthLoginResponse.Exclude_keyofAuthLoginResponse.user__": { + accessToken: string; + refreshToken: string; + }; + /** @description Construct a type with the properties of T except for those in type K. */ + "Omit_AuthLoginResponse.user_": components["schemas"]["Pick_AuthLoginResponse.Exclude_keyofAuthLoginResponse.user__"]; + "APIResponse_Omit_AuthLoginResponse.user__": { + data: components["schemas"]["Omit_AuthLoginResponse.user_"] | null; + error: string | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + UserFetch: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_SanitizedUser_"]; + }; + }; + }; + }; + UserRemove: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_SanitizedUser_"]; + }; + }; + }; + }; + UserUpdate: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdate"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_SanitizedUser_"]; + }; + }; + }; + }; + UserFetchList: { + parameters: { + query?: { + sortOrder?: components["schemas"]["SortOrder"]; + sortField?: components["schemas"]["UserSortFields"]; + limit?: number; + page?: number; + search?: string; + role?: components["schemas"]["Role"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_PaginationSortResponse_SanitizedUser__"]; + }; + }; + }; + }; + UserCreate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserCreate"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_SanitizedUser_"]; + }; + }; + }; + }; + ProfileFetch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_SanitizedUser_"]; + }; + }; + }; + }; + ProfileDelete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_SanitizedUser_"]; + }; + }; + }; + }; + ProfileUpdate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + name?: string; + bio?: string; + added_fcm_token?: string; + removed_fcm_token?: string; + /** Format: binary */ + photo?: File; + }; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_SanitizedUser_"]; + }; + }; + }; + }; + PostFetch: { + parameters: { + query?: never; + header?: never; + path: { + postId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_PostType_"]; + }; + }; + }; + }; + PostRemove: { + parameters: { + query?: never; + header?: never; + path: { + postId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_PostType_"]; + }; + }; + }; + }; + PostUpdate: { + parameters: { + query?: never; + header?: never; + path: { + postId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PostUpdate"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_PostType_"]; + }; + }; + }; + }; + PostFetchList: { + parameters: { + query?: { + sortOrder?: components["schemas"]["SortOrder"]; + sortField?: components["schemas"]["PostSortFields"]; + limit?: number; + page?: number; + search?: string; + author_id?: string; + labels?: string[]; + populate?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_PaginationSortResponse_PostType__"]; + }; + }; + }; + }; + PostCreate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PostCreate"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_PostType_"]; + }; + }; + }; + }; + NotificationHandleTrigger: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NotificationPayload"] & { + taskMetadata: components["schemas"]["GenericObject"]; + }; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + NotificationSendGeneral: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NotificationSendGeneral"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + NotificationFetchList: { + parameters: { + query?: { + sortOrder?: components["schemas"]["SortOrder"]; + sortField?: components["schemas"]["NotificationSortFields"]; + limit?: number; + page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_PaginationSortResponse_Notification__"]; + }; + }; + }; + }; + NotificationMarkRead: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + MiscGetStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + MiscGetOpenApiSpec: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GenericObject"]; + }; + }; + }; + }; + MiscReloadSecrets: { + parameters: { + query: { + api_key: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + FileSave: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file?: File; + img_sizes?: string; + }; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_FileWithImgVariants_"]; + }; + }; + }; + }; + FileRemove: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FileDelete"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + FileHandleImageResize: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FileWebhookHandleResize"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + AuthLogin: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthLogin"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_AuthLoginResponse_"]; + }; + }; + }; + }; + AuthSignup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + name: string; + email: string; + password: string; + bio?: string; + hcaptcha_token?: string; + /** Format: binary */ + photo?: File; + }; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_SanitizedUser_"]; + }; + }; + }; + }; + AuthVerifyEmail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthVerifyEmail"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_AuthLoginResponse_"]; + }; + }; + }; + }; + AuthSignout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + AuthForgotPassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthForgotPass"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + AuthResetPassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthResetPass"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + AuthChangePassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthChangePass"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + AuthResendVerification: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthForgotPass"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Message_"]; + }; + }; + }; + }; + AuthRefresh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIResponse_Omit_AuthLoginResponse.user__"]; + }; + }; + }; + }; +} diff --git a/src/lib/utils/colors.ts b/src/lib/utils/colors.ts new file mode 100644 index 0000000..1a9a415 --- /dev/null +++ b/src/lib/utils/colors.ts @@ -0,0 +1,45 @@ +import { stringToNumberInRange } from "./string.utils"; + +export const colors = [ + "#FF5733", + "#00AABB", + "#FFC300", + "#FF0066", + "#22DD55", + "#FF3399", + "#FF9900", + "#44FFAA", + "#FF3366", + "#0088FF", + "#FFCC00", + "#DD33FF", + "#33FF99", + "#FF0033", + "#55AAFF", + "#FF6600", + "#FF00CC", + "#11FF44", + "#FF9933", + "#0099FF", +]; + +export const getColorFromStr = (value: string) => { + return colors[stringToNumberInRange(value, 0, colors.length - 1)]; +}; + +export const fadeColor = (hex: string, opacity: number) => { + // Remove the '#' character if present + hex = hex.replace(/^#/, ""); + + // Parse the hex value into individual RGB components + const bigint = parseInt(hex, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + + // Create the faded color in RGBA format + return `rgba(${r}, ${g}, ${b}, ${opacity})`; +}; + +export const getRandomColor = () => + colors[Math.floor(Math.random() * colors.length)]; diff --git a/src/lib/utils/dateTime.utils.ts b/src/lib/utils/dateTime.utils.ts new file mode 100644 index 0000000..145d88e --- /dev/null +++ b/src/lib/utils/dateTime.utils.ts @@ -0,0 +1,16 @@ +import dayjs from "dayjs"; +import { getRootContextValues } from "../contexts/root.context"; + +export const formattedDate = (date: dayjs.ConfigType) => { + return dayjs(date).toDate().toLocaleDateString(getRootContextValues().lang); +}; + +export const formattedTime = (date: dayjs.ConfigType) => { + return dayjs(date).toDate().toLocaleTimeString(getRootContextValues().lang); +}; + +export const formattedDateTime = (date: dayjs.ConfigType) => { + return date + ? dayjs(date).toDate().toLocaleString(getRootContextValues().lang) + : "-"; +}; diff --git a/src/lib/utils/error.utils.ts b/src/lib/utils/error.utils.ts index 76f67a9..40ffbbd 100644 --- a/src/lib/utils/error.utils.ts +++ b/src/lib/utils/error.utils.ts @@ -22,3 +22,5 @@ export const globalErrorHandler = (error: any) => { console.error(errorMsg, error); message.error(errorMsg); }; + +export const suppressError = () => {}; diff --git a/src/lib/utils/image.utils.ts b/src/lib/utils/image.utils.ts new file mode 100644 index 0000000..d305a10 --- /dev/null +++ b/src/lib/utils/image.utils.ts @@ -0,0 +1,11 @@ +export type IMAGE_SIZE = "small" | "medium" | "large"; + +export const isImage = (file?: File) => { + return file?.type?.startsWith("image/"); +}; + +export const sizedImg = (obj: T, field: keyof T, size: IMAGE_SIZE) => { + if (!obj) return ""; + + return (obj as any)[`${field.toString()}_sizes`]?.[size] || obj[field]; +}; diff --git a/src/lib/utils/misc.utils.ts b/src/lib/utils/misc.utils.ts index db35c8b..134f679 100644 --- a/src/lib/utils/misc.utils.ts +++ b/src/lib/utils/misc.utils.ts @@ -11,10 +11,38 @@ export const fakeApi = async ( return res; }; -export function isDev() { +export const isDev = () => { return import.meta.env.VITE_ENV === "dev"; -} +}; -export function isNullish(value: any) { +export const isNullish = (value: any) => { return [null, undefined, ""].includes(value); -} +}; + +export const fileNameFromUrl = (url: string) => { + return decodeURIComponent(url.split("?")?.[0]?.split("/").pop() || ""); +}; + +export const arrayExtend = (arr: Array, count: number) => { + if (count <= arr.length) return [...arr]; + return [...arr, ...Array(count - arr.length).fill({})]; +}; + +export const objectToFormData = ( + obj?: Record, + formData = new FormData(), + parentKey = "" +) => { + if (!obj) return formData; + + for (const key in obj) { + const propName = parentKey ? `${parentKey}[${key}]` : key; + if (obj[key] === undefined) continue; + if (typeof obj[key] === "object" && !(obj[key] instanceof File)) { + objectToFormData(obj[key], formData, propName); + } else { + formData.append(propName, obj[key]); + } + } + return formData; +}; diff --git a/src/lib/utils/number.utils.ts b/src/lib/utils/number.utils.ts new file mode 100644 index 0000000..7f50aa0 --- /dev/null +++ b/src/lib/utils/number.utils.ts @@ -0,0 +1,3 @@ +export const formattedNumber = (value: number) => { + return new Intl.NumberFormat("en-US").format(value); +}; diff --git a/src/lib/utils/string.utils.ts b/src/lib/utils/string.utils.ts index 38effd1..9057822 100644 --- a/src/lib/utils/string.utils.ts +++ b/src/lib/utils/string.utils.ts @@ -6,6 +6,13 @@ export const capitalize = (s: string) => { .join(" "); }; +export const kebabCaseToWords = (str: string) => { + return str + .split("-") + .map((word) => capitalize(word)) + .join(" "); +}; + export const randString = (len: number = 8) => window .btoa( @@ -15,3 +22,24 @@ export const randString = (len: number = 8) => ) .replace(/[+/]/g, "") .substring(0, len); + +export const stringToNumberInRange = ( + text: string, + min: number, + max: number +) => { + // Calculate a hash value from the input string + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash += text.charCodeAt(i); + } + + // Map the hash value to the desired range + const range = max - min + 1; + const mappedValue = ((hash % range) + range) % range; + + // Add the minimum value to get the final result within the range + const result = min + mappedValue; + + return result; +}; diff --git a/src/lib/utils/validations.ts b/src/lib/utils/validations.ts index 89ce242..70dcc9c 100755 --- a/src/lib/utils/validations.ts +++ b/src/lib/utils/validations.ts @@ -33,13 +33,13 @@ function getValidator( const Validations = { email: getValidator(isValidEmail, "Email is invalid"), - min_len: (len: number) => (_: any, str: string) => + minLen: (len: number) => (_: any, str: string) => getValidator(isValidMinLength, `Minimum ${len} characters are required`)( undefined, str, len ), - max_len: (len: number) => (_: any, str: string) => + maxLen: (len: number) => (_: any, str: string) => getValidator(isValidMaxLength, `Maximum ${len} characters are allowed`)( undefined, str, @@ -47,14 +47,10 @@ const Validations = { ), name: getValidator(isValidName, "Please enter a valid name"), phone: getValidator(isValidPhone, "Phone number is invalid"), - reqd_msg: (field: string) => `${field} is required`, -}; - -export const requiredRule = (name: string) => { - return { + requiredField: (name: string = "This field") => ({ required: true, - message: Validations.reqd_msg(name), - }; + message: `${name} is required`, + }), }; export default Validations; diff --git a/src/main.tsx b/src/main.tsx index 85b14b2..5cddea1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,14 +1,11 @@ import "@/lib/styles/global.css"; -import React from "react"; import ReactDOM from "react-dom/client"; import { Provider } from "react-redux"; import { store } from "./lib/redux/store"; import Router from "./lib/router/router"; ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - + + + ); diff --git a/src/modules/auth/hooks/role.hooks.ts b/src/modules/auth/hooks/role.hooks.ts index d07c25b..02bc4cc 100644 --- a/src/modules/auth/hooks/role.hooks.ts +++ b/src/modules/auth/hooks/role.hooks.ts @@ -1,5 +1,11 @@ import { useAppSelector } from "@/lib/redux/store"; +export enum Role { + USER = "user", + ADMIN = "admin", + SUPER_ADMIN = "super-admin", +} + export const useRole = () => { const role = useAppSelector((state) => state.profile.data?.role); return role; diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 06588e1..b917568 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -1,27 +1,56 @@ -import { fakeApi } from "@/lib/utils/misc.utils"; +import apiClient, { withApiResponseHandling } from "@/lib/openapi-fetch.config"; +import { + AuthChangePasswordBody, + AuthForgotPasswordBody, + AuthLoginBody, + AuthResetPasswordBody, +} from "../types/auth.types"; -async function login({ email }: { email: string; password: string }) { - return fakeApi(() => ({ - accessToken: email.toLowerCase(), - refreshToken: "refreshToken", - })) as Promise<{ accessToken: string; refreshToken: string }>; +async function login(payload: AuthLoginBody) { + const { data } = await withApiResponseHandling( + apiClient.POST("/auth/login", { body: payload }) + ); + return data; } -async function logout() { - return fakeApi(() => true) as Promise; +async function refreshToken(refreshToken: string) { + const { data } = await withApiResponseHandling( + apiClient.POST("/auth/refresh", { + headers: { + authorization: `Bearer ${refreshToken}`, + }, + }) + ); + return data; } -async function refreshToken() { - return fakeApi(() => ({ - accessToken: "accessToken", - refreshToken: "refreshToken", - })) as Promise<{ accessToken: string; refreshToken: string }>; +async function changePassword(payload: AuthChangePasswordBody) { + const { data: response } = await withApiResponseHandling( + apiClient.POST("/auth/change-password", { body: payload }) + ); + return response; +} + +async function forgotPassword(payload: AuthForgotPasswordBody) { + const { data: response } = await withApiResponseHandling( + apiClient.POST("/auth/forgot-password", { body: payload }) + ); + return response; +} + +async function resetPassword(payload: AuthResetPasswordBody) { + const { data: response } = await withApiResponseHandling( + apiClient.POST("/auth/reset-password", { body: payload }) + ); + return response; } const authService = { login, - logout, refreshToken, + changePassword, + forgotPassword, + resetPassword, }; export default authService; diff --git a/src/modules/auth/services/profile.service.ts b/src/modules/auth/services/profile.service.ts index e534a7e..d51e3d8 100644 --- a/src/modules/auth/services/profile.service.ts +++ b/src/modules/auth/services/profile.service.ts @@ -1,22 +1,25 @@ -import { fakeApi } from "@/lib/utils/misc.utils"; -import { Profile } from "../types/profile.type"; +import apiClient, { withApiResponseHandling } from "@/lib/openapi-fetch.config"; +import { objectToFormData } from "@/lib/utils/misc.utils"; +import { ProfileUpdateBody } from "../types/profile.types"; async function fetch() { - const email = localStorage.getItem("accessToken"); - return fakeApi( - () => ({ - id: "1", - name: "John Doe", - email, - role: email?.includes("admin") ? "admin" : "user", - picture: "https://via.placeholder.com/150", - }), - { errorRate: 0 } - ) as Promise; + const { data } = await withApiResponseHandling(apiClient.GET("/profile")); + return data; +} + +async function update(payload: ProfileUpdateBody) { + const { data } = await withApiResponseHandling( + apiClient.PATCH("/profile", { + body: payload, + bodySerializer: objectToFormData, + }) + ); + return data; } const profileService = { fetch, + update, }; export default profileService; diff --git a/src/modules/auth/slices/auth.slice.ts b/src/modules/auth/slices/auth.slice.ts index 0d1a772..0102ac3 100644 --- a/src/modules/auth/slices/auth.slice.ts +++ b/src/modules/auth/slices/auth.slice.ts @@ -12,12 +12,18 @@ const initialState: { status: "processing" | "authenticated" | "unauthenticated"; loginStatus: ThunkStatus; logoutStatus: ThunkStatus; + changePasswordStatus: ThunkStatus; + forgotPasswordStatus: ThunkStatus; + resetPasswordStatus: ThunkStatus; } = { computedRoutes: undefined, sidebarCollapsed: false, status: "processing", loginStatus: ThunkStatus.IDLE, logoutStatus: ThunkStatus.IDLE, + changePasswordStatus: ThunkStatus.IDLE, + forgotPasswordStatus: ThunkStatus.IDLE, + resetPasswordStatus: ThunkStatus.IDLE, }; const login = createAsyncThunk( @@ -29,12 +35,27 @@ const login = createAsyncThunk( return res; } ); + const logout = createAsyncThunk(`${name}/logout`, async () => { - await authService.logout(); localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); }); +const changePassword = createAsyncThunk( + `${name}/changePassword`, + authService.changePassword +); + +const forgotPassword = createAsyncThunk( + `${name}/forgotPassword`, + authService.forgotPassword +); + +const resetPassword = createAsyncThunk( + `${name}/resetPassword`, + authService.resetPassword +); + //slice export const authSlice = createSlice({ name, @@ -61,4 +82,11 @@ export const authSlice = createSlice({ }); //action creators -export const authActions = { ...authSlice.actions, login, logout }; +export const authActions = { + ...authSlice.actions, + login, + logout, + changePassword, + forgotPassword, + resetPassword, +}; diff --git a/src/modules/auth/slices/profile.slice.ts b/src/modules/auth/slices/profile.slice.ts index d586564..44b0b10 100644 --- a/src/modules/auth/slices/profile.slice.ts +++ b/src/modules/auth/slices/profile.slice.ts @@ -1,7 +1,7 @@ import { ThunkStatus } from "@/lib/types/misc"; import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import profileService from "../services/profile.service"; -import { Profile } from "../types/profile.type"; +import { Profile } from "../types/profile.types"; export const name = "profile"; @@ -9,24 +9,32 @@ export const name = "profile"; const initialState: { data?: Profile; fetchStatus: ThunkStatus; + updateStatus: ThunkStatus; } = { data: undefined, fetchStatus: ThunkStatus.IDLE, + updateStatus: ThunkStatus.IDLE, }; const fetch = createAsyncThunk(`${name}/fetch`, profileService.fetch); +const update = createAsyncThunk(`${name}/update`, profileService.update); + //slice export const profileSlice = createSlice({ name, initialState, reducers: {}, extraReducers: (builder) => { - builder.addCase(fetch.fulfilled, (state, action) => { - state.data = action.payload; - }); + builder + .addCase(fetch.fulfilled, (state, action) => { + state.data = action.payload; + }) + .addCase(update.fulfilled, (state, action) => { + state.data = action.payload; + }); }, }); //action creators -export const profileActions = { ...profileSlice.actions, fetch }; +export const profileActions = { ...profileSlice.actions, fetch, update }; diff --git a/src/modules/auth/types/auth.types.ts b/src/modules/auth/types/auth.types.ts new file mode 100644 index 0000000..f16ec5d --- /dev/null +++ b/src/modules/auth/types/auth.types.ts @@ -0,0 +1,7 @@ +import { ApiBody } from "@/lib/types/api"; + +// Request types +export type AuthLoginBody = ApiBody<"AuthLogin">; +export type AuthForgotPasswordBody = ApiBody<"AuthForgotPassword">; +export type AuthResetPasswordBody = ApiBody<"AuthResetPassword">; +export type AuthChangePasswordBody = ApiBody<"AuthChangePassword">; diff --git a/src/modules/auth/types/profile.type.ts b/src/modules/auth/types/profile.type.ts deleted file mode 100644 index 73517ff..0000000 --- a/src/modules/auth/types/profile.type.ts +++ /dev/null @@ -1,12 +0,0 @@ -export enum Role { - ADMIN = "admin", - USER = "user", -} - -export type Profile = { - id: string; - name: string; - email: string; - role: Role; - picture?: string; -}; diff --git a/src/modules/auth/types/profile.types.ts b/src/modules/auth/types/profile.types.ts new file mode 100644 index 0000000..d402973 --- /dev/null +++ b/src/modules/auth/types/profile.types.ts @@ -0,0 +1,7 @@ +import { ApiBody, ApiResponse } from "@/lib/types/api"; + +// Response types +export type Profile = ApiResponse<"ProfileFetch">; + +// Request types +export type ProfileUpdateBody = ApiBody<"ProfileUpdate">; diff --git a/src/modules/file/services/file.service.ts b/src/modules/file/services/file.service.ts new file mode 100644 index 0000000..b5f90e1 --- /dev/null +++ b/src/modules/file/services/file.service.ts @@ -0,0 +1,29 @@ +import apiClient, { withApiResponseHandling } from "@/lib/openapi-fetch.config"; +import { objectToFormData } from "@/lib/utils/misc.utils"; +import { FileRemoveBody, FileSaveBody } from "../types/file.types"; + +const save = async (payload: FileSaveBody) => { + const { data } = await withApiResponseHandling( + apiClient.POST("/files", { + body: payload, + bodySerializer: objectToFormData, + }) + ); + return data; +}; + +const remove = async (payload: FileRemoveBody) => { + const { data } = await withApiResponseHandling( + apiClient.DELETE("/files", { + body: payload, + }) + ); + return data; +}; + +const fileService = { + save, + remove, +}; + +export default fileService; diff --git a/src/modules/file/slices/file.slice.ts b/src/modules/file/slices/file.slice.ts new file mode 100644 index 0000000..e0d052a --- /dev/null +++ b/src/modules/file/slices/file.slice.ts @@ -0,0 +1,32 @@ +import { ThunkStatus } from "@/lib/types/misc"; +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import fileService from "../services/file.service"; + +export const name = "file"; + +//initial state +const initialState: { + saveStatus: ThunkStatus; + removeStatus: ThunkStatus; +} = { + saveStatus: ThunkStatus.IDLE, + removeStatus: ThunkStatus.IDLE, +}; + +const save = createAsyncThunk(`${name}/save`, fileService.save); + +const remove = createAsyncThunk(`${name}/remove`, fileService.remove); + +//slice +export const fileSlice = createSlice({ + name, + initialState, + reducers: {}, +}); + +//action creators +export const fileActions = { + ...fileSlice.actions, + save, + remove, +}; diff --git a/src/modules/file/types/file.types.ts b/src/modules/file/types/file.types.ts new file mode 100644 index 0000000..1c36aa4 --- /dev/null +++ b/src/modules/file/types/file.types.ts @@ -0,0 +1,5 @@ +import { ApiBody } from "@/lib/types/api"; + +// Request types +export type FileSaveBody = ApiBody<"FileSave">; +export type FileRemoveBody = ApiBody<"FileRemove">; diff --git a/src/modules/posts/post.types.ts b/src/modules/posts/post.types.ts new file mode 100644 index 0000000..988c807 --- /dev/null +++ b/src/modules/posts/post.types.ts @@ -0,0 +1,4 @@ +import { ApiResponse } from "@/lib/types/api"; + +// Response types +export type Post = ApiResponse<"PostFetch">; diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts new file mode 100644 index 0000000..6dc4df4 --- /dev/null +++ b/src/modules/user/services/user.service.ts @@ -0,0 +1,48 @@ +import apiClient, { withApiResponseHandling } from "@/lib/openapi-fetch.config"; +import { UserCreateBody, UserUpdateBody } from "../types/user.types"; + +async function fetch(id: string) { + const { data } = await withApiResponseHandling( + apiClient.GET("/users/{userId}", { + params: { path: { userId: id } }, + }) + ); + return data; +} + +async function create(payload: UserCreateBody) { + const { data } = await withApiResponseHandling( + apiClient.POST("/users", { + body: payload, + }) + ); + return data; +} + +async function update(id: string, payload: UserUpdateBody) { + const { data } = await withApiResponseHandling( + apiClient.PATCH("/users/{userId}", { + params: { path: { userId: id } }, + body: payload, + }) + ); + return data; +} + +async function remove(id: string) { + const { data } = await withApiResponseHandling( + apiClient.DELETE("/users/{userId}", { + params: { path: { userId: id } }, + }) + ); + return data; +} + +const userService = { + fetch, + create, + update, + remove, +}; + +export default userService; diff --git a/src/modules/user/slices/user.slice.ts b/src/modules/user/slices/user.slice.ts new file mode 100644 index 0000000..3c6d6b9 --- /dev/null +++ b/src/modules/user/slices/user.slice.ts @@ -0,0 +1,47 @@ +import { ThunkStatus } from "@/lib/types/misc"; +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import userService from "../services/user.service"; +import { UserUpdateBody } from "../types/user.types"; + +export const name = "user"; + +//initial state +const initialState: { + fetchStatus: ThunkStatus; + createStatus: ThunkStatus; + updateStatus: ThunkStatus; + removeStatus: ThunkStatus; +} = { + fetchStatus: ThunkStatus.IDLE, + createStatus: ThunkStatus.IDLE, + updateStatus: ThunkStatus.IDLE, + removeStatus: ThunkStatus.IDLE, +}; + +const fetch = createAsyncThunk(`${name}/fetch`, userService.fetch); + +const create = createAsyncThunk(`${name}/create`, userService.create); + +const update = createAsyncThunk( + `${name}/update`, + ({ id, ...payload }: UserUpdateBody & { id: string }) => + userService.update(id, payload) +); + +const remove = createAsyncThunk(`${name}/remove`, userService.remove); + +//slice +export const userSlice = createSlice({ + name, + initialState, + reducers: {}, +}); + +//action creators +export const userActions = { + ...userSlice.actions, + fetch, + create, + update, + remove, +}; diff --git a/src/modules/user/types/user.types.ts b/src/modules/user/types/user.types.ts new file mode 100644 index 0000000..a9e4675 --- /dev/null +++ b/src/modules/user/types/user.types.ts @@ -0,0 +1,8 @@ +import { ApiBody, ApiResponse } from "@/lib/types/api"; + +// Response types +export type User = ApiResponse<"UserFetch">; + +// Request types +export type UserCreateBody = ApiBody<"UserCreate">; +export type UserUpdateBody = ApiBody<"UserUpdate">; diff --git a/src/pages/auth/forgot-password.tsx b/src/pages/auth/forgot-password.tsx new file mode 100644 index 0000000..da1e580 --- /dev/null +++ b/src/pages/auth/forgot-password.tsx @@ -0,0 +1,92 @@ +import Captcha from "@/lib/components/captcha/captcha"; +import { useIsLoading } from "@/lib/redux/enhancers/status.enhancer"; +import { useAppDispatch } from "@/lib/redux/store"; +import { suppressError } from "@/lib/utils/error.utils"; +import Validations from "@/lib/utils/validations"; +import { authActions } from "@/modules/auth/slices/auth.slice"; +import { AuthForgotPasswordBody } from "@/modules/auth/types/auth.types"; +import { MailOutlined } from "@ant-design/icons"; +import { Button, Form, Input, Typography } from "antd"; +import React, { useRef, useState } from "react"; +import { Link } from "react-router-dom"; + +interface Props {} + +const ForgotPassword: React.FC = () => { + const dispatch = useAppDispatch(); + const loading = useIsLoading("auth", "forgotPasswordStatus"); + const [isSuccess, setIsSuccess] = useState(false); + const email = useRef(); + + const onFinish = (values: AuthForgotPasswordBody) => { + dispatch(authActions.forgotPassword(values)) + .unwrap() + .then(() => { + email.current = values.email; + setIsSuccess(true); + }) + .catch(suppressError); + }; + + if (isSuccess) + return ( +
+ + Password reset instructions sent! + + + Check your inbox or spam folder. You will receive an email at{" "} + {email.current} with instructions on how to reset + your password. + +
+ ); + + return ( +
+ onFinish={onFinish}> +
+ + Enter your email to start the password reset process. You will + receive an email with instructions. + +
+ + + className="mb-1" + name="email" + rules={[ + Validations.requiredField("Email"), + { validator: Validations.email }, + ]} + validateFirst + > + } placeholder="Email" size="large" /> + + +
+ + Back to login + +
+ + + + + + + + + +
+ ); +}; + +export default ForgotPassword; diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index f1c02b0..7d2d1d8 100644 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -1,28 +1,27 @@ import Validations from "@/lib/utils/validations"; import { useAuth } from "@/modules/auth/hooks/auth.hooks"; -import { authActions } from "@/modules/auth/slices/auth.slice"; +import { AuthLoginBody } from "@/modules/auth/types/auth.types"; import { LockOutlined, MailOutlined } from "@ant-design/icons"; import { Button, Form, Input } from "antd"; import React from "react"; - -type FormValues = Parameters[0]; +import { Link } from "react-router-dom"; interface Props {} const Login: React.FC = () => { const { login, loginLoading } = useAuth(); - const onFinish = (values: FormValues) => { + const onFinish = (values: AuthLoginBody) => { login(values); }; return (
- onFinish={onFinish}> - onFinish={onFinish}> + name="email" rules={[ - { required: true, message: Validations.reqd_msg("Email") }, + Validations.requiredField("Email"), { validator: Validations.email }, ]} validateFirst @@ -30,11 +29,10 @@ const Login: React.FC = () => { } placeholder="Email" size="large" /> - name="password" - rules={[ - { required: true, message: Validations.reqd_msg("Password") }, - ]} + rules={[Validations.requiredField("Password")]} + className="mb-1" > } @@ -43,8 +41,19 @@ const Login: React.FC = () => { /> - - diff --git a/src/pages/auth/reset-password.tsx b/src/pages/auth/reset-password.tsx new file mode 100644 index 0000000..6eb6eed --- /dev/null +++ b/src/pages/auth/reset-password.tsx @@ -0,0 +1,124 @@ +import { useIsLoading } from "@/lib/redux/enhancers/status.enhancer"; +import { useAppDispatch } from "@/lib/redux/store"; +import { suppressError } from "@/lib/utils/error.utils"; +import Validations from "@/lib/utils/validations"; +import { authActions } from "@/modules/auth/slices/auth.slice"; +import { AuthResetPasswordBody } from "@/modules/auth/types/auth.types"; +import useUrlState from "@ahooksjs/use-url-state"; +import { LockOutlined } from "@ant-design/icons"; +import { Button, Form, Input, message, Typography } from "antd"; +import { omit } from "lodash"; +import React from "react"; +import { useNavigate } from "react-router-dom"; + +type ResetPasswordForm = Omit & { + confirmPassword: string; +}; + +interface Props {} + +const ResetPassword: React.FC = () => { + const dispatch = useAppDispatch(); + const loading = useIsLoading("auth", "resetPasswordStatus"); + const [{ email, otp }] = useUrlState(); + const navigate = useNavigate(); + + const onFinish = (values: ResetPasswordForm) => { + dispatch( + authActions.resetPassword({ ...omit(values, ["confirmPassword"]), email }) + ) + .unwrap() + .then(() => { + message.success("Your password has been updated successfully!"); + navigate("/login"); + }) + .catch(suppressError); + }; + + if (!email) { + return ( +
+ Invalid link + + The link you used to reset your password is invalid or has expired. + +
+ ); + } + + return ( +
+
+ + Enter your new password below for email {email}. Make + sure to remember it for future logins. + +
+ + + onFinish={onFinish} + layout="vertical" + requiredMark={false} + > + + name="otp" + rules={[Validations.requiredField("Recovery code")]} + initialValue={otp} + label={otp ? "Recovery code" : undefined} + > + + + + + name="password" + rules={[ + Validations.requiredField("Password"), + { validator: Validations.minLen(6) }, + ]} + validateFirst + > + } + placeholder="New password" + size="large" + /> + + + + name="confirmPassword" + rules={[ + Validations.requiredField(), + ({ getFieldValue }) => ({ + validator(_, value) { + if (!value || getFieldValue("password") === value) { + return Promise.resolve(); + } + return Promise.reject("Passwords do not match"); + }, + }), + ]} + validateFirst + > + } + placeholder="Confirm new password" + size="large" + /> + + + + + + +
+ ); +}; + +export default ResetPassword; diff --git a/src/pages/home/home.tsx b/src/pages/home/home.tsx deleted file mode 100644 index aaac60d..0000000 --- a/src/pages/home/home.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import PageHeading from "@/lib/components/page-heading/page-heading"; -import { useLang } from "@/lib/contexts/root.context"; -import React from "react"; - -interface Props {} - -const Home: React.FC = () => { - const { t } = useLang(); - - return ( -
- {t("sidebar:home")} - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla quam velit, - vulputate eu pharetra nec, mattis ac neque. Duis vulputate commodo lectus, - ac blandit elit tincidunt id. Sed rhoncus, tortor sed eleifend tristique, - tortor mauris molestie elit, et luctus enim justo sit amet diam. Vivamus - at dui a mi lobortis pretium et eu quam. Suspendisse nec erat eu nunc - porttitor luctus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Proin nec purus magna. Aenean non mauris in velit mollis posuere. - Phasellus a tristique ligula. Quisque non urna ut sapien semper dictum. - Nulla facilisi. Aenean non pharetra nisl. Proin accumsan, nulla sed - feugiat vestibulum, nulla dui iaculis justo, a egestas nisi turpis vel - metus. Phasellus viverra nisl vitae risus lacinia eget aliquam ipsum - rhoncus. Suspendisse et urna. Integer semper, sem venenatis ultrices - vulputate, purus dui egestas massa, a commodo dui dolor non purus. Nam ut - est vel lacus tincidunt elementum. Nulla facilisi. Sed tincidunt, lorem in - lacinia congue, magna sem congue dui, et convallis libero dui a tortor. - Nulla facilisi. Sed tincidunt, lorem in lacinia congue, magna sem congue - dui, et convallis libero dui a tortor. Nulla facilisi. Sed tincidunt, - lorem in lacinia congue, magna sem congue dui, et convallis libero dui a - tortor. Nulla facilisi. Sed tincidunt, lorem in lacinia congue, magna sem - congue dui, et convallis libero dui a tortor. Nulla facilisi. Sed - tincidunt, lorem in lacinia congue, magna sem congue dui, et convallis - libero dui a tortor. Nulla facilisi. Sed tincidunt, lorem in lacinia - congue, magna sem congue dui, et convallis libero dui a tortor. Nulla - facilisi. -
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla quam velit, - vulputate eu pharetra nec, mattis ac neque. Duis vulputate commodo lectus, - ac blandit elit tincidunt id. Sed rhoncus, tortor sed eleifend tristique, - tortor mauris molestie elit, et luctus enim justo sit amet diam. Vivamus - at dui a mi lobortis pretium et eu quam. Suspendisse nec erat eu nunc - porttitor luctus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Proin nec purus magna. Aenean non mauris in velit mollis posuere. - Phasellus a tristique ligula. Quisque non urna ut sapien semper dictum. - Nulla facilisi. Aenean non pharetra nisl. Proin accumsan, nulla sed - feugiat vestibulum, nulla dui iaculis justo, a egestas nisi turpis vel - metus. Phasellus viverra nisl vitae risus lacinia eget aliquam ipsum - rhoncus. Suspendisse et urna. Integer semper, sem venenatis ultrices - vulputate, purus dui egestas massa, a commodo dui dolor non purus. Nam ut -
- ); -}; - -export default Home; diff --git a/src/pages/posts/posts.tsx b/src/pages/posts/posts.tsx new file mode 100644 index 0000000..50adff2 --- /dev/null +++ b/src/pages/posts/posts.tsx @@ -0,0 +1,73 @@ +import PageHeading from "@/lib/components/page-heading/page-heading"; +import ServerPaginatedTable from "@/lib/components/server-paginated-table/server-paginated-table"; +import { useLang } from "@/lib/contexts/root.context"; +import { getColorFromStr } from "@/lib/utils/colors"; +import { formattedDateTime } from "@/lib/utils/dateTime.utils"; +import { Post } from "@/modules/posts/post.types"; +import { Tag, Typography } from "antd"; +import React from "react"; +import UserAvatar from "../users/components/user-avatar"; + +interface Props {} + +const Posts: React.FC = () => { + const { t } = useLang(); + + return ( +
+ {t("sidebar:posts")} + + url="posts?populate=true" + columns={[ + { + title: "Title", + dataIndex: "title", + ellipsis: true, + fixed: "left", + sorter: true, + }, + { + title: "Date", + dataIndex: "created_at", + sorter: true, + defaultSortOrder: "descend", + render: (_, record) => ( + + {formattedDateTime(record.created_at)} + + ), + }, + { + title: "Author", + dataIndex: "author", + render: (_, record) => , + }, + { + title: "Labels", + dataIndex: "labels", + render: (_, record) => + record.labels.map((label, ix) => ( + + {label} + + )), + }, + { title: "Views", dataIndex: "views", sorter: true }, + { + title: "Content", + dataIndex: "text", + width: 400, + render: (_, record) => ( + + {record.text.slice(0, 110)} + {record.text.length > 110 ? "..." : ""} + + ), + }, + ]} + /> +
+ ); +}; + +export default Posts; diff --git a/src/pages/sample-page/sample-page.tsx b/src/pages/sample-page/sample-page.tsx new file mode 100644 index 0000000..3f608b6 --- /dev/null +++ b/src/pages/sample-page/sample-page.tsx @@ -0,0 +1,19 @@ +import PageHeading from "@/lib/components/page-heading/page-heading"; +import { Typography } from "antd"; +import React from "react"; + +interface Props { + title: string; +} + +const SamplePage: React.FC = ({ title }) => { + return ( +
+ {title} + + This is just a sample page +
+ ); +}; + +export default SamplePage; diff --git a/src/pages/settings/settings.tsx b/src/pages/settings/settings.tsx new file mode 100644 index 0000000..5aac8d3 --- /dev/null +++ b/src/pages/settings/settings.tsx @@ -0,0 +1,149 @@ +import ImagePicker from "@/lib/components/image-picker/image-picker"; +import PageSpinner from "@/lib/components/spinner/page-spinner"; +import { useIsLoading } from "@/lib/redux/enhancers/status.enhancer"; +import { useAppDispatch, useAppSelector } from "@/lib/redux/store"; +import { Modify } from "@/lib/types/misc"; +import { suppressError } from "@/lib/utils/error.utils"; +import { sizedImg } from "@/lib/utils/image.utils"; +import Validations from "@/lib/utils/validations"; +import { authActions } from "@/modules/auth/slices/auth.slice"; +import { profileActions } from "@/modules/auth/slices/profile.slice"; +import { AuthChangePasswordBody } from "@/modules/auth/types/auth.types"; +import { Profile } from "@/modules/auth/types/profile.types"; +import { Button, Form, Input, message, Typography } from "antd"; +import { omit } from "lodash"; +import React from "react"; + +type ProfileForm = Modify; + +type ChangePasswordForm = AuthChangePasswordBody & { + confirmPassword: string; +}; + +interface Props {} + +const Settings: React.FC = () => { + const user = useAppSelector((state) => state.profile.data); + const dispatch = useAppDispatch(); + const updateProfileLoading = useIsLoading("profile", "updateStatus"); + const changePasswordLoading = useIsLoading("auth", "changePasswordStatus"); + + const onUpdateProfile = (values: ProfileForm) => { + dispatch( + profileActions.update({ + ...values, + photo: values.photo[0] instanceof File ? values.photo[0] : undefined, + }) + ) + .unwrap() + .then(() => message.success("Profile updated successfully")) + .catch(suppressError); + }; + + const onChangePassword = (values: ChangePasswordForm) => { + dispatch(authActions.changePassword(omit(values, ["confirmPassword"]))) + .unwrap() + .then(() => message.success("Password updated successfully")) + .catch(suppressError); + }; + + if (!user) return ; + + return ( +
+ + Update Profile + + + layout="vertical" + className="w-96" + initialValues={{ ...user, photo: [sizedImg(user, "photo", "small")] }} + onFinish={onUpdateProfile} + > + name="photo"> + + + name="name"> + + + name="bio"> + + + + + + + + + Update Password + + + layout="vertical" + className="w-96" + onFinish={onChangePassword} + > + + name="oldPassword" + label="Old Password" + rules={[Validations.requiredField()]} + > + + + + name="newPassword" + label="New Password" + rules={[ + Validations.requiredField(), + { validator: Validations.minLen(6) }, + ]} + validateFirst + > + + + + name="confirmPassword" + label="Confirm Password" + rules={[ + Validations.requiredField(), + ({ getFieldValue }) => ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); + } + return Promise.reject("Passwords do not match"); + }, + }), + ]} + validateFirst + > + + + + + + +
+ ); +}; + +export default Settings; diff --git a/src/pages/users/components/user-avatar.tsx b/src/pages/users/components/user-avatar.tsx new file mode 100644 index 0000000..51aa4c9 --- /dev/null +++ b/src/pages/users/components/user-avatar.tsx @@ -0,0 +1,38 @@ +import ServerImg from "@/lib/components/server-img/server-img"; +import { kebabCaseToWords } from "@/lib/utils/string.utils"; +import { User } from "@/modules/user/types/user.types"; +import { Space, Typography } from "antd"; +import React from "react"; + +interface Props { + user: Pick; + imgPreview?: boolean; + showRole?: boolean; +} + +const UserAvatar: React.FC = ({ user, imgPreview, showRole }) => { + return ( + + +
+
+ + {user.name} + {showRole ? ` (${kebabCaseToWords(user.role ?? "")})` : ""} + +
+ + {user.email} + +
+
+ ); +}; + +export default UserAvatar; diff --git a/src/pages/users/components/user-edit-modal.tsx b/src/pages/users/components/user-edit-modal.tsx new file mode 100644 index 0000000..efd1041 --- /dev/null +++ b/src/pages/users/components/user-edit-modal.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +interface Props {} + +const UserEditModal: React.FC = () => { + return
; +}; + +export default UserEditModal; diff --git a/src/pages/users/modals/edit-user-modal.tsx b/src/pages/users/modals/edit-user-modal.tsx new file mode 100644 index 0000000..9298fca --- /dev/null +++ b/src/pages/users/modals/edit-user-modal.tsx @@ -0,0 +1,125 @@ +import CustomModal from "@/lib/components/custom-modal/custom-modal"; +import ImagePicker from "@/lib/components/image-picker/image-picker"; +import { useIsLoading } from "@/lib/redux/enhancers/status.enhancer"; +import { useAppDispatch } from "@/lib/redux/store"; +import { Modify } from "@/lib/types/misc"; +import { suppressError } from "@/lib/utils/error.utils"; +import { sizedImg } from "@/lib/utils/image.utils"; +import { kebabCaseToWords } from "@/lib/utils/string.utils"; +import Validations from "@/lib/utils/validations"; +import { Role } from "@/modules/auth/hooks/role.hooks"; +import { fileActions } from "@/modules/file/slices/file.slice"; +import { userActions } from "@/modules/user/slices/user.slice"; +import { User, UserUpdateBody } from "@/modules/user/types/user.types"; +import { Form, Input, message, Select } from "antd"; +import React, { ComponentProps, useEffect, useState } from "react"; + +type EditUserForm = Modify; + +type Props = ComponentProps & { + userId?: string; + onSuccess?: (user: User) => void; +}; + +const EditUserModal: React.FC = ({ + userId, + open, + onSuccess, + ...props +}) => { + const [form] = Form.useForm(); + const [submitLoading, setSubmitLoading] = useState(false); + const dispatch = useAppDispatch(); + const loading = useIsLoading("user", "fetchStatus"); + const isOpen = open && Boolean(userId); + + const onFinish = async (values: EditUserForm) => { + try { + setSubmitLoading(true); + + // upload photo if it's a file + let photo = values.photo?.[0]; + if (photo instanceof File) { + photo = (await dispatch(fileActions.save({ file: photo })).unwrap()) + .url; + } + + const updatedUser = await dispatch( + userActions.update({ ...values, id: userId!, photo }) + ).unwrap(); + + message.success("User updated successfully"); + form.resetFields(); + onSuccess?.(updatedUser); + } catch (error) { + } finally { + setSubmitLoading(false); + } + }; + + useEffect(() => { + if (isOpen) { + dispatch(userActions.fetch(userId!)) + .unwrap() + .then((user) => { + form.setFieldsValue({ + ...user, + photo: [sizedImg(user, "photo", "small")], + }); + }) + .catch(suppressError); + } + }, [isOpen]); + + return ( + + onFinish={onFinish} form={form} layout="vertical"> + name="photo"> + + + + + name="name" + label="Name" + rules={[ + Validations.requiredField("name"), + { validator: Validations.minLen(3) }, + ]} + validateFirst + > + + + + name="bio" label="Bio"> + + + + + name="role" + label="Role" + rules={[Validations.requiredField("role")]} + > + + + + + name="email" + label="Email" + rules={[ + Validations.requiredField("email"), + { validator: Validations.email }, + ]} + validateFirst + > + + + + + name="role" + label="Role" + rules={[Validations.requiredField("role")]} + > +