first commit
This commit is contained in:
commit
922362dc6f
|
|
@ -0,0 +1,27 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-content
|
||||
dist-ssr
|
||||
auto-post-facebook-extensions
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
"eslint-disable-next-line react-refresh/only-export-components": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"eslint-disable no-case-declarations": "off",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"name": "auto-post-facebook-extensions",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"build:all": "vite build --config vite.config.content.ts && vite build --config vite.config.popup.ts && vite build",
|
||||
"dev:build": "vite build --watch",
|
||||
"dev:content": "vite build --watch --config vite.config.content.ts",
|
||||
"dev:popup": "vite build --watch --config vite.config.popup.ts",
|
||||
"dev:main": "vite build --watch --config vite.config.ts",
|
||||
"dev:all": "run-p dev:content dev:popup dev:main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"fs-extra": "^11.3.1",
|
||||
"lucide-react": "^0.540.0",
|
||||
"p-queue": "^8.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.9.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"webextension-polyfill": "^0.12.0",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/chrome": "^0.1.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/webextension-polyfill": "^0.12.3",
|
||||
"@vitejs/plugin-react-swc": "^4.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Auto post facebook extensions",
|
||||
"version": "1.0",
|
||||
"action": {
|
||||
"default_popup": "index.html"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content/content.js"],
|
||||
"type": "module"
|
||||
}
|
||||
],
|
||||
|
||||
"permissions": ["storage", "tabs", "activeTab", "scripting"]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,27 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-content
|
||||
dist-ssr
|
||||
auto-post-facebook-extensions
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
[
|
||||
{
|
||||
"title": "Cisco 2911 Voice Bundle, Pvdm3-16, Uc License Pak, Fl- Cube10 - CISCO2911-V/K9",
|
||||
"price": 3595,
|
||||
"el": {},
|
||||
"date": "2025-08-21T03:25:51.274Z"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,848 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"type-is": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"content-disposition": "^1.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"merge-descriptors": "^2.0.0",
|
||||
"mime-types": "^3.0.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"qs": "^6.14.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.0.1",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"parseurl": "^1.3.3",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
|
||||
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"is-promise": "^4.0.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"path-to-regexp": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"parseurl": "^1.3.3",
|
||||
"send": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import express from "express";
|
||||
import fs from "fs";
|
||||
import bodyParser from "body-parser";
|
||||
import cors from "cors";
|
||||
|
||||
const app = express();
|
||||
|
||||
// Cho phép tất cả origin gọi API
|
||||
app.use(cors());
|
||||
|
||||
// parse JSON body
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// API lưu dữ liệu
|
||||
app.post("/sync", (req, res) => {
|
||||
const data = req.body;
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return res.status(400).json({ error: "Dữ liệu phải là array" });
|
||||
}
|
||||
|
||||
// Thêm field date cho mỗi item
|
||||
const mapped = data.map((item) => ({
|
||||
...item,
|
||||
date: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// Ghi xuống file
|
||||
fs.writeFileSync("data.json", JSON.stringify(mapped, null, 2));
|
||||
|
||||
res.json({ message: "Đã lưu dữ liệu thành công", saved: mapped.length });
|
||||
});
|
||||
|
||||
// API đọc lại dữ liệu
|
||||
app.get("/data", (req, res) => {
|
||||
if (!fs.existsSync("data.json")) {
|
||||
return res.json([]);
|
||||
}
|
||||
const content = fs.readFileSync("data.json", "utf-8");
|
||||
res.json(JSON.parse(content));
|
||||
});
|
||||
|
||||
app.listen(3000, () => {
|
||||
console.log("Server chạy tại http://localhost:3000");
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import axios from "@/lib/axios";
|
||||
import type { IPost, ISyncItem } from "@/lib/utils";
|
||||
|
||||
class ProductApiService {
|
||||
item_per_page = 10;
|
||||
|
||||
token =
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2ludC5pcHN1cHBseS5jb20uYXUvYXBpL2xvZ2luIiwiaWF0IjoxNzIxNjA5MTEzLCJleHAiOjMyMzUzMzcxMTMsIm5iZiI6MTcyMTYwOTExMywianRpIjoiWHpCZkZPc0ZJUlFxaEZvaiIsInN1YiI6MSwicHJ2IjoiYzhlZTFmYzg5ZTc3NWVjNGM3Mzg2NjdlNWJlMTdhNTkwYjZkNDBmYyJ9.kFFEPpdmNUp-kn_G3cIIc26bivO6tbfcvkpG9I8Im7M";
|
||||
|
||||
async index(filter?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
order?: string;
|
||||
where?: Record<string, any>;
|
||||
}) {
|
||||
const defaultFilter = {
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
order: "updatedAt desc",
|
||||
where: {
|
||||
account: "prology_net",
|
||||
status: "Updated",
|
||||
},
|
||||
};
|
||||
|
||||
return axios({
|
||||
url: "transferGetData",
|
||||
headers: {
|
||||
Authorization: "Bearer " + this.token,
|
||||
},
|
||||
method: "POST",
|
||||
data: {
|
||||
urlAPI: "/api/ebay-listing/listing-get-list",
|
||||
filter: {
|
||||
...defaultFilter,
|
||||
...filter, // merge filter vào defaultFilter
|
||||
where: {
|
||||
...defaultFilter.where,
|
||||
...filter?.where, // merge where riêng để không mất account/status mặc định
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async get(data: IPost) {
|
||||
return axios({
|
||||
url: "transferGetData",
|
||||
headers: {
|
||||
Authorization: "Bearer " + this.token,
|
||||
},
|
||||
method: "POST",
|
||||
data: {
|
||||
urlAPI: "/api/product-model/get-info/" + data.id,
|
||||
pageCurrent: "/productdata/products/edit/" + data.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sync(data: ISyncItem[]) {
|
||||
return axios({
|
||||
baseURL: import.meta.env.VITE_API_SYNC_URL,
|
||||
url: "sync",
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async getPublistedProducts() {
|
||||
return axios({
|
||||
baseURL: import.meta.env.VITE_API_SYNC_URL,
|
||||
url: "data",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const productApi = new ProductApiService();
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1,34 @@
|
|||
// background.ts
|
||||
import { BackgroundService } from "@/services/background.service";
|
||||
import PQueue from "p-queue";
|
||||
|
||||
// Hàng đợi để xử lý tuần tự
|
||||
const queue = new PQueue({ concurrency: 1 });
|
||||
|
||||
const service = new BackgroundService();
|
||||
|
||||
// Forward hoặc xử lý logic
|
||||
service.msgService.on("popup-to-content", (payload) => {
|
||||
console.log("Background nhận từ popup:", payload);
|
||||
service.msgService.send("content", "popup-to-content", payload);
|
||||
|
||||
switch (payload?.type) {
|
||||
case "publist": {
|
||||
queue.add(() => service.publishJob(payload.data, 60000));
|
||||
break;
|
||||
}
|
||||
case "unlist": {
|
||||
queue.add(() => service.unlistJob(payload.data, 60000));
|
||||
break;
|
||||
}
|
||||
case "re-publist": {
|
||||
queue.add(() => service.rePublistJob(payload.data, 60000));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
service.msgService.on("content-to-popup", (payload) => {
|
||||
console.log("Background nhận từ content:", payload);
|
||||
service.msgService.send("popup", "content-to-popup", payload);
|
||||
});
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "../ui/button";
|
||||
import { delay } from "@/features/app";
|
||||
import Loader from "../loader";
|
||||
|
||||
export function ConfirmAlert({
|
||||
children,
|
||||
title = "Are you sure ?",
|
||||
description = "This action cannot be undone.",
|
||||
onConfirm,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
description?: string | ReactNode;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
// Set loading
|
||||
setLoading(true);
|
||||
|
||||
// Set delay 0.5s cho cảm giác dễ chịu khi sử dụng
|
||||
await delay(500);
|
||||
|
||||
// Gọi callback
|
||||
await onConfirm?.();
|
||||
|
||||
// Tắt modal và loading
|
||||
setLoading(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
{loading && <Loader color="white" size="size-3" />}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,254 @@
|
|||
# 📊 `DataTable` Component
|
||||
|
||||
Component bảng dữ liệu mạnh mẽ, tùy biến cao, hỗ trợ các chức năng như tìm kiếm, lọc, phân trang, sắp xếp, hành động hàng loạt và hành động tùy chỉnh — được xây dựng bằng Tailwind, Shadcn UI, và Lucide Icons.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Cài đặt
|
||||
|
||||
```tsx
|
||||
import { DataTable } from "@/components/data-table"; // Đường dẫn có thể khác tùy theo cấu trúc dự án
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Props
|
||||
|
||||
### 1. `data: T[]`
|
||||
|
||||
Danh sách dữ liệu hiển thị.
|
||||
|
||||
### 2. `columns: Column<T>[]`
|
||||
|
||||
Cấu hình cột (xem bên dưới).
|
||||
|
||||
### 3. `searchKeys?: (keyof T)[]`
|
||||
|
||||
Các field dùng để tìm kiếm toàn cục.
|
||||
|
||||
### 4. `filterOptions?: FilterOption[]`
|
||||
|
||||
Cấu hình bộ lọc nâng cao (theo field, số, ngày, v.v.).
|
||||
|
||||
### 5. `pageSize?: number`
|
||||
|
||||
Số dòng mặc định mỗi trang (mặc định: 10).
|
||||
|
||||
### 6. `onPageSizeChange?: (size: number) => void`
|
||||
|
||||
Callback khi thay đổi `pageSize`.
|
||||
|
||||
### 7. `onRowClick?: (row: T) => void`
|
||||
|
||||
Sự kiện click vào 1 hàng.
|
||||
|
||||
### 8. `onView`, `onEdit`, `onDelete`
|
||||
|
||||
Callback khi người dùng chọn thao tác tương ứng từ menu hành động.
|
||||
|
||||
### 9. `selectable?: boolean`
|
||||
|
||||
Cho phép chọn nhiều dòng.
|
||||
|
||||
### 10. `bulkActions?: BulkAction<T>[]`
|
||||
|
||||
Hành động hàng loạt với các dòng được chọn.
|
||||
|
||||
### 11. `customActions?: CustomAction<T>[]`
|
||||
|
||||
Hành động tùy chỉnh cho từng dòng (menu 3 chấm).
|
||||
|
||||
### 12. `pagination?: PaginationData`
|
||||
|
||||
Phân trang điều khiển từ ngoài (server-side).
|
||||
|
||||
### 13. `onPaginationChange?: (pagination: PaginationData) => void`
|
||||
|
||||
Callback khi thay đổi phân trang.
|
||||
|
||||
### 14. `onSortChange`, `onFilterChange`, `onDataChange`
|
||||
|
||||
Callback khi sort / lọc / dữ liệu thay đổi.
|
||||
|
||||
### 15. `loading?: boolean`
|
||||
|
||||
Trạng thái đang tải dữ liệu.
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Column Definition
|
||||
|
||||
```ts
|
||||
interface Column<T> {
|
||||
key: keyof T;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
displayType?: "text" | "avatar" | "badge" | "currency" | ...;
|
||||
displayOptions?: object;
|
||||
render?: (value: any, row: T) => React.ReactNode;
|
||||
}
|
||||
```
|
||||
|
||||
> Hỗ trợ nhiều kiểu hiển thị: `text`, `password`, `image`, `badge`, `currency`, `boolean`, `link`, `email`, `tags`, `progress`, `status`, `rating`, `filesize`, `percentage`, `custom`, v.v.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Filter Options
|
||||
|
||||
```ts
|
||||
interface FilterOption {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "select" | "text" | "date" | "dateRange" | "number" | "numberRange";
|
||||
options?: { value: string; label: string }[];
|
||||
}
|
||||
```
|
||||
|
||||
> Hiển thị bộ lọc nâng cao thông qua dialog.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Bulk & Custom Actions
|
||||
|
||||
```ts
|
||||
interface BulkAction<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
action: (selected: T[]) => void;
|
||||
confirmMessage?: string;
|
||||
}
|
||||
|
||||
interface CustomAction<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
action: (row: T) => void;
|
||||
show?: (row: T) => boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Hiển thị Trạng Thái (`displayType: "status"`)
|
||||
|
||||
Dùng để hiển thị trạng thái dưới dạng badge màu. Ví dụ các trạng thái như `"active"`, `"inactive"`, `"pending"`.
|
||||
|
||||
### ✅ Cú pháp:
|
||||
|
||||
```ts
|
||||
{
|
||||
key: "status",
|
||||
label: "Trạng thái",
|
||||
displayType: "status",
|
||||
displayOptions: {
|
||||
statusMap: {
|
||||
active: {
|
||||
label: "Hoạt động",
|
||||
variant: "default",
|
||||
},
|
||||
inactive: {
|
||||
label: "Ngừng hoạt động",
|
||||
variant: "secondary",
|
||||
},
|
||||
pending: {
|
||||
label: "Đang chờ",
|
||||
variant: "outline",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 🎨 Tuỳ chọn nâng cao với màu cụ thể (dùng Tailwind):
|
||||
|
||||
```ts
|
||||
{
|
||||
key: "status",
|
||||
label: "Trạng thái",
|
||||
displayType: "status",
|
||||
displayOptions: {
|
||||
statusMap: {
|
||||
active: {
|
||||
label: "Hoạt động",
|
||||
variant: "default",
|
||||
color: "bg-green-100 text-green-800 border-green-200",
|
||||
},
|
||||
inactive: {
|
||||
label: "Ngừng hoạt động",
|
||||
variant: "secondary",
|
||||
color: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
},
|
||||
pending: {
|
||||
label: "Đang chờ",
|
||||
variant: "outline",
|
||||
color: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> 🔁 Nếu `statusMap` không được định nghĩa:
|
||||
>
|
||||
> - `label` sẽ là giá trị gốc (`value`) của field
|
||||
> - `variant` sẽ mặc định là `"default"`
|
||||
|
||||
---
|
||||
|
||||
## 📦 Ví dụ sử dụng
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={[
|
||||
{ key: "name", label: "Tên", sortable: true },
|
||||
{ key: "email", label: "Email", displayType: "email" },
|
||||
{
|
||||
key: "status",
|
||||
label: "Trạng thái",
|
||||
displayType: "status",
|
||||
displayOptions: {
|
||||
statusMap: {
|
||||
active: { label: "Hoạt động", variant: "default" },
|
||||
inactive: { label: "Ngừng hoạt động", variant: "secondary" },
|
||||
pending: { label: "Đang chờ", variant: "outline" },
|
||||
},
|
||||
},
|
||||
},
|
||||
]}
|
||||
searchKeys={["name", "email"]}
|
||||
filterOptions={[
|
||||
{
|
||||
key: "status",
|
||||
label: "Trạng thái",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Hoạt động" },
|
||||
{ value: "inactive", label: "Ngừng hoạt động" },
|
||||
{ value: "pending", label: "Đang chờ" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
selectable
|
||||
onEdit={(row) => console.log("Edit:", row)}
|
||||
onDelete={(row) => console.log("Delete:", row)}
|
||||
bulkActions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: "Xoá đã chọn",
|
||||
variant: "destructive",
|
||||
confirmMessage: "Bạn có chắc chắn muốn xoá những dòng này?",
|
||||
action: (rows) => console.log("Bulk delete:", rows),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ TODO
|
||||
|
||||
- Export CSV / Excel
|
||||
- Resizable columns
|
||||
- Grouping columns
|
||||
- Column reorder / drag-drop
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,82 @@
|
|||
interface FilterOption {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "select" | "text" | "date" | "dateRange" | "number" | "numberRange";
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface BulkAction<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
variant?: "default" | "secondary" | "destructive" | "outline";
|
||||
action: (selectedRows: T[]) => void;
|
||||
confirmMessage?: string;
|
||||
}
|
||||
|
||||
interface PaginationData {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
interface InitialState<T> {
|
||||
pagination?: {
|
||||
currentPage?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
search?: string;
|
||||
sort?: {
|
||||
key: keyof T;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
filters?: {
|
||||
select?: Record<string, string[]>;
|
||||
text?: Record<string, string>;
|
||||
date?: Record<string, Date>;
|
||||
dateRange?: Record<string, { from?: Date; to?: Date }>;
|
||||
number?: Record<string, number>;
|
||||
numberRange?: Record<string, { min?: number; max?: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface TableState<T> {
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
};
|
||||
search: string;
|
||||
sort: {
|
||||
key: keyof T | null;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
filters: {
|
||||
select: Record<string, string[]>;
|
||||
text: Record<string, string>;
|
||||
date: Record<string, Date | undefined>;
|
||||
dateRange: Record<string, { from?: Date; to?: Date }>;
|
||||
number: Record<string, number | undefined>;
|
||||
numberRange: Record<string, { min?: number; max?: number }>;
|
||||
};
|
||||
data: {
|
||||
filtered: T[];
|
||||
sorted: T[];
|
||||
paginated: T[];
|
||||
};
|
||||
selection: T[];
|
||||
}
|
||||
|
||||
interface SortOption<T> {
|
||||
key: keyof T;
|
||||
label: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import type { DetailedHTMLProps } from "react";
|
||||
|
||||
export interface ILoaderProps
|
||||
extends DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> {
|
||||
size?: string;
|
||||
showLabel?: boolean;
|
||||
color?: "black" | "white";
|
||||
}
|
||||
|
||||
export default function Loader({ color = "black", ...props }: ILoaderProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex items-center justify-center flex-col gap-4",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"animate-spin rounded-full size-4 border-b-2",
|
||||
props.size,
|
||||
{
|
||||
["border-primary"]: color === "black",
|
||||
["border-white"]: color === "white",
|
||||
}
|
||||
)}
|
||||
></div>
|
||||
|
||||
{props.showLabel && <span>Đang tải...</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { productApi } from "@/api/product-api.service";
|
||||
import { mapToIPost, type IPost } from "@/lib/utils";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { removeFalsyValues } from "@/features/app";
|
||||
import MessageService from "@/services/message.service";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Edit,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import Loader from "./loader";
|
||||
import ProductModal from "./product-modal";
|
||||
|
||||
export function PostTable() {
|
||||
const msg = new MessageService("popup");
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [conditionFilter, setConditionFilter] = useState("all");
|
||||
const [brandFilter, setBrandFilter] = useState("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => ["products", { currentPage, searchTerm }],
|
||||
[currentPage, searchTerm]
|
||||
);
|
||||
|
||||
// --- React Query fetch ---
|
||||
const { data: rawProducts, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const { data } = await productApi.index(
|
||||
removeFalsyValues({
|
||||
skip: (currentPage - 1) * productApi.item_per_page,
|
||||
where: {
|
||||
productModelCode: searchTerm,
|
||||
},
|
||||
})
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: publistedProducts } = useQuery({
|
||||
queryKey: ["publised-products"],
|
||||
queryFn: async () => {
|
||||
const { data } = await productApi.getPublistedProducts();
|
||||
return data ?? [];
|
||||
},
|
||||
staleTime: 0, // luôn coi là stale -> gọi lại API mỗi lần mount
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const data: IPost[] = useMemo(() => {
|
||||
if (!rawProducts || !rawProducts?.data) return [];
|
||||
return rawProducts?.data.map((item: any) => mapToIPost(item));
|
||||
}, [rawProducts]);
|
||||
|
||||
const formatPrice = (price: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(price);
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm("");
|
||||
setCategoryFilter("all");
|
||||
setConditionFilter("all");
|
||||
setBrandFilter("all");
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFiltersCount = [
|
||||
categoryFilter !== "all",
|
||||
conditionFilter !== "all",
|
||||
brandFilter !== "all",
|
||||
searchTerm !== "",
|
||||
].filter(Boolean).length;
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (!rawProducts?.total) return 0;
|
||||
|
||||
return Math.ceil(rawProducts.total / productApi.item_per_page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rawProducts?.total, productApi.item_per_page]);
|
||||
|
||||
// --- reset page when filter changes ---
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// useMemo(() => setCurrentPage(1), [filteredPosts.length]);
|
||||
|
||||
const from = useMemo(() => {
|
||||
return (currentPage - 1) * productApi.item_per_page + 1;
|
||||
}, [currentPage]);
|
||||
|
||||
const to = useMemo(() => {
|
||||
return Math.min(
|
||||
currentPage * productApi.item_per_page,
|
||||
rawProducts?.total ?? 0
|
||||
);
|
||||
}, [currentPage, rawProducts]);
|
||||
|
||||
// popup.ts
|
||||
const handleActionListing = async (data: IPost) => {
|
||||
const res = await productApi.get(data);
|
||||
|
||||
if (!res || !res.data?.data) return;
|
||||
|
||||
if (data.status) {
|
||||
msg.send("content", "popup-to-content", {
|
||||
type: "unlist",
|
||||
data: data,
|
||||
});
|
||||
} else {
|
||||
msg.send("content", "popup-to-content", {
|
||||
type: "publist",
|
||||
data: mapToIPost({ ...data, ...res.data?.data }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// const handleUnListing = async (data: IPost) => {
|
||||
// msg.send("content", "popup-to-content", {
|
||||
// type: "unlist",
|
||||
// data: data,
|
||||
// });
|
||||
// };
|
||||
|
||||
// const handleRePublist = async (data: IPost) => {
|
||||
// const res = await productApi.get(data);
|
||||
|
||||
// if (!res || !res.data?.data) return;
|
||||
|
||||
// msg.send("content", "popup-to-content", {
|
||||
// type: "re-publist",
|
||||
// data: mapToIPost({ ...data, ...res.data?.data }),
|
||||
// });
|
||||
// };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={isFilterModalOpen} onOpenChange={setIsFilterModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="relative bg-transparent">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
{activeFiltersCount > 0 && (
|
||||
<Badge className="ml-2 h-5 w-5 p-0 text-xs flex items-center justify-center">
|
||||
{activeFiltersCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Product Filters</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
value={conditionFilter}
|
||||
onValueChange={setConditionFilter}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Condition" />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
|
||||
<Select value={brandFilter} onValueChange={setBrandFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Brand" />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={clearFilters}
|
||||
className="flex-1 bg-transparent"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsFilterModalOpen(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
Showing {from}-{to} of {rawProducts?.total ?? 0} products
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-x-auto relative">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-[rgba(255,255,255,.6)] flex items-center justify-center z-10">
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table className="min-w-[500px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[200px]">Name</TableHead>
|
||||
<TableHead className="w-[80px]">Price</TableHead>
|
||||
<TableHead className="w-[80px]">Status</TableHead>
|
||||
<TableHead className="w-[80px] text-center sticky right-0 bg-background border-l shadow-[-4px_0_8px_rgba(0,0,0,0.1)]">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody className="relative">
|
||||
{data.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={10}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No products found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((post) => {
|
||||
const status = publistedProducts.some((item: any) =>
|
||||
item.title.includes(post.sku)
|
||||
);
|
||||
|
||||
post.status = status;
|
||||
|
||||
return (
|
||||
<TableRow key={post.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div
|
||||
className="truncate max-w-[200px] w-fit"
|
||||
title={post.title}
|
||||
>
|
||||
{post.title}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-muted-foreground truncate max-w-[200px]"
|
||||
title={post.description}
|
||||
>
|
||||
{post.description}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
{formatPrice(post.price)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{post?.status ? "Listed" : "Unlisted"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-center sticky right-0 bg-background border-l shadow-[-4px_0_8px_rgba(0,0,0,0.1)]">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 mx-auto"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<ProductModal data={post}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault(); // Ngăn dropdown đóng lại
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" /> Edit
|
||||
</DropdownMenuItem>
|
||||
</ProductModal>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleActionListing(post)}
|
||||
>
|
||||
{post.status ? (
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{post.status ? "Unlist" : "List"}
|
||||
</DropdownMenuItem>
|
||||
{/* <DropdownMenuItem onClick={() => handleUnListing(post)}>
|
||||
unList
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleRePublist(post)}>
|
||||
re publist
|
||||
</DropdownMenuItem> */}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{totalPages <= 7 ? (
|
||||
// Show all pages if 7 or fewer
|
||||
Array.from({ length: totalPages }, (_, i) => (
|
||||
<Button
|
||||
key={i + 1}
|
||||
variant={currentPage === i + 1 ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(i + 1)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
))
|
||||
) : (
|
||||
// Show pages with ellipsis for more than 7 pages
|
||||
<>
|
||||
{/* First page */}
|
||||
<Button
|
||||
variant={currentPage === 1 ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
1
|
||||
</Button>
|
||||
|
||||
{/* Left ellipsis */}
|
||||
{currentPage > 4 && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
|
||||
{/* Middle pages */}
|
||||
{Array.from({ length: 3 }, (_, i) => {
|
||||
let pageNum;
|
||||
if (currentPage <= 4) {
|
||||
pageNum = i + 2;
|
||||
} else if (currentPage >= totalPages - 3) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 1 + i;
|
||||
}
|
||||
|
||||
if (pageNum > 1 && pageNum < totalPages) {
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={
|
||||
currentPage === pageNum ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean)}
|
||||
|
||||
{/* Right ellipsis */}
|
||||
{currentPage < totalPages - 3 && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
|
||||
{/* Last page */}
|
||||
<Button
|
||||
variant={currentPage === totalPages ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{totalPages}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
|
||||
}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,632 @@
|
|||
/* eslint-disable no-constant-binary-expression */
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { productApi } from "@/api/product-api.service";
|
||||
import Loader from "@/components/loader";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { delay } from "@/features/app";
|
||||
import { urlToBase64 } from "@/features/base64";
|
||||
import { mapToIPost, type IPost } from "@/lib/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import z from "zod/v3";
|
||||
import usePost from "@/hooks/use-post-hook";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const productSchema = z.object({
|
||||
images: z.array(z.string()).min(1, "At least 1 image is required"),
|
||||
title: z
|
||||
.string()
|
||||
.min(1, "Product title is required")
|
||||
.min(5, "Product title must be at least 5 characters"),
|
||||
price: z.preprocess(
|
||||
(val) => (typeof val === "string" ? Number(val) : val),
|
||||
z
|
||||
.number({
|
||||
invalid_type_error: "Price must be a number",
|
||||
required_error: "Price is required",
|
||||
})
|
||||
.min(1, "Price must be greater than 0")
|
||||
),
|
||||
category: z.string().min(1, "Category is required"),
|
||||
condition: z.string().min(1, "Condition is required"),
|
||||
brand: z.string().optional(),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, "Description is required")
|
||||
.min(20, "Description must be at least 20 characters"),
|
||||
tags: z.array(z.string()).optional(),
|
||||
sku: z.string().min(1, "Sku must be required"),
|
||||
location: z.string().optional(),
|
||||
publist: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ProductFormData = z.infer<typeof productSchema>;
|
||||
|
||||
export default function ProductModal({
|
||||
children,
|
||||
data,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSubmit?: () => void;
|
||||
data?: IPost;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
|
||||
const form = useForm<ProductFormData>({
|
||||
resolver: zodResolver(productSchema) as any,
|
||||
defaultValues: {
|
||||
images: [],
|
||||
title: "",
|
||||
price: 0,
|
||||
category: "",
|
||||
condition: "",
|
||||
brand: "",
|
||||
description: "",
|
||||
tags: [],
|
||||
sku: "",
|
||||
location: "",
|
||||
publist: false,
|
||||
},
|
||||
});
|
||||
|
||||
const watchedImages = form.watch("images");
|
||||
const watchedTags = form.watch("tags");
|
||||
|
||||
const conditions = ["New", "Used - like new", "Used - good", "Used - fair"];
|
||||
const categories = ["Tools"];
|
||||
|
||||
const { isLoading, refetch, ...query } = usePost(data);
|
||||
|
||||
// const delImageMutation = useMutation({
|
||||
// mutationFn: async (imageUrl: string) => {
|
||||
// await delay(300);
|
||||
|
||||
// return await axios.delete(imageUrl);
|
||||
// },
|
||||
// onSuccess: (data) => {
|
||||
// refetch();
|
||||
// },
|
||||
// });
|
||||
|
||||
// const actionMutation = useMutation({
|
||||
// mutationFn: async (formData: ProductFormData & { id?: number }) => {
|
||||
// const { id, brand, publist, ...rest } = formData;
|
||||
// let response;
|
||||
|
||||
// if (id) {
|
||||
// // Update
|
||||
// response = await productApi.update(id, { ...rest, id });
|
||||
|
||||
// if (publist && response?.data) {
|
||||
// const action = data?.status ? "re-publist" : "publist";
|
||||
// await productApi.customAction(
|
||||
// response.data.id,
|
||||
// action,
|
||||
// response.data,
|
||||
// "POST",
|
||||
// { toast_success: false }
|
||||
// );
|
||||
// }
|
||||
// } else {
|
||||
// // Create
|
||||
// response = await productApi.create({ ...rest, brand });
|
||||
|
||||
// if (publist && response?.data) {
|
||||
// await productApi.customAction(
|
||||
// response.data.id,
|
||||
// "publist",
|
||||
// response.data,
|
||||
// "POST",
|
||||
// { toast_success: false }
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// return response;
|
||||
// },
|
||||
// onSuccess: () => {
|
||||
// refetch(); // làm mới danh sách
|
||||
// setOpen(false);
|
||||
// setUrlInput("");
|
||||
// setTagInput("");
|
||||
// props.onSubmit?.();
|
||||
// form.reset();
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// console.error("Mutation failed:", error);
|
||||
// },
|
||||
// });
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
const newImages: string[] = [];
|
||||
const currentImages = form.getValues("images");
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
newImages.push(url);
|
||||
});
|
||||
|
||||
form.setValue("images", [...currentImages, ...newImages]);
|
||||
}
|
||||
};
|
||||
|
||||
const addImageFromUrl = () => {
|
||||
if (urlInput.trim() && !watchedImages.includes(urlInput.trim())) {
|
||||
const currentImages = form.getValues("images");
|
||||
form.setValue("images", [...currentImages, urlInput.trim()]);
|
||||
setUrlInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index: number, image: string) => {
|
||||
const currentImages = form.getValues("images");
|
||||
|
||||
if (data && isHttpUrl(image)) {
|
||||
// delImageMutation.mutate(image);
|
||||
} else {
|
||||
form.setValue(
|
||||
"images",
|
||||
currentImages.filter((_, i) => i !== index)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
if (tagInput.trim() && !watchedTags?.includes(tagInput.trim())) {
|
||||
const currentTags = form.getValues("tags") || [];
|
||||
form.setValue("tags", [...currentTags, tagInput.trim()]);
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
const currentTags = form.getValues("tags") || [];
|
||||
form.setValue(
|
||||
"tags",
|
||||
currentTags.filter((tag) => tag !== tagToRemove)
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async ({ images, ...values }: ProductFormData) => {
|
||||
try {
|
||||
let imagesToConvert: string[] = [];
|
||||
|
||||
if (data) {
|
||||
// Có data => đang update
|
||||
const oldImages = data.images || [];
|
||||
const newImages = images || [];
|
||||
|
||||
// Lấy ra hình khác so với data.images
|
||||
imagesToConvert = newImages.filter((img) => !oldImages.includes(img));
|
||||
} else {
|
||||
// Không có data => đang create
|
||||
imagesToConvert = images || [];
|
||||
}
|
||||
|
||||
// Convert blob url sang base64 nếu cần
|
||||
const convertedImages = await Promise.all(
|
||||
imagesToConvert.map(async (img) =>
|
||||
img.startsWith("blob:") ? await urlToBase64(img) : img
|
||||
)
|
||||
);
|
||||
|
||||
const dataToSubmit: ProductFormData = {
|
||||
...values,
|
||||
images: convertedImages,
|
||||
};
|
||||
|
||||
console.log("Product data to submit:", dataToSubmit);
|
||||
|
||||
// const response = data
|
||||
// ? await (async () => {
|
||||
// const { brand, ...d } = dataToSubmit;
|
||||
// return await productApi.update(data.id, { ...d, id: data.id });
|
||||
// })()
|
||||
// : await productApi.create(dataToSubmit);
|
||||
|
||||
// if (!response) return;
|
||||
|
||||
// actionMutation.mutate({ ...dataToSubmit, id: data?.id });
|
||||
|
||||
console.log("Hình mới cần xử lý:", convertedImages);
|
||||
} catch (error) {
|
||||
console.error("Error submitting form:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const isHttpUrl = (url: string) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
setUrlInput("");
|
||||
setTagInput("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log({ a: { ...data, ...query.data?.data.data } });
|
||||
if (query.data?.data?.data && data) {
|
||||
const values = mapToIPost({ ...data, ...query.data?.data.data });
|
||||
form.reset(values);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query.data, data]);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (query.isSuccess && query.isFetched && query.data?.data) {
|
||||
// form.reset(query.data?.data);
|
||||
// }
|
||||
// }, [delImageMutation.isSuccess, query.isFetched, query.isSuccess]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="md:max-w-4xl h-[95vh] flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="p-6 border-b">
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{data ? "Edit product" : "Create new product"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* Images */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="images"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-lg font-semibold">
|
||||
Images *
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
|
||||
{/* Image preview */}
|
||||
{watchedImages.length > 0 && (
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<div className="flex gap-3">
|
||||
{watchedImages.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative flex-shrink-0 w-32 h-32 group rounded-lg border overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={image || "/placeholder.svg"}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Product info */}
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
<h3 className="text-lg font-semibold border-b pb-2">
|
||||
Product information
|
||||
</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="VD: MacBook Pro 14-inch 2023"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Price ($) *</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category *</FormLabel>
|
||||
<FormControl>
|
||||
{/* <Input placeholder="Tools" {...field} /> */}
|
||||
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c} value={c.toLowerCase()}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="condition"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Condition *</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select condition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{conditions.map((c) => (
|
||||
<SelectItem key={c} value={c.toLowerCase()}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brand"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Brand</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
readOnly={!!data}
|
||||
placeholder="VD: Cisco"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description *</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={4} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Additional info */}
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
<h3 className="text-lg font-semibold border-b pb-2">
|
||||
Additional information
|
||||
</h3>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-3">
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="Tag and press Enter"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" onClick={addTag} variant="outline">
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{watchedTags && watchedTags?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{watchedTags.map((tag) => (
|
||||
<Badge
|
||||
onClick={() => removeTag(tag)}
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 px-3 py-1"
|
||||
>
|
||||
{tag}
|
||||
<X className="w-3 h-3 cursor-pointer hover:text-red-500" />
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sku"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sku *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
readOnly={!!data}
|
||||
placeholder="VD: MBP14-2023-512GB"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Location</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="VD: Hà Nội, Việt Nam"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publist"
|
||||
render={({ field }) => {
|
||||
const isUpdate = !!data?.id; // đang update nếu có id
|
||||
const canRepublish = isUpdate && data?.status === true;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="publish"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<div className="grid gap-1 leading-none">
|
||||
<Label htmlFor="publish">
|
||||
{canRepublish
|
||||
? "Re-publish this product"
|
||||
: "Publish after saving"}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{canRepublish
|
||||
? "Make this product visible again after updating"
|
||||
: "Automatically publish this product when saving"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t bg-white flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Loader color="white" />
|
||||
) : data ? (
|
||||
"Save"
|
||||
) : (
|
||||
"Create"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
// content.ts
|
||||
import { productApi } from "@/api/product-api.service";
|
||||
import { delayRD } from "@/features/app";
|
||||
import type { IPost } from "@/lib/utils";
|
||||
import { facebookService } from "@/services/facebook.service";
|
||||
import MessageService from "@/services/message.service";
|
||||
import { thiefService } from "@/services/thief.service";
|
||||
|
||||
const msgService = new MessageService("content");
|
||||
|
||||
// Nhận từ background [publist]
|
||||
msgService.on<{ data: IPost }>(
|
||||
"background-to-content-publish",
|
||||
async (payload) => {
|
||||
try {
|
||||
console.log("[publish] Content nhận:", payload);
|
||||
|
||||
await delayRD(1000, 2000);
|
||||
|
||||
await facebookService.handlePublist(payload.data);
|
||||
} catch (error) {
|
||||
console.log({ error });
|
||||
} finally {
|
||||
// Gửi ngược lại bacground
|
||||
msgService.send("background", "content-to-background-publish", {
|
||||
msg: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Nhận từ background [unlist]
|
||||
msgService.on<{ data: IPost }>(
|
||||
"background-to-content-unlist",
|
||||
async (payload) => {
|
||||
try {
|
||||
console.log("[unlist] Content nhận:", payload);
|
||||
|
||||
await delayRD(1000, 2000);
|
||||
|
||||
await facebookService.gotoSell();
|
||||
|
||||
await delayRD(1000, 2000);
|
||||
|
||||
await facebookService.handleDelete(payload.data);
|
||||
|
||||
await delayRD(1000, 2000);
|
||||
|
||||
const products = await facebookService.getProducts();
|
||||
|
||||
await delayRD(1000, 2000);
|
||||
|
||||
productApi.sync(products);
|
||||
} catch (error) {
|
||||
console.log({ error });
|
||||
} finally {
|
||||
// Gửi ngược lại bacground
|
||||
msgService.send("background", "content-to-background-unlist", {
|
||||
msg: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Nhận từ background [re-publist]
|
||||
msgService.on<{ data: IPost }>(
|
||||
"background-to-content-re-publist",
|
||||
async (payload) => {
|
||||
try {
|
||||
console.log("[re-publist] Content nhận:", payload);
|
||||
|
||||
await delayRD(1000, 2000);
|
||||
|
||||
await facebookService.gotoSell();
|
||||
|
||||
await delayRD(1000, 2000);
|
||||
|
||||
await facebookService.handleRePublist(payload.data);
|
||||
} catch (error) {
|
||||
console.log({ error });
|
||||
} finally {
|
||||
// Gửi ngược lại bacground
|
||||
msgService.send("background", "content-to-background-re-publist", {
|
||||
msg: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
(async () => {
|
||||
await delayRD(1000, 2000);
|
||||
|
||||
// Sử dụng
|
||||
thiefService
|
||||
.waitForElement('[aria-label="Collection of your Marketplace items"]')
|
||||
.then(async (el) => {
|
||||
console.log("Element đã xuất hiện:", el);
|
||||
|
||||
await facebookService.waitForPageReady(
|
||||
facebookService.sellingPath,
|
||||
'[aria-label="Collection of your Marketplace items"]'
|
||||
);
|
||||
|
||||
await delayRD(1000, 2000);
|
||||
const products = await facebookService.getProducts();
|
||||
|
||||
console.log({ products });
|
||||
|
||||
productApi.sync(products);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
})();
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function removeFalsyValues<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
excludeKeys: (keyof T)[] = []
|
||||
): Partial<T> {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
if (value || excludeKeys.includes(key as keyof T)) {
|
||||
acc[key as keyof T] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Partial<T>);
|
||||
}
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function delayRD(minMs: number, maxMs: number): Promise<void> {
|
||||
const time = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
|
||||
return delay(time);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
export const toBase64 = (file: File) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
});
|
||||
|
||||
export async function urlToBase64(blobUrl: string): Promise<string> {
|
||||
return fetch(blobUrl)
|
||||
.then((res) => res.blob())
|
||||
.then(
|
||||
(blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject("Failed to convert blob to base64");
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { IPost } from "@/lib/utils";
|
||||
import { productApi } from "@/api/product-api.service";
|
||||
import { delay } from "@/features/app";
|
||||
|
||||
const usePost = (post?: IPost) => {
|
||||
const { isLoading, refetch, ...query } = useQuery({
|
||||
queryKey: ["product", post?.id],
|
||||
queryFn: async () => {
|
||||
if (!post) return null;
|
||||
|
||||
await delay(300); // Giả lập delay để thấy loading
|
||||
const res = await productApi.get(post);
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
||||
return { isLoading, refetch, ...query };
|
||||
};
|
||||
|
||||
export default usePost;
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import ax from "axios";
|
||||
|
||||
const axios = ax.create({
|
||||
// Dev
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
export default axios;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// lib/react-query.ts
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60, // 1 phút cache
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface IPost {
|
||||
images: string[];
|
||||
title: string;
|
||||
price: number;
|
||||
category: string;
|
||||
condition: string;
|
||||
brand?: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
sku: string;
|
||||
location?: string;
|
||||
status?: boolean;
|
||||
id: string;
|
||||
publist_id?: string;
|
||||
}
|
||||
|
||||
export interface ISyncItem {
|
||||
title: string;
|
||||
price: number;
|
||||
el: HTMLElement;
|
||||
}
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function mapToIPost(raw: any): IPost {
|
||||
let images: string[] = [];
|
||||
|
||||
if (Array.isArray(raw?.listImages)) {
|
||||
images = raw.listImages
|
||||
.map((img: any) =>
|
||||
typeof img === "string"
|
||||
? img
|
||||
: `${(import.meta.env.VITE_API_URL as string).replaceAll("api", "")}${
|
||||
img.url
|
||||
}`
|
||||
)
|
||||
.slice(0, 10); // chỉ lấy tối đa 10 ảnh
|
||||
}
|
||||
|
||||
// xử lý package_contain
|
||||
let packageContainText = "";
|
||||
if (Array.isArray(raw?.package_contain)) {
|
||||
packageContainText = raw.package_contain
|
||||
.map((item: string) => `- ${item}`)
|
||||
.join("\n");
|
||||
} else if (typeof raw?.package_contain === "string") {
|
||||
packageContainText = raw.package_contain;
|
||||
}
|
||||
|
||||
// build description rõ ràng, không có thụt đầu dòng
|
||||
const descriptionLines = [
|
||||
raw.description || "",
|
||||
raw.code || "",
|
||||
raw.name || "",
|
||||
(typeof raw?.category === "string" ? raw.category : raw?.category?.name) ||
|
||||
"",
|
||||
packageContainText,
|
||||
]
|
||||
.filter(Boolean) // loại bỏ cái rỗng
|
||||
.join("\n\n"); // cách 1 dòng giữa các block
|
||||
|
||||
return {
|
||||
images,
|
||||
title: `${raw.name} - ${raw.code}`,
|
||||
price: raw.price,
|
||||
category: "electronics & computers",
|
||||
condition: "new",
|
||||
brand: typeof raw.manufactor === "string" ? raw.manufactor : raw?.name,
|
||||
description: descriptionLines,
|
||||
tags: raw.tags || [],
|
||||
sku: raw.code,
|
||||
location: raw.location || "Sydney, Australia",
|
||||
status: false,
|
||||
id: raw.id,
|
||||
publist_id: raw.listingId,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { queryClient } from "@/lib/react-query";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import Popup from "./popup/popup";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Popup />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { PostTable } from "@/components/post-table";
|
||||
|
||||
export default function Popup() {
|
||||
return (
|
||||
<div className="w-fit min-h-[600px] p-8">
|
||||
<PostTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import type { IPost } from "@/lib/utils";
|
||||
import MessageService from "./message.service";
|
||||
import { facebookService } from "./facebook.service";
|
||||
|
||||
export class BackgroundService {
|
||||
msgService: MessageService;
|
||||
|
||||
constructor() {
|
||||
this.msgService = new MessageService("background");
|
||||
}
|
||||
|
||||
publishJob(data: IPost, timeout = 30000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.query({}, () => {
|
||||
const openAndSend = (tabId: number) => {
|
||||
// Đợi tab load xong
|
||||
const listener = (updatedTabId: number, info: any) => {
|
||||
if (updatedTabId === tabId && info.status === "complete") {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
|
||||
// Gửi message xuống content
|
||||
this.msgService.send("content", "background-to-content-publish", {
|
||||
data,
|
||||
});
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
|
||||
// Chờ message phản hồi từ content
|
||||
this.msgService
|
||||
.waitForMessage("content-to-background-publish", timeout)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
};
|
||||
// Tạo tab mới
|
||||
chrome.tabs.create({ url: facebookService.createItem }, (tab) => {
|
||||
if (!tab?.id) {
|
||||
reject(new Error("Không mở được tab"));
|
||||
return;
|
||||
}
|
||||
console.log("Đã mở tab mới:", tab.id);
|
||||
openAndSend(tab.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
unlistJob(data: IPost, timeout = 30000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const openAndSend = (tabId: number, shouldClose: boolean) => {
|
||||
const listener = (updatedTabId: number, info: any) => {
|
||||
if (updatedTabId === tabId && info.status === "complete") {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
|
||||
// Gửi message xuống content
|
||||
this.msgService.send("content", "background-to-content-unlist", {
|
||||
data,
|
||||
});
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
|
||||
// Chờ message phản hồi từ content
|
||||
this.msgService
|
||||
.waitForMessage("content-to-background-unlist", timeout)
|
||||
.then((res) => {
|
||||
if (shouldClose) {
|
||||
chrome.tabs.remove(tabId, () => {
|
||||
console.log("Đã đóng tab:", tabId);
|
||||
});
|
||||
} else {
|
||||
console.log("Hoàn thành nhưng giữ tab:", tabId);
|
||||
}
|
||||
resolve(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (shouldClose) {
|
||||
chrome.tabs.remove(tabId, () => {
|
||||
console.warn("Đóng tab do timeout/lỗi:", tabId);
|
||||
});
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
// Tìm tab đã có sẵn
|
||||
chrome.tabs.query({ url: `${facebookService.sellingPath}*` }, (tabs) => {
|
||||
if (tabs.length > 0 && tabs[0].id) {
|
||||
console.log("Dùng lại tab cũ:", tabs[0].id);
|
||||
// Xài lại tab, không đóng sau khi xong
|
||||
this.msgService.send("content", "background-to-content-unlist", {
|
||||
data,
|
||||
});
|
||||
|
||||
this.msgService
|
||||
.waitForMessage("content-to-background-unlist", timeout)
|
||||
.then((res) => {
|
||||
console.log("Hoàn thành, giữ tab cũ:", tabs[0].id!);
|
||||
resolve(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Lỗi khi xử lý tab cũ:", tabs[0].id!);
|
||||
reject(err);
|
||||
});
|
||||
} else {
|
||||
// Không có tab nào, tạo tab mới
|
||||
chrome.tabs.create(
|
||||
{ url: facebookService.marketPath, active: false },
|
||||
(tab) => {
|
||||
if (!tab?.id) {
|
||||
reject(new Error("Không mở được tab"));
|
||||
return;
|
||||
}
|
||||
console.log("Đã mở tab mới:", tab.id);
|
||||
// Tab mới => đóng sau khi xong
|
||||
openAndSend(tab.id, true);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
rePublistJob(data: IPost, timeout = 30000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.query({}, () => {
|
||||
const openAndSend = (tabId: number) => {
|
||||
// Đợi tab load xong
|
||||
const listener = (updatedTabId: number, info: any) => {
|
||||
if (updatedTabId === tabId && info.status === "complete") {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
|
||||
// Gửi message xuống content
|
||||
this.msgService.send(
|
||||
"content",
|
||||
"background-to-content-re-publist",
|
||||
{
|
||||
data,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
|
||||
// Chờ message phản hồi từ content
|
||||
this.msgService
|
||||
.waitForMessage("content-to-background-re-publist", timeout)
|
||||
.then((res) => {
|
||||
// ✅ Xong thì đóng tab
|
||||
chrome.tabs.remove(tabId, () => {
|
||||
console.log("Đã đóng tab:", tabId);
|
||||
});
|
||||
resolve(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
// ❌ Nếu lỗi hoặc timeout thì cũng đóng tab
|
||||
chrome.tabs.remove(tabId, () => {
|
||||
console.warn("Đóng tab do timeout/lỗi:", tabId);
|
||||
});
|
||||
reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
// Tạo tab mới
|
||||
chrome.tabs.create({ url: facebookService.marketPath }, (tab) => {
|
||||
if (!tab?.id) {
|
||||
reject(new Error("Không mở được tab"));
|
||||
return;
|
||||
}
|
||||
console.log("Đã mở tab mới:", tab.id);
|
||||
openAndSend(tab.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,633 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { delay, delayRD } from "@/features/app";
|
||||
import type { IPost, ISyncItem } from "@/lib/utils";
|
||||
import { thiefService } from "./thief.service";
|
||||
|
||||
class FacebookService {
|
||||
sellingPath = "https://www.facebook.com/marketplace/you/selling";
|
||||
marketPath = "https://www.facebook.com/marketplace";
|
||||
createItem = "https://www.facebook.com/marketplace/create/item";
|
||||
editItem = "https://www.facebook.com/marketplace/edit";
|
||||
|
||||
selectors = {
|
||||
file__image_input: 'input[type="file"]',
|
||||
next_btn:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[5]/div/div/div",
|
||||
update_btn:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div/div/div",
|
||||
publish_btn:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div[2]/div/div",
|
||||
products:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[2]/div[2]/div",
|
||||
products_fallback:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[3]/div/div/span/div[2]/div",
|
||||
option_btn:
|
||||
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div/div/div/div[3]/div/div/div/div/div[1]/div",
|
||||
option_btn_fallback:
|
||||
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[3]/div[2]/div/div[2]/div[1]",
|
||||
close_btn_modal_feedback:
|
||||
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[2]/div",
|
||||
images_container:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[2]/div[1]/div[2]/div/div/div[3]/div[2]/div",
|
||||
description_edit_input:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[2]/div/div/div/label/div/div/textarea",
|
||||
sku_edit_input:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div[1]/label/div/input",
|
||||
new_item_btn:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[7]/a/div",
|
||||
item_for_sell:
|
||||
"/html/body/div[1]/div/div[1]/div/div[5]/div/div/div[3]/div[2]/div[2]/div/div/div[2]/div[1]/div/span/div/div/div[1]/div/div",
|
||||
|
||||
market_container: '[aria-label="Marketplace"]',
|
||||
};
|
||||
|
||||
findInputByTitle(title: string, type: "input" | "textarea" = "input") {
|
||||
const el = thiefService.findNestedElement(
|
||||
this.selectors.market_container,
|
||||
"span",
|
||||
title
|
||||
);
|
||||
|
||||
if (!el) throw new Error("Not found input by title: " + title);
|
||||
|
||||
return thiefService.findElInParent(el, type) as HTMLInputElement;
|
||||
}
|
||||
|
||||
clearImages = async () => {
|
||||
document
|
||||
.querySelectorAll('[aria-label="Remove"]')
|
||||
.forEach((item) => (item as any)?.click());
|
||||
};
|
||||
|
||||
uploadImages = async (item: IPost) => {
|
||||
// Tạo DataTransfer để giả lập FileList
|
||||
const dt: DataTransfer = new DataTransfer();
|
||||
|
||||
for (const image of item.images) {
|
||||
const base64 = await thiefService.imageUrlToBase64(image);
|
||||
|
||||
console.log("Base64:", image.slice(0, 50) + "...");
|
||||
const file = thiefService.base64ToFile(
|
||||
base64,
|
||||
item.sku,
|
||||
thiefService.getImageExtension(image) || "jpg"
|
||||
);
|
||||
|
||||
dt.items.add(file);
|
||||
}
|
||||
|
||||
// Tìm input file của Facebook
|
||||
const input: HTMLInputElement | null = document.querySelector(
|
||||
this.selectors.file__image_input
|
||||
);
|
||||
|
||||
if (input) {
|
||||
// Gán file vào input
|
||||
input.files = dt.files;
|
||||
|
||||
// Gửi event change
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
} else {
|
||||
console.error("Không tìm thấy input[type='file']");
|
||||
}
|
||||
};
|
||||
|
||||
chooseSelect = async (
|
||||
value: string,
|
||||
title: string,
|
||||
dropContainer: string = '[aria-label="Dropdown menu"]'
|
||||
) => {
|
||||
const el = thiefService.findNestedElement(
|
||||
this.selectors.market_container,
|
||||
"span",
|
||||
title
|
||||
);
|
||||
|
||||
thiefService.scrollToElement(el!);
|
||||
await delayRD(500, 600); // delay sau scroll
|
||||
|
||||
el?.click();
|
||||
await delayRD(500, 600); // delay sau click
|
||||
|
||||
const matchingChild = thiefService.findNestedElement(
|
||||
dropContainer,
|
||||
"span",
|
||||
value
|
||||
);
|
||||
await delayRD(500, 600); // delay sau khi tìm
|
||||
|
||||
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
|
||||
|
||||
thiefService.scrollToElement(matchingChild);
|
||||
await delayRD(500, 600); // delay sau scroll đến child
|
||||
|
||||
matchingChild.click();
|
||||
await delayRD(500, 600); // delay sau click child
|
||||
};
|
||||
|
||||
chooseLocation = async (value: string) => {
|
||||
const input = this.findInputByTitle("location");
|
||||
|
||||
await thiefService.writeToInput(value, input);
|
||||
|
||||
await delay(600);
|
||||
|
||||
// input.click();
|
||||
|
||||
const matchingChilds =
|
||||
document.querySelector('[role="listbox"]')?.querySelectorAll("li") || [];
|
||||
|
||||
const matchingChild = Array.from(matchingChilds).find((item) =>
|
||||
item.innerHTML.trim().toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
console.log(matchingChild);
|
||||
|
||||
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
|
||||
|
||||
thiefService.scrollToElement(matchingChild);
|
||||
|
||||
await delay(400);
|
||||
thiefService.clickByPoint(matchingChild);
|
||||
};
|
||||
|
||||
writeTags = async (tags: string[]) => {
|
||||
const input = this.findInputByTitle("product tags", "textarea");
|
||||
if (!input) throw new Error("Input is not found");
|
||||
|
||||
thiefService.scrollToElement(input);
|
||||
|
||||
await delay(500);
|
||||
|
||||
for (const tag of tags) {
|
||||
await thiefService.writeToInput(tag, input);
|
||||
|
||||
await delay(300);
|
||||
|
||||
thiefService.pressEnter(input);
|
||||
}
|
||||
};
|
||||
|
||||
clickNext = async () => {
|
||||
const btn = await thiefService.getElementByXPath(this.selectors.next_btn);
|
||||
|
||||
if (!btn) throw new Error("Next button is not found");
|
||||
|
||||
thiefService.clickByPoint(btn);
|
||||
};
|
||||
|
||||
clickUpdate = async () => {
|
||||
const btn = await thiefService.getElementByXPath(this.selectors.update_btn);
|
||||
|
||||
if (!btn) throw new Error("Next button is not found");
|
||||
|
||||
thiefService.clickByPoint(btn);
|
||||
};
|
||||
|
||||
clickPublist = async () => {
|
||||
const btn = await thiefService.getElementByXPath(
|
||||
this.selectors.publish_btn
|
||||
);
|
||||
|
||||
if (!btn) throw new Error("Publist button is not found");
|
||||
|
||||
thiefService.clickByPoint(btn);
|
||||
};
|
||||
|
||||
/**
|
||||
* B1. Upload images
|
||||
* B2. Write title
|
||||
* B3. Write price
|
||||
* B4. Select category
|
||||
* B5. Select condition
|
||||
* .....
|
||||
*/
|
||||
handlePublist = async (item: IPost) => {
|
||||
console.log({ item });
|
||||
|
||||
await delayRD(600, 1000);
|
||||
// B1. Upload images
|
||||
await this.uploadImages(item);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
// B2. Write title
|
||||
|
||||
const title_input = this.findInputByTitle("title");
|
||||
|
||||
thiefService.writeToInput(item.title, title_input);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// B3. Write price
|
||||
const price_input = this.findInputByTitle("price");
|
||||
thiefService.writeToInput(String(item.price), price_input);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// // B4. Select category
|
||||
await this.chooseSelect(item.category, "category");
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// // B5. Select condition
|
||||
await this.chooseSelect(
|
||||
item.condition,
|
||||
"condition",
|
||||
'[aria-label="Select an option"]'
|
||||
);
|
||||
|
||||
if (item.brand) {
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// B6. Write brand
|
||||
const brand_input = this.findInputByTitle("brand");
|
||||
await thiefService.writeToInput(item.brand, brand_input);
|
||||
}
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// B7. Write description
|
||||
const description_input = this.findInputByTitle("description", "textarea");
|
||||
await thiefService.writeToInput(item.description, description_input);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
await this.writeTags(item.tags);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// B8. Write sku
|
||||
const sku_input = this.findInputByTitle("sku");
|
||||
await thiefService.writeToInput(item.sku, sku_input);
|
||||
|
||||
if (item?.location) {
|
||||
await delayRD(600, 1000);
|
||||
|
||||
await this.chooseLocation(item.location);
|
||||
}
|
||||
|
||||
await delayRD(300, 500);
|
||||
|
||||
await this.clickNext();
|
||||
|
||||
// await delayRD(300, 500);
|
||||
|
||||
// await this.clickPublist();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* B1. Upload images
|
||||
* B2. Write title
|
||||
* B3. Write price
|
||||
* B4. Select category
|
||||
* B5. Select condition
|
||||
* .....
|
||||
*/
|
||||
handleRePublist = async (item: IPost) => {
|
||||
console.log({ item });
|
||||
|
||||
await thiefService.waitForElement(
|
||||
'[aria-label="Collection of your Marketplace items"]'
|
||||
);
|
||||
|
||||
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
|
||||
const items = await this.getOptionEls(item);
|
||||
|
||||
console.log({ items });
|
||||
|
||||
// // Tìm item chứa chữ "delete"
|
||||
this.clickItemInList(items, "edit");
|
||||
|
||||
// Delay 1 giây để modal xác nhận xóa hiện ra
|
||||
await delay(1000);
|
||||
|
||||
// CHỜ tới khi URL đổi sang sellingPath
|
||||
await this.waitForPageReady(this.editItem, this.selectors.market_container);
|
||||
|
||||
await delayRD(1000, 2000);
|
||||
|
||||
await this.clearImages();
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// B1. Upload images
|
||||
await this.uploadImages(item);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
// B2. Write title
|
||||
|
||||
const title_input = this.findInputByTitle("title");
|
||||
|
||||
thiefService.writeToInput(item.title, title_input);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// B3. Write price
|
||||
const price_input = this.findInputByTitle("price");
|
||||
thiefService.writeToInput(String(item.price), price_input);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// // B4. Select category
|
||||
await this.chooseSelect(item.category, "category");
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// // B5. Select condition
|
||||
await this.chooseSelect(
|
||||
item.condition,
|
||||
"condition",
|
||||
'[aria-label="Select an option"]'
|
||||
);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
// B7. Write description
|
||||
const description_input = this.findInputByTitle("description", "textarea");
|
||||
await thiefService.writeToInput(item.description, description_input);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
await this.writeTags(item.tags);
|
||||
|
||||
await delayRD(600, 1000);
|
||||
|
||||
if (item?.location) {
|
||||
await delayRD(600, 1000);
|
||||
|
||||
await this.chooseLocation(item.location);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
getProducts = async () => {
|
||||
const products1 = await thiefService.getElementByXPath(
|
||||
this.selectors.products
|
||||
);
|
||||
const products2 = await thiefService.getElementByXPath(
|
||||
this.selectors.products_fallback
|
||||
);
|
||||
|
||||
console.log({ products1, products2 });
|
||||
|
||||
// Gom 2 cái vào một mảng, bỏ null
|
||||
const allProductsEls = [products1, products2].filter(
|
||||
Boolean
|
||||
) as HTMLElement[];
|
||||
|
||||
if (allProductsEls.length === 0) return [];
|
||||
|
||||
// Nối tất cả kết quả extractListings từ mỗi element
|
||||
return allProductsEls.flatMap((el) =>
|
||||
this.extractListings(el)
|
||||
) as ISyncItem[];
|
||||
};
|
||||
|
||||
extractListings(productsEl: HTMLElement) {
|
||||
const children = Array.from(productsEl.children);
|
||||
|
||||
return children.map((child) => {
|
||||
// Lấy title
|
||||
const titleEl = child.querySelector('span[dir="auto"], div[dir="auto"]');
|
||||
const title = titleEl?.textContent?.trim() || "";
|
||||
|
||||
const els = document.querySelectorAll(`[aria-label="${title}"]`);
|
||||
|
||||
// Chuyển sang mảng và filter
|
||||
const filtered = Array.from(els).filter(
|
||||
(el) => !el.querySelector("img")
|
||||
)[0];
|
||||
|
||||
const priceEl = thiefService.findNestedIcElement(filtered, "span", "AU$");
|
||||
|
||||
const priceMatch = priceEl?.textContent?.match(/[\d,]+(?:\.\d+)?/);
|
||||
const price = priceMatch
|
||||
? parseFloat(priceMatch[0].replace(/,/g, ""))
|
||||
: 0;
|
||||
|
||||
return { title, price, el: productsEl };
|
||||
});
|
||||
}
|
||||
|
||||
closeTab = async (data: IPost) => {
|
||||
chrome.runtime.sendMessage({
|
||||
type: "close-tab",
|
||||
payload: data,
|
||||
});
|
||||
};
|
||||
|
||||
clickOptionOfProduct(data: IPost) {
|
||||
// Tìm nút "More options" cho sản phẩm đó
|
||||
const optionEl = document.querySelector(
|
||||
`[aria-label="More options for ${data.title}"]`
|
||||
);
|
||||
|
||||
if (!optionEl)
|
||||
throw new Error(
|
||||
`Not found option buttin in product ${`[aria-label="More options for ${data.title}"]`}, ID: ${
|
||||
data.id
|
||||
}`
|
||||
);
|
||||
|
||||
thiefService.scrollToElement(optionEl as any);
|
||||
|
||||
// Click vào nút "More options" để mở menu
|
||||
(optionEl as any).click?.();
|
||||
}
|
||||
|
||||
async getOptionEls(data: IPost) {
|
||||
this.clickOptionOfProduct(data);
|
||||
|
||||
// Delay 2 giây để menu hiển thị hoàn toàn
|
||||
await delay(2000);
|
||||
|
||||
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
|
||||
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
clickItemInList(items: Element[], innerText: string) {
|
||||
// Tìm item chứa chữ "delete"
|
||||
const item = items.find((item) =>
|
||||
item.textContent.toLocaleLowerCase().includes(innerText)
|
||||
);
|
||||
|
||||
if (!item) throw new Error(`Not found item ${innerText} in options list`);
|
||||
|
||||
// Click vào item "Delete"
|
||||
(item as any).click?.();
|
||||
}
|
||||
|
||||
getItemInList(items: Element[], innerText: string) {
|
||||
// Tìm item chứa chữ "delete"
|
||||
const item = items.find((item) =>
|
||||
item.textContent.toLocaleLowerCase().includes(innerText)
|
||||
);
|
||||
|
||||
if (!item) throw new Error(`Not found item ${innerText} in options list`);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
handleDelete = async (payload: IPost) => {
|
||||
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
|
||||
const items = await this.getOptionEls(payload);
|
||||
|
||||
console.log({ items });
|
||||
|
||||
// // Tìm item chứa chữ "delete"
|
||||
this.clickItemInList(items, "delete");
|
||||
|
||||
// Delay 1 giây để modal xác nhận xóa hiện ra
|
||||
await delay(1000);
|
||||
|
||||
// Lấy nút "Options" trong modal (hoặc fallback nếu xpath chính không tìm thấy)
|
||||
const confirmBtn = await thiefService.getElementByXPath(
|
||||
this.selectors.option_btn,
|
||||
{
|
||||
xpathFallback: this.selectors.option_btn_fallback,
|
||||
}
|
||||
);
|
||||
|
||||
console.log({ confirmBtn });
|
||||
|
||||
// Click vào nút "Options" trong modal nếu tìm thấy
|
||||
confirmBtn?.click();
|
||||
|
||||
// Lấy nút "Close" của modal feedback (nếu có) để đóng modal sau khi xóa
|
||||
const closeBtnModalFeedback = await thiefService.getElementByXPath(
|
||||
this.selectors.close_btn_modal_feedback
|
||||
);
|
||||
|
||||
closeBtnModalFeedback?.click();
|
||||
|
||||
// Gọi API backend để đánh dấu sản phẩm đã xóa (published = false)
|
||||
// await productApi.finistDelete(payload, { published: false });
|
||||
|
||||
// Gửi message đến background/content script thông báo là delete đã xong
|
||||
chrome.runtime.sendMessage({ type: "delete-done" });
|
||||
};
|
||||
|
||||
waitForUrlContains(path: string, timeout = 10000) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const check = () => {
|
||||
if (window.location.href.includes(path)) {
|
||||
resolve();
|
||||
} else if (Date.now() - start > timeout) {
|
||||
reject(new Error("Timeout waiting for URL change"));
|
||||
} else {
|
||||
setTimeout(check, 500);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
waitForPageReady(path: string, selector: string, timeout = 15000) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
|
||||
const check = async () => {
|
||||
// Đúng URL + DOM có dấu hiệu đã render
|
||||
if (
|
||||
window.location.href.includes(path) &&
|
||||
// document.querySelector('[role="main"]') // thay bằng selector chắc chắn có trong selling page
|
||||
document.querySelector(selector)
|
||||
) {
|
||||
resolve();
|
||||
} else if (Date.now() - start > timeout) {
|
||||
reject(new Error("Timeout waiting for selling page ready"));
|
||||
} else {
|
||||
setTimeout(check, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// chạy liền để check ngay
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
syncListing = async () => {
|
||||
const url = window.location.href;
|
||||
if (!url.includes(this.sellingPath)) {
|
||||
(
|
||||
document.querySelector('a[href="/marketplace/you/selling/"]') as any
|
||||
)?.click?.();
|
||||
|
||||
// CHỜ tới khi URL đổi sang sellingPath
|
||||
await this.waitForPageReady(this.sellingPath, this.selectors.products);
|
||||
}
|
||||
|
||||
const products = await facebookService.getProducts();
|
||||
|
||||
// const response = await productApi.sync(
|
||||
// products.map((item) => ({
|
||||
// title: item.title,
|
||||
// price: item.price,
|
||||
// })) as ISyncItem[]
|
||||
// );
|
||||
|
||||
// console.log({ response });
|
||||
};
|
||||
|
||||
extractMarketplaceItemId(url: string): string | null {
|
||||
const match = url.match(/\/marketplace\/item\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
handleGetPublistID = async (payload: IPost) => {
|
||||
// Lấy tất cả sản phẩm hiện tại trên Facebook Marketplace
|
||||
const products = await facebookService.getProducts();
|
||||
|
||||
// Tìm sản phẩm khớp với title và price từ payload
|
||||
const product = products.find((product) => {
|
||||
return product.title == payload.title && product.price == payload.price;
|
||||
});
|
||||
|
||||
console.log({ payload, product, products });
|
||||
|
||||
// Nếu không tìm thấy sản phẩm nào khớp thì kết thúc
|
||||
if (!product) return;
|
||||
|
||||
const el = product.el; // element HTML đại diện cho sản phẩm trên trang
|
||||
|
||||
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
|
||||
const items = await this.getOptionEls(payload);
|
||||
|
||||
const item = this.getItemInList(items, "view listing");
|
||||
|
||||
const publistID = this.extractMarketplaceItemId((item as any)["href"]);
|
||||
|
||||
this.clickOptionOfProduct(payload);
|
||||
|
||||
return publistID;
|
||||
};
|
||||
|
||||
async gotoPublist() {
|
||||
const btn = await thiefService.getElementByXPath(
|
||||
this.selectors.new_item_btn
|
||||
);
|
||||
|
||||
btn?.click();
|
||||
|
||||
await delayRD(400, 800);
|
||||
|
||||
const forSell = await thiefService.getElementByXPath(
|
||||
this.selectors.item_for_sell
|
||||
);
|
||||
|
||||
forSell?.click();
|
||||
}
|
||||
|
||||
async gotoSell() {
|
||||
const btn = document.querySelector('[href="/marketplace/you/selling/"]');
|
||||
|
||||
(btn as any)?.click();
|
||||
}
|
||||
}
|
||||
|
||||
export const facebookService = new FacebookService();
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
/* eslint-disable prefer-const */
|
||||
type Role = "popup" | "background" | "content";
|
||||
type Target = "popup" | "background" | "content";
|
||||
|
||||
interface IMessage<T = any> {
|
||||
from: Role;
|
||||
to: Target;
|
||||
type: string;
|
||||
payload?: T;
|
||||
}
|
||||
|
||||
type MessageHandler<T = any> = (
|
||||
payload: T,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: any) => void
|
||||
) => void;
|
||||
|
||||
export default class MessageService {
|
||||
private role: Role;
|
||||
private listeners: Map<string, MessageHandler>;
|
||||
|
||||
constructor(role: Role) {
|
||||
this.role = role;
|
||||
this.listeners = new Map();
|
||||
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(message: IMessage, sender, sendResponse) => {
|
||||
if (message?.type && this.listeners.has(message.type)) {
|
||||
const handler = this.listeners.get(message.type)!;
|
||||
handler(message.payload, sender, sendResponse);
|
||||
return true; // cho phép async response
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng ký handler cho một loại message
|
||||
*/
|
||||
on<T = any>(type: string, handler: MessageHandler<T>): void {
|
||||
this.listeners.set(type, handler as MessageHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gửi message tới target
|
||||
*/
|
||||
send<T = any>(to: Target, type: string, payload?: T): void {
|
||||
const message: IMessage<T> = { from: this.role, to, type, payload };
|
||||
|
||||
if (this.role === "popup" || this.role === "content") {
|
||||
// popup hoặc content luôn gửi qua background
|
||||
chrome.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
if (this.role === "background") {
|
||||
if (to === "content") {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs[0]?.id) {
|
||||
chrome.tabs.sendMessage(tabs[0].id, message, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn(
|
||||
"Không gửi được tới content script:",
|
||||
chrome.runtime.lastError.message
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (to === "popup") {
|
||||
chrome.runtime.sendMessage(message, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn(
|
||||
"Không gửi được tới popup:",
|
||||
chrome.runtime.lastError.message
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gửi message trực tiếp tới tab chỉ định (chỉ dùng cho background)
|
||||
*/
|
||||
sendToTab<T = any>(tabId: number, type: string, payload?: T): void {
|
||||
if (this.role !== "background") {
|
||||
throw new Error("sendToTab chỉ được gọi từ background");
|
||||
}
|
||||
|
||||
const message: IMessage<T> = {
|
||||
from: this.role,
|
||||
to: "content",
|
||||
type,
|
||||
payload,
|
||||
};
|
||||
|
||||
chrome.tabs.sendMessage(tabId, message, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn(
|
||||
`Không gửi được tới content script ở tab ${tabId}:`,
|
||||
chrome.runtime.lastError.message
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
waitForMessage<T = any>(type: string, timeout = 30000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer: any;
|
||||
|
||||
const handler: MessageHandler<T> = (payload) => {
|
||||
clearTimeout(timer);
|
||||
this.off(type, handler);
|
||||
resolve(payload);
|
||||
};
|
||||
|
||||
// timeout
|
||||
timer = setTimeout(() => {
|
||||
this.off(type, handler);
|
||||
reject(new Error(`Timeout khi chờ message: ${type}`));
|
||||
}, timeout);
|
||||
|
||||
this.on(type, handler);
|
||||
});
|
||||
}
|
||||
|
||||
off(type: string, handler: MessageHandler): void {
|
||||
const current = this.listeners.get(type);
|
||||
if (current === handler) {
|
||||
this.listeners.delete(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
class ThiefService {
|
||||
base64ToFile(base64: string, filename: string, mimeType: string): File {
|
||||
// Nếu có tiền tố "data:image/xxx;base64," thì cắt bỏ
|
||||
const pureBase64 = base64.includes(",") ? base64.split(",")[1] : base64;
|
||||
|
||||
const byteString = atob(pureBase64);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([ab], { type: mimeType });
|
||||
return new File([blob], filename, { type: mimeType });
|
||||
}
|
||||
|
||||
clickByPoint(el: Element) {
|
||||
const rect: DOMRect = el.getBoundingClientRect();
|
||||
const x: number = rect.left + rect.width / 2;
|
||||
const y: number = rect.top + rect.height / 2;
|
||||
|
||||
const target: Element | null = document.elementFromPoint(x, y);
|
||||
if (target) {
|
||||
target.dispatchEvent(
|
||||
new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getElementByXPath(
|
||||
xpath: string,
|
||||
{
|
||||
retryCount = 2,
|
||||
delay = 100,
|
||||
xpathFallback,
|
||||
}: { retryCount?: number; delay?: number; xpathFallback?: string } = {}
|
||||
): Promise<HTMLElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
let usingFallback = false;
|
||||
|
||||
const tryFind = () => {
|
||||
const el: Node | null = document.evaluate(
|
||||
usingFallback && xpathFallback ? xpathFallback : xpath,
|
||||
document,
|
||||
null,
|
||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||
null
|
||||
).singleNodeValue;
|
||||
|
||||
if (el instanceof HTMLElement) {
|
||||
resolve(el);
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < retryCount) {
|
||||
setTimeout(tryFind, delay);
|
||||
} else {
|
||||
if (!usingFallback && xpathFallback) {
|
||||
usingFallback = true;
|
||||
attempts = 0;
|
||||
setTimeout(tryFind, delay);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tryFind();
|
||||
});
|
||||
}
|
||||
|
||||
async imageUrlToBase64(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
// Kết quả là data URL (base64)
|
||||
resolve(reader.result.split(",")[1]);
|
||||
} else {
|
||||
reject("Không thể đọc dữ liệu ảnh");
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
getImageExtension(url: string): string | null {
|
||||
try {
|
||||
const pathname = new URL(url).pathname; // Lấy phần path từ URL
|
||||
const match = pathname.match(/\.([a-zA-Z0-9]+)$/); // Tìm đuôi file
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
} catch {
|
||||
// Nếu url không hợp lệ thì fallback sang split
|
||||
const parts = url.split("?");
|
||||
const path = parts[0];
|
||||
const match = path.match(/\.([a-zA-Z0-9]+)$/);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
}
|
||||
|
||||
imageLocalToBase64(relativePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Lấy URL đầy đủ trong extension
|
||||
const url = chrome.runtime.getURL(`${relativePath}`);
|
||||
|
||||
// Fetch file
|
||||
fetch(url)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string); // base64
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch(reject);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scrollToElement(el: HTMLElement, behavior: ScrollBehavior = "smooth") {
|
||||
if (!el) return;
|
||||
el.scrollIntoView({
|
||||
behavior, // "smooth" hoặc "auto"
|
||||
block: "center", // Đưa phần tử ra giữa màn hình
|
||||
inline: "nearest",
|
||||
});
|
||||
}
|
||||
|
||||
getElementPointCoores(el: HTMLElement): { x: number; y: number } | null {
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
setInputValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
if (!el) return;
|
||||
|
||||
// Gán giá trị trực tiếp
|
||||
el.value = value;
|
||||
|
||||
// Tạo và dispatch sự kiện input
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
// Tạo và dispatch sự kiện change (nếu cần)
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
writeToInput = async (value: string, element: string | HTMLInputElement) => {
|
||||
let el;
|
||||
|
||||
if (typeof element === "string") {
|
||||
el = (await this.getElementByXPath(element)) as HTMLInputElement;
|
||||
} else {
|
||||
el = element;
|
||||
}
|
||||
|
||||
if (!el) throw new Error("Xpath is not found with value: " + value);
|
||||
|
||||
// Scroll to EL
|
||||
this.scrollToElement(el);
|
||||
|
||||
this.clickByPoint(el);
|
||||
|
||||
this.setInputValue(el, value);
|
||||
};
|
||||
|
||||
pressEnter(el: HTMLElement) {
|
||||
if (!el) {
|
||||
throw new Error("Textarea not found:", el);
|
||||
}
|
||||
|
||||
el.focus();
|
||||
|
||||
// Chuỗi sự kiện bàn phím
|
||||
["keydown", "keypress", "keyup"].forEach((type) => {
|
||||
el.dispatchEvent(
|
||||
new KeyboardEvent(type, {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tìm phần tử con nằm trong 1 cha, có selector + text cụ thể
|
||||
* @param parentSelector - selector của cha
|
||||
* @param childSelector - selector của con
|
||||
* @param text - innerText cần match
|
||||
* @returns HTMLElement | null
|
||||
*/
|
||||
findNestedElement(
|
||||
parentSelector: string,
|
||||
childSelector: string,
|
||||
text: string
|
||||
): HTMLElement | null {
|
||||
const parent = document.querySelector<HTMLElement>(parentSelector);
|
||||
if (!parent) return null;
|
||||
|
||||
const children = parent.querySelectorAll<HTMLElement>(childSelector);
|
||||
|
||||
for (const el of children) {
|
||||
if (el.innerText.trim().toLowerCase() === text.toLowerCase()) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
findNestedIcElement(
|
||||
parentSelector: string | Element,
|
||||
childSelector: string,
|
||||
text: string
|
||||
): HTMLElement | null {
|
||||
let parent = null;
|
||||
|
||||
if (typeof parentSelector === "string") {
|
||||
document.querySelector<HTMLElement>(parentSelector);
|
||||
if (!parent) return null;
|
||||
} else {
|
||||
parent = parentSelector;
|
||||
}
|
||||
|
||||
const children = parent.querySelectorAll<HTMLElement>(childSelector);
|
||||
|
||||
for (const el of children) {
|
||||
if (el.innerText.trim().toLowerCase().includes(text.toLowerCase())) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tìm parent element theo selector từ 1 element cho trước
|
||||
* @param el - phần tử bắt đầu
|
||||
* @param selector - selector của cha cần tìm
|
||||
* @returns HTMLElement | null
|
||||
*/
|
||||
findElInParent(el: HTMLElement, selector: string): HTMLElement | null {
|
||||
if (!el) return null;
|
||||
|
||||
const parent = el.parentElement;
|
||||
|
||||
return parent?.querySelector(selector) || null;
|
||||
}
|
||||
|
||||
waitForElement(selector: string, checkInterval = 100): Promise<HTMLElement> {
|
||||
return new Promise((resolve) => {
|
||||
const isLoaded = (el: HTMLElement) =>
|
||||
el.offsetParent !== null && // element hiển thị
|
||||
(el.childElementCount > 0 || el.innerText.trim().length > 0); // đã có nội dung
|
||||
|
||||
const tryResolve = () => {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (el && isLoaded(el)) {
|
||||
observer.disconnect();
|
||||
clearInterval(interval);
|
||||
resolve(el);
|
||||
}
|
||||
};
|
||||
|
||||
// MutationObserver để bắt khi element xuất hiện
|
||||
const observer = new MutationObserver(tryResolve);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Kiểm tra theo interval để chờ element load nội dung
|
||||
const interval = setInterval(tryResolve, checkInterval);
|
||||
|
||||
// Thử ngay lần đầu
|
||||
tryResolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const thiefService = new ThiefService();
|
||||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// vite.config.content.ts
|
||||
import { defineConfig } from "vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist-content",
|
||||
emptyOutDir: false, // giữ dist gốc
|
||||
rollupOptions: {
|
||||
input: path.resolve(__dirname, "src/content/content.ts"),
|
||||
output: {
|
||||
entryFileNames: "content/content.js",
|
||||
format: "iife",
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// vite.config.popup.ts
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path, { resolve } from "path";
|
||||
import pkg from "./package.json";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: pkg.name,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
popup: resolve(__dirname, "index.html"),
|
||||
content: resolve(__dirname, "src/content/content.ts"),
|
||||
background: resolve(__dirname, "src/background/background.ts"),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunk) => {
|
||||
if (chunk.name === "content") return "content/content.js";
|
||||
if (chunk.name === "background") return "background/background.js";
|
||||
return "assets/[name].js";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import fs from "fs";
|
||||
import fse from "fs-extra";
|
||||
import path, { resolve } from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import pkg from "./package.json";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: "copy-content-script",
|
||||
closeBundle: async () => {
|
||||
const src = resolve(__dirname, "dist-content/content/content.js");
|
||||
const destDir = resolve(__dirname, pkg.name, "content");
|
||||
const dest = resolve(destDir, "content.js");
|
||||
|
||||
if (!fs.existsSync(src)) {
|
||||
console.warn(
|
||||
"⚠️ content.js chưa được build từ vite.config.content.ts"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await fse.ensureDir(destDir);
|
||||
await fse.copyFile(src, dest);
|
||||
console.log(`✅ Copied content.js → ${dest}`);
|
||||
},
|
||||
},
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue