first commit

This commit is contained in:
Admin 2025-08-12 11:02:05 +07:00
commit 1e339c08db
258 changed files with 44935 additions and 0 deletions

View File

@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,19 @@
function rightClickElementById(id) {
const element = document.getElementById(id);
if (!element) {
console.error("Element not found:", id);
return;
}
const event = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
view: window,
button: 2, // Chuột phải
buttons: 2,
clientX: element.getBoundingClientRect().left,
clientY: element.getBoundingClientRect().top
});
element.dispatchEvent(event);
}

View File

@ -0,0 +1 @@
function n(e){return new Promise(t=>setTimeout(t,e))}export{n as d};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,38 @@
[
{
"images": ["images/1.png", "images/2.png"],
"title": "Bộ Dụng Cụ Đa Năng 18 Món",
"price": 299000,
"category": "Tools",
"condition": "new",
"brand": "Bosch",
"description": "Bộ dụng cụ đa năng 18 món chất liệu thép không gỉ, bền bỉ, tiện lợi cho gia đình và công việc sửa chữa. Bao gồm tua vít, kìm, búa, thước dây và nhiều phụ kiện khác.",
"tags": ["dụng cụ", "đa năng", "sửa chữa", "gia đình", "Bosch"],
"sku": "BOSCH-TOOLS-18",
"location": "Hanoi, Vietnam"
},
{
"images": ["images/1.png", "images/2.png"],
"title": "Máy Khoan Pin 21V",
"price": 1250000,
"category": "Tools",
"condition": "new",
"brand": "Makita",
"description": "Máy khoan pin 21V công suất mạnh mẽ, tốc độ điều chỉnh linh hoạt, kèm 2 pin dự phòng. Phù hợp cho công việc sửa chữa và lắp ráp tại nhà hoặc công trình.",
"tags": ["máy khoan", "dụng cụ điện", "Makita", "pin sạc"],
"sku": "MAKITA-DRILL-21V",
"location": "Ho Chi Minh City, Vietnam"
},
{
"images": ["images/1.png", "images/2.png"],
"title": "Hộp Đựng Dụng Cụ Chuyên Nghiệp",
"price": 450000,
"category": "Tools",
"condition": "new",
"brand": "Stanley",
"description": "Hộp đựng dụng cụ chuyên nghiệp chất liệu nhựa ABS cao cấp, chịu lực và chống va đập. Thiết kế nhiều ngăn giúp sắp xếp gọn gàng các loại dụng cụ cầm tay.",
"tags": ["hộp dụng cụ", "Stanley", "chuyên nghiệp", "bảo quản"],
"sku": "STANLEY-BOX-PRO",
"location": "Da Nang, Vietnam"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1,30 @@
{
"manifest_version": 3,
"name": "Auto listing facebook marketplace",
"version": "1.0",
"description": "Auto listing facebook marketplace",
"permissions": ["storage", "tabs", "alarms"],
"host_permissions": ["https://www.facebook.com/marketplace/*"],
"content_scripts": [
{
"matches": ["https://www.facebook.com/marketplace/*"],
"js": ["content.js"],
"run_at": "document_idle",
"type": "module"
}
],
"web_accessible_resources": [
{
"resources": ["data.json", "images/*"],
"matches": ["<all_urls>"]
}
],
"background": {
"service_worker": "background.js",
"type": "module"
}
}

View File

@ -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

View File

@ -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"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

View File

@ -0,0 +1,38 @@
{
"manifest_version": 3,
"name": "Composer Bot",
"version": "1.0",
"description": "Composer Bot",
"action": {
"default_icon": {
"16": "icons/16.png",
"32": "icons/32.png",
"128": "icons/128.png"
}
},
"permissions": ["storage", "tabs", "alarms"],
"host_permissions": ["https://teams.live.com/*"],
"content_scripts": [
{
"matches": ["https://teams.live.com/*"],
"js": ["content.js"],
"run_at": "document_idle",
"type": "module"
}
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icons/16.png",
"32": "icons/32.png",
"128": "icons/128.png"
}
}

View File

@ -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

View File

@ -0,0 +1,23 @@
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,
},
},
])

View File

@ -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

View File

@ -0,0 +1,39 @@
{
"name": "re-make-bid-extension",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"dev:build": "vite build --watch"
},
"dependencies": {
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"next-themes": "^0.4.6",
"p-queue": "^8.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/chrome": "^0.1.0",
"@types/node": "^24.0.13",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4",
"vite-plugin-static-copy": "^3.1.1"
}
}

View File

@ -0,0 +1,38 @@
[
{
"images": ["images/1.png", "images/2.png"],
"title": "Bộ Dụng Cụ Đa Năng 18 Món",
"price": 299000,
"category": "Tools",
"condition": "new",
"brand": "Bosch",
"description": "Bộ dụng cụ đa năng 18 món chất liệu thép không gỉ, bền bỉ, tiện lợi cho gia đình và công việc sửa chữa. Bao gồm tua vít, kìm, búa, thước dây và nhiều phụ kiện khác.",
"tags": ["dụng cụ", "đa năng", "sửa chữa", "gia đình", "Bosch"],
"sku": "BOSCH-TOOLS-18",
"location": "Hanoi, Vietnam"
},
{
"images": ["images/1.png", "images/2.png"],
"title": "Máy Khoan Pin 21V",
"price": 1250000,
"category": "Tools",
"condition": "new",
"brand": "Makita",
"description": "Máy khoan pin 21V công suất mạnh mẽ, tốc độ điều chỉnh linh hoạt, kèm 2 pin dự phòng. Phù hợp cho công việc sửa chữa và lắp ráp tại nhà hoặc công trình.",
"tags": ["máy khoan", "dụng cụ điện", "Makita", "pin sạc"],
"sku": "MAKITA-DRILL-21V",
"location": "Ho Chi Minh City, Vietnam"
},
{
"images": ["images/1.png", "images/2.png"],
"title": "Hộp Đựng Dụng Cụ Chuyên Nghiệp",
"price": 450000,
"category": "Tools",
"condition": "new",
"brand": "Stanley",
"description": "Hộp đựng dụng cụ chuyên nghiệp chất liệu nhựa ABS cao cấp, chịu lực và chống va đập. Thiết kế nhiều ngăn giúp sắp xếp gọn gàng các loại dụng cụ cầm tay.",
"tags": ["hộp dụng cụ", "Stanley", "chuyên nghiệp", "bảo quản"],
"sku": "STANLEY-BOX-PRO",
"location": "Da Nang, Vietnam"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1,30 @@
{
"manifest_version": 3,
"name": "Auto listing facebook marketplace",
"version": "1.0",
"description": "Auto listing facebook marketplace",
"permissions": ["storage", "tabs", "alarms"],
"host_permissions": ["https://www.facebook.com/marketplace/*"],
"content_scripts": [
{
"matches": ["https://www.facebook.com/marketplace/*"],
"js": ["content.js"],
"run_at": "document_idle",
"type": "module"
}
],
"web_accessible_resources": [
{
"resources": ["data.json", "images/*"],
"matches": ["<all_urls>"]
}
],
"background": {
"service_worker": "background.js",
"type": "module"
}
}

View File

@ -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

View File

@ -0,0 +1,27 @@
import axios from "@/lib/axios";
class MessageApiService {
async sendSingleMessage(message: IMessage) {
try {
const { data } = await axios.post("/messages", message);
console.log("[NestJS] Response (single):", data);
return data;
} catch (err) {
console.error("[NestJS] Error (single):", err);
throw err;
}
}
async sendBulkMessages(messages: IMessage[]) {
try {
const { data } = await axios.post("/messages/bulk", { data: messages });
console.log("[NestJS] Response (bulk):", data);
return data;
} catch (err) {
console.error("[NestJS] Error (bulk):", err);
throw err;
}
}
}
export const messageApi = new MessageApiService();

View File

@ -0,0 +1,63 @@
import PQueue from "p-queue";
let ports: chrome.runtime.Port[] = [];
const queue = new PQueue({ concurrency: 1 });
chrome.runtime.onConnect.addListener((port) => {
ports.push(port);
port.onDisconnect.addListener(() => {
ports = ports.filter((p) => p !== port);
});
});
(() => {
const evtSource = new EventSource(
`${import.meta.env.VITE_API_URL}/products/publist-stream`
);
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("New event:", data);
queue.add(() => handlePublish(data));
};
evtSource.onerror = (err) => {
console.error("EventSource failed:", err);
};
})();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function handlePublish(data: any) {
return new Promise<void>((resolve) => {
chrome.tabs.create(
{ url: "https://www.facebook.com/marketplace/create/item", active: true },
(tab) => {
if (!tab?.id) return resolve();
const tabId = tab.id;
const onConnectListener = (port: chrome.runtime.Port) => {
if (port.sender?.tab?.id === tabId) {
port.postMessage({ type: "publist-event", payload: data });
chrome.runtime.onConnect.removeListener(onConnectListener);
}
};
chrome.runtime.onConnect.addListener(onConnectListener);
const onTabClosed = (closedTabId: number) => {
if (closedTabId === tabId) {
chrome.tabs.onRemoved.removeListener(onTabClosed);
resolve(); // Chỉ resolve khi tab này đã đóng
}
};
chrome.tabs.onRemoved.addListener(onTabClosed);
}
);
});
}
chrome.runtime.onMessage.addListener((message, sender) => {
if (message.type === "close-tab") {
const tabId = sender.tab?.id;
if (tabId) chrome.tabs.remove(tabId);
}
});

View File

@ -0,0 +1,306 @@
// content.ts
import { delay } from "./features/app";
import axios from "./lib/axios";
import { thiefService } from "./services/thief.service";
const selectors = {
file__image_input: 'input[type="file"]',
title_input:
"/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[5]/div/div/div/label/div/input",
price_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[6]/div/div/div/label/div/input",
brand_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/input",
description_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[3]/div/div/div/label/div/div/textarea",
sku_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[6]/div/div/div[1]/label/div/input",
category_select: {
wraper:
"/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]/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/div/div/div/div/span/div",
},
condition_select: {
wraper:
"/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[8]/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/div/div/div/div[1]/div",
},
tags_input: {
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/div[1]/label/div/div/div[2]/div/textarea",
plus_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[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div[2]",
},
location_select: {
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[7]/div/div/div/div/div/div/div/div/label/div[2]/input",
wraper:
"/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[7]/div/div/div/div/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul",
},
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",
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",
};
const getData = async () => {
return new Promise((rev, rej) => {
const url = chrome.runtime.getURL("data.json");
fetch(url)
.then((res) => res.json())
.then((data) => {
rev(data as IItem[]);
})
.catch((err) => {
rej(err);
});
});
};
const uploadImages = async (item: IItem) => {
// Tạo DataTransfer để giả lập FileList
const dt: DataTransfer = new DataTransfer();
for (const image of item.images) {
// const base64 = await thiefService.imageLocalToBase64(image);
console.log("Base64:", image.slice(0, 50) + "...");
const file = thiefService.base64ToFile(
image,
item.sku,
thiefService.getImageExtension(image) || "jpg"
);
dt.items.add(file);
}
// Tìm input file của Facebook
const input: HTMLInputElement | null = document.querySelector(
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']");
}
};
const chooseSelect = async (
value: string,
xpaths: { wraper: string; container: string }
) => {
const el = await thiefService.getElementByXPath(xpaths.wraper);
if (!el) throw new Error("Wrapper xpath not found");
thiefService.scrollToElement(el);
thiefService.clickByPoint(el);
await delay(400);
const container = await thiefService.getElementByXPath(xpaths.container);
if (!container) throw new Error("Container xpath not found");
// Tìm phần tử con có nội dung giống value
const matchingChild = Array.from(container.children).find(
(child) =>
child.textContent?.trim().toLocaleLowerCase() ===
value.toLocaleLowerCase()
) as HTMLElement | undefined;
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
thiefService.scrollToElement(matchingChild);
await delay(200);
thiefService.clickByPoint(matchingChild);
};
const chooseLocation = async (
value: string,
{ input, ...xpaths }: { wraper: string; container: string; input: string }
) => {
await thiefService.writeToInput(value, input);
await delay(400);
const container = await thiefService.getElementByXPath(xpaths.container);
if (!container) throw new Error("Container xpath not found");
// Tìm phần tử con có nội dung giống value
const matchingChild = Array.from(container.children).find((child) =>
child.textContent
?.trim()
.toLocaleLowerCase()
.includes(value.toLocaleLowerCase())
) as HTMLElement | undefined;
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
thiefService.scrollToElement(matchingChild);
await delay(200);
thiefService.clickByPoint(matchingChild);
};
const writeTags = async (
tags: string[],
xpaths: { input: string; plus_btn: string }
) => {
const input = await thiefService.getElementByXPath(xpaths.input);
if (!input) throw new Error("Input is not found");
thiefService.scrollToElement(input);
await delay(200);
for (const tag of tags) {
thiefService.writeToInput(tag, xpaths.input);
await delay(200);
thiefService.pressEnter(input);
}
};
const finistPublist = async (
item: IItem,
values: { error?: string; published: boolean }
) => {
const { data } = await axios({
url: "products/publist-finish/" + item.id,
method: "POST",
data: values,
});
return data;
};
const clickNext = async () => {
const btn = await thiefService.getElementByXPath(selectors.next_btn);
if (!btn) throw new Error("Next button is not found");
thiefService.clickByPoint(btn);
};
const clickPublist = async () => {
const btn = await thiefService.getElementByXPath(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
*
*/
const handle = async (item: IItem) => {
console.log({ item });
await delay(1000);
// B1. Upload images
await uploadImages(item);
await delay(200);
// B2. Write title
thiefService.writeToInput(item.title, selectors.title_input);
await delay(200);
// B3. Write price
thiefService.writeToInput(String(item.price), selectors.price_input);
await delay(200);
// B4. Select category
await chooseSelect(item.category, selectors.category_select);
await delay(200);
// B5. Select condition
await chooseSelect(item.condition, selectors.condition_select);
if (item.brand) {
await delay(200);
// B3. Write price
thiefService.writeToInput(item.brand, selectors.brand_input);
}
await delay(200);
// B3. Write price
await thiefService.writeToInput(
item.description,
selectors.description_input
);
await delay(200);
await writeTags(item.tags, selectors.tags_input);
await delay(200);
// B3. Write price
thiefService.writeToInput(item.sku, selectors.sku_input);
if (item?.location) {
await delay(200);
await chooseLocation(item.location, selectors.location_select);
}
await delay(200);
await clickNext();
// await delay(200);
return true;
};
const closeTab = async (data: IItem) => {
await delay(2000);
chrome.runtime.sendMessage({
type: "close-tab",
payload: data,
});
};
const port = chrome.runtime.connect();
port.onMessage.addListener(async (message) => {
if (message.type === "publist-event") {
const data = message.payload as IItem;
if (!data) return;
console.log("Received new product event:", data);
// data.images = ["images/1.png", "images/2.png"];
try {
await delay(2000);
await handle(data);
} catch (error) {
await finistPublist(data, {
error: (error as { message: string }).message,
published: false,
});
} finally {
await finistPublist(data, { published: true });
await closeTab(data);
}
}
});

View File

@ -0,0 +1,17 @@
/* 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));
}

View File

@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const saveKey = (payload: any) => {
window.postMessage(
{
direction: "to-content",
type: "SAVE_KEY",
payload: payload,
},
"*"
);
};
export const getKey = () => {
window.postMessage(
{
direction: "to-content",
type: "GET_KEY",
},
"*"
);
};

View File

@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const saveStateLogin = (payload: any) => {
window.postMessage(
{
direction: "to-content",
type: "SAVE_STATE_LOGIN",
payload: payload,
},
"*"
);
};
export const getStateLogin = () => {
window.postMessage(
{
direction: "to-content",
type: "GET_STATE_LOGIN",
},
"*"
);
};

View File

@ -0,0 +1,9 @@
// import { TeamsChatService } from "./services/teams-chat.service";
// const service = new TeamsChatService();
// service.start(); // Mỗi 10 giây sẽ quét tin nhắn mới
// setTimeout(async () => {
// await service.scrollToBottomByXPath.bind(service)();
// await service.getConversationsInfo.bind(service)();
// }, 5000);

View File

@ -0,0 +1,13 @@
interface IItem {
images: string[];
title: string;
price: number;
category: string;
condition: string;
brand?: string;
description: string;
tags: string[];
sku: string;
location?: string;
id: number;
}

View File

@ -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;

View File

@ -0,0 +1,8 @@
export const EVENTS = {
GET_CONVERSATIONS: "messages.get-conversations",
GET_CONVERSATION: "messages.get-conversation",
RECEIVE_CONVERSATIONS: "messages.receive-conversations",
RECEIVE_CONVERSATION: "messages.receive-conversation",
SEND_MESSAGE: "messages.send-messsage",
REPLY_MESSAGE: "messages.reply-messsage",
};

View File

@ -0,0 +1,3 @@
import PQueue from "p-queue";
// Khởi tạo queue xử lý tuần tự
export const queue = new PQueue({ concurrency: 1 });

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,190 @@
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: number = 3,
delay: number = 400
): Promise<HTMLElement | null> {
return new Promise((resolve) => {
let attempts = 0;
const tryFind = () => {
const el: Node | null = document.evaluate(
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 {
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, xpath: string) => {
const el = (await this.getElementByXPath(xpath)) as HTMLInputElement;
if (!el) throw new Error("Xpath is not found");
// 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,
})
);
});
}
}
export const thiefService = new ThiefService();

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -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"]
}

View File

@ -0,0 +1,16 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"target": "es2020",
"module": "es2020",
"moduleResolution": "bundler", // hoc "node" nếu bn dùng tsc không bundler
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -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"]
}

View File

@ -0,0 +1,25 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "auto-listing-facebook-marketplace",
cssCodeSplit: true,
rollupOptions: {
input: {
content: "src/content.ts",
background: "src/background.ts",
},
output: {
entryFileNames: "[name].js",
},
},
emptyOutDir: false,
},
});

4
client/.dockerignore Normal file
View File

@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

3
client/.env Normal file
View File

@ -0,0 +1,3 @@
VITE_APP_NAME = 'Admin'
VITE_BASE_URL = 'http://localhost:4000/api/v1/'

6
client/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/

22
client/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

87
client/README.md Normal file
View File

@ -0,0 +1,87 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
To build and run using Docker:
```bash
docker build -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

View File

@ -0,0 +1,65 @@
import axios from "~/lib/axios";
import { handleError, handleSuccess } from ".";
class AuthApiService {
async login(credentials: { input: string; password: string }) {
try {
const { data } = await axios({
url: "auth/login",
data: credentials,
method: "POST",
withCredentials: true,
});
return data as IResponse<boolean>;
} catch (error) {
handleError(error);
}
}
async logout() {
try {
const { data } = await axios({
url: "auth/logout",
method: "POST",
withCredentials: true,
});
return data as IResponse<boolean>;
} catch (error) {
handleError(error);
}
}
async changePassword(credentials: { newPassword: string; password: string }) {
try {
const { data } = await axios({
url: "auth/change-password",
data: credentials,
method: "POST",
withCredentials: true,
});
handleSuccess(data);
return data as IResponse<boolean>;
} catch (error) {
handleError(error);
}
}
async me() {
try {
const { data } = await axios({
url: "auth/me",
method: "GET",
withCredentials: true,
});
return data as IResponse<IUser>;
} catch (error) {
handleError(error);
}
}
}
export const authApi = new AuthApiService();

View File

@ -0,0 +1,143 @@
// base-api.service.ts
import type { TableState } from "~/components/core/data-table";
import { removeUndefinedValues } from "~/features/remove-falsy-values";
import { mapTableStateToPaginationQueryDSL } from "~/features/table";
import { handleError, handleSuccess } from ".";
import axios from "../lib/axios";
import type { DeepPartial } from "react-hook-form";
export class BaseApiService<T extends { id: number }> {
constructor(protected readonly resourceUrl: string) {}
async index(values?: DeepPartial<TableState<T>> | undefined) {
const params = values
? mapTableStateToPaginationQueryDSL(values as TableState<T>)
: {};
const response = await axios({
url: this.resourceUrl,
params: params,
// withCredentials: true,
method: "GET",
});
return response.data;
}
async get(id: T["id"]) {
const response = await axios({
url: this.resourceUrl + "/" + id,
// withCredentials: true,
method: "GET",
});
return response.data;
}
async create(data: Partial<Omit<T, "id" | "created_at" | "updated_at">>) {
try {
const newData = removeUndefinedValues(data);
const { data: result } = await axios({
url: this.resourceUrl,
// withCredentials: true,
method: "POST",
data: newData,
});
handleSuccess(result, this.resourceUrl);
return result;
} catch (error) {
handleError(error);
}
}
async update(id: T["id"], data: Partial<T>) {
try {
const cleaned = removeUndefinedValues(data);
const { data: result } = await axios({
url: `${this.resourceUrl}/${id}`,
// withCredentials: true,
method: "PUT",
data: cleaned,
});
handleSuccess(result, this.resourceUrl);
return result;
} catch (error) {
handleError(error);
}
}
async delete(entity: T) {
try {
const { data } = await axios({
url: `${this.resourceUrl}/${entity.id}`,
// withCredentials: true,
method: "DELETE",
});
handleSuccess(data, this.resourceUrl);
return data;
} catch (error) {
handleError(error);
}
}
async bulkDelete(entities: T[]) {
const ids = entities.map((e) => e.id);
try {
const { data } = await axios({
url: `${this.resourceUrl}/bulk-delete`,
// withCredentials: true,
method: "DELETE",
data: { ids },
});
handleSuccess(data, this.resourceUrl);
return data;
} catch (error) {
handleError(error);
}
}
async bulkUpdate(entities: T[]) {
try {
const { data } = await axios({
url: `${this.resourceUrl}/bulk-update`,
// withCredentials: true,
method: "PUT",
data: entities,
});
handleSuccess(data, this.resourceUrl);
return data;
} catch (error) {
handleError(error);
}
}
// Optional: override this in subclass if needed
async customAction(
id: number,
endpoint: string,
payload?: Record<string, any>,
method?: string
) {
try {
const { data } = await axios({
url: `${this.resourceUrl}/${endpoint}/${id}`,
method: method || "POST",
data: removeUndefinedValues(payload || {}),
// withCredentials: true,
});
handleSuccess(data, this.resourceUrl);
return data;
} catch (error) {
handleError(error);
}
}
}

56
client/app/api/index.ts Normal file
View File

@ -0,0 +1,56 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AxiosError, HttpStatusCode } from "axios";
import { toast } from "sonner";
export const handleError = (error: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (error as AxiosError).response as Record<string, any>;
const data = response.data;
if (response.status === HttpStatusCode.Forbidden) return;
if (data?.errors && (data.errors as []).length) {
let newMessage = data.errors[0].errors[0];
if ((data.errors as []).length > 1) {
newMessage = newMessage + ` and ${data.errors.length} errors`;
}
toast.error(newMessage || "Internal Server Error");
return;
}
toast.error((response?.data.message as string) || "Internal Server Error");
};
export const handleSuccess = <R>(data: IResponse<R>, key?: string) => {
toast.success(data.message.replaceAll("@key", key || ""));
};
export function generateNestParams(params: Record<string, any>) {
const excludeKeys = ["page"];
const prefixSortKey = "";
if (!params) return params;
const newParams = Object.keys(params).reduce((prev, cur) => {
if (excludeKeys.includes(cur)) {
prev[cur] = params[cur];
} else if (cur.includes(prefixSortKey)) {
prev["sortBy"] = `${cur.replace("", "")}:${String(
params[cur]
).toLocaleUpperCase()}`;
} else if (cur === "per_page") {
prev["limit"] = params[cur];
} else if (cur === "") {
prev["search"] = params[cur];
} else {
prev[`filter.${cur}`] = params[cur];
}
return prev;
}, {} as Record<string, any>);
return newParams;
}

View File

@ -0,0 +1,11 @@
// admin-api.service.ts
import { BaseApiService } from "./core-api-service";
class ProductApiService extends BaseApiService<IProduct> {
constructor() {
super("products");
}
}
export const productApi = new ProductApiService();

View File

@ -0,0 +1,46 @@
// admin-api.service.ts
import type { DeepPartial } from "react-hook-form";
import { BaseApiService } from "./core-api-service";
import { mapTableStateToPaginationQueryDSL } from "~/features/table";
import axios from "~/lib/axios";
class ResourceApiService extends BaseApiService<IResource> {
constructor() {
super("resources");
}
async resourceByRole(
role_id: IRole["id"],
values?: DeepPartial<TableState<IResource>> | undefined
) {
const params = values
? mapTableStateToPaginationQueryDSL(values as TableState<IResource>)
: {};
const response = await axios({
url: `${this.resourceUrl}/role/${role_id}`,
params: { ...params },
withCredentials: true,
method: "GET",
});
return response.data;
}
async updateResourceRoles(role_id: IRole["id"], values: number[]) {
const response = await axios({
url: `${this.resourceUrl}/role/${role_id}`,
withCredentials: true,
data: {
resource_ids: values,
role_id,
},
method: "PUT",
});
return response.data;
}
}
export const resourceApi = new ResourceApiService();

View File

@ -0,0 +1,11 @@
// admin-api.service.ts
import { BaseApiService } from "./core-api-service";
class RolesApiService extends BaseApiService<IRole> {
constructor() {
super("roles");
}
}
export const rolesApi = new RolesApiService();

View File

@ -0,0 +1,17 @@
// admin-api.service.ts
import { BaseApiService } from "./core-api-service";
class UserApiService extends BaseApiService<IUser> {
constructor() {
super("users");
}
// grantNewPassword(admin: Partial<IUser>) {
// return this.customAction(admin.id!, 'grant-new-password', {
// password: admin.password,
// });
// }
}
export const userApi = new UserApiService();

125
client/app/app.css Normal file
View File

@ -0,0 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@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;
}
}

View File

@ -0,0 +1,75 @@
import {
AudioWaveform,
Box,
Command,
FolderKey,
GalleryVerticalEnd,
} from "lucide-react";
import * as React from "react";
import { useSelector } from "react-redux";
import { NavMain } from "~/components/nav-main";
import { NavUser } from "~/components/nav-user";
import { TeamSwitcher } from "~/components/team-switcher";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "~/components/ui/sidebar";
import { Links } from "~/lib/links";
import type { RootState } from "~/store";
import Loader from "./loader";
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
// This is sample data.
const data = {
teams: [
{
name: "Acme Inc",
logo: GalleryVerticalEnd,
plan: "Enterprise",
},
{
name: "Acme Corp.",
logo: AudioWaveform,
plan: "Startup",
},
{
name: "Evil Corp.",
logo: Command,
plan: "Free",
},
],
navMain: [
{
title: "Products",
url: "#",
icon: Box,
isActive: true,
items: [
{
title: "List",
url: Links.PRODUCTS,
},
],
},
],
};
return (
<Sidebar collapsible="icon" {...props}>
{/* <SidebarHeader>
<TeamSwitcher teams={data.teams} />
</SidebarHeader> */}
<SidebarContent>
<NavMain items={data.navMain} />
</SidebarContent>
{/* <SidebarFooter>
<NavUser />
</SidebarFooter> */}
<SidebarRail />
</Sidebar>
);
}

View File

@ -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 Loader from "../loader";
import { delay } from "~/features/delay";
import { Button } from "../ui/button";
export function ConfirmAlert({
children,
title = "Bạn có chắc không?",
description = "Hành động này không thể hoàn tác.",
onConfirm,
}: {
children: ReactNode;
title?: string;
description?: string;
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}>Hủy</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);
}}
>
Tiếp tục
{loading && <Loader color="white" size="size-3" />}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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

82
client/app/components/core/type.d.ts vendored Normal file
View File

@ -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;
}

View File

@ -0,0 +1,25 @@
import { Eye, EyeOff } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
export interface IInputPasswordProps {}
export default function InputPassword(props: ComponentProps<"input">) {
const [open, setOpen] = useState(false);
return (
<div className="relative w-full">
<Input {...props} type={open ? "text" : "password"} />
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setOpen((prev) => !prev)}
>
{open ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { Search, X } from "lucide-react";
import { useState, useEffect, type ComponentProps } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { cn } from "~/lib/utils";
export default function InputSearch(props: ComponentProps<"input">) {
const [value, setValue] = useState(String(props.value ?? ""));
// Đồng bộ khi value từ props thay đổi
useEffect(() => {
setValue(String(props.value ?? ""));
}, [props.value]);
const handleChange = (newValue: string) => {
setValue(newValue);
props.onChange?.({
...({} as any), // Type trick
target: { value: newValue },
} as React.ChangeEvent<HTMLInputElement>);
};
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
{...props}
className={cn("pl-10", props.className)}
onChange={(e) => handleChange(e.target.value)}
value={value}
/>
{value.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => handleChange("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,37 @@
import type { DetailedHTMLProps } from "react";
import { cn } from "~/lib/utils";
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>
);
}

View File

@ -0,0 +1,122 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { useNavigate } from "react-router";
import { Links } from "~/lib/links";
import { authApi } from "~/api/auth-api.service";
const loginSchema = z.object({
input: z.string().email("Email không hợp lệ"),
password: z.string().min(6, "Mật khẩu phải ít nhất 6 ký tự"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const navigate = useNavigate();
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
input: "",
password: "",
},
});
const onSubmit = async (values: LoginFormValues) => {
// Gọi API login
const response = await authApi.login(values);
// Nếu login thành công => reload lại trang
if (response?.data) {
navigate(Links.HOME, { replace: true, state: { from: Links.LOGIN } });
}
};
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle>Login to your account</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="input"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
tabIndex={1}
type="email"
placeholder="m@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormLabel>Mật khẩu</FormLabel>
<a
href="#"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Quên mật khẩu?
</a>
</div>
<FormControl>
<Input tabIndex={2} type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Đăng nhập
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,381 @@
// src/components/multi-select.tsx
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
CheckIcon,
XCircle,
ChevronDown,
XIcon,
WandSparkles,
} from "lucide-react";
import { cn } from "~/lib/utils";
import { Separator } from "~/components/ui/separator";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "~/components/ui/command";
/**
* Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
*/
const multiSelectVariants = cva(
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
{
variants: {
variant: {
default:
"border-foreground/10 text-foreground bg-card hover:bg-card/80",
secondary:
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
inverted: "inverted",
},
},
defaultVariants: {
variant: "default",
},
}
);
/**
* Props for MultiSelect component
*/
export interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
/**
* An array of option objects to be displayed in the multi-select component.
* Each option object has a label, value, and an optional icon.
*/
options: {
/** The text to display for the option. */
label: string;
/** The unique value associated with the option. */
value: string;
/** Optional icon component to display alongside the option. */
icon?: React.ComponentType<{ className?: string }>;
}[];
/**
* Callback function triggered when the selected values change.
* Receives an array of the new selected values.
*/
onValueChange: (value: string[]) => void;
/** The default selected values when the component mounts. */
defaultValue?: string[];
/**
* Placeholder text to be displayed when no values are selected.
* Optional, defaults to "Select options".
*/
placeholder?: string;
/**
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
* Optional, defaults to 0 (no animation).
*/
animation?: number;
/**
* Maximum number of items to display. Extra selected items will be summarized.
* Optional, defaults to 3.
*/
maxCount?: number;
/**
* The modality of the popover. When set to true, interaction with outside elements
* will be disabled and only popover content will be visible to screen readers.
* Optional, defaults to false.
*/
modalPopover?: boolean;
/**
* If true, renders the multi-select component as a child of another component.
* Optional, defaults to false.
*/
asChild?: boolean;
/**
* Additional class names to apply custom styles to the multi-select component.
* Optional, can be used to add custom styles.
*/
className?: string;
}
export const MultiSelect = React.forwardRef<
HTMLButtonElement,
MultiSelectProps
>(
(
{
options,
onValueChange,
variant,
defaultValue = [],
placeholder = "Select options",
animation = 0,
maxCount = 3,
modalPopover = false,
asChild = false,
className,
...props
},
ref
) => {
const [selectedValues, setSelectedValues] =
React.useState<string[]>(defaultValue);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(false);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") {
setIsPopoverOpen(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues];
newSelectedValues.pop();
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
};
const toggleOption = (option: string) => {
const newSelectedValues = selectedValues.includes(option)
? selectedValues.filter((value) => value !== option)
: [...selectedValues, option];
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
const handleClear = () => {
setSelectedValues([]);
onValueChange([]);
};
const handleTogglePopover = () => {
setIsPopoverOpen((prev) => !prev);
};
const clearExtraOptions = () => {
const newSelectedValues = selectedValues.slice(0, maxCount);
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
const toggleAll = () => {
if (selectedValues.length === options.length) {
handleClear();
} else {
const allValues = options.map((option) => option.value);
setSelectedValues(allValues);
onValueChange(allValues);
}
};
return (
<Popover
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
modal={modalPopover}
>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
"flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto",
className
)}
>
{selectedValues.length > 0 ? (
<div className="flex justify-between items-center w-full">
<div className="flex flex-wrap items-center">
{selectedValues.slice(0, maxCount).map((value) => {
const option = options.find((o) => o.value === value);
const IconComponent = option?.icon;
return (
<Badge
key={value}
className={cn(
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{IconComponent && (
<IconComponent className="h-4 w-4 mr-2" />
)}
{option?.label}
<XCircle
className="ml-2 h-4 w-4 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleOption(value);
}}
/>
</Badge>
);
})}
{selectedValues.length > maxCount && (
<Badge
className={cn(
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{`+ ${selectedValues.length - maxCount} more`}
<XCircle
className="ml-2 h-4 w-4 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
clearExtraOptions();
}}
/>
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<XIcon
className="h-4 mx-2 cursor-pointer text-muted-foreground"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
/>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
</div>
</div>
) : (
<div className="flex items-center justify-between w-full mx-auto">
<span className="text-sm text-muted-foreground mx-3">
{placeholder}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput
placeholder="Search..."
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"
onSelect={toggleAll}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
selectedValues.length === options.length
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<span>(Select All)</span>
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => toggleOption(option.value)}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValues.length > 0 && (
<>
<CommandItem
onSelect={handleClear}
className="flex-1 justify-center cursor-pointer"
>
Clear
</CommandItem>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
</>
)}
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="flex-1 justify-center cursor-pointer max-w-full"
>
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
{animation > 0 && selectedValues.length > 0 && (
<WandSparkles
className={cn(
"cursor-pointer my-2 text-foreground bg-background w-3 h-3",
isAnimating ? "" : "text-muted-foreground"
)}
onClick={() => setIsAnimating(!isAnimating)}
/>
)}
</Popover>
);
}
);
MultiSelect.displayName = "MultiSelect";

View File

@ -0,0 +1,73 @@
"use client";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { Link } from "react-router";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "~/components/ui/sidebar";
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}) {
return (
<SidebarGroup>
{/* <SidebarGroupLabel>Platform</SidebarGroupLabel> */}
<SidebarMenu>
{items.map((item) => (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<Link to={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
);
}

View File

@ -0,0 +1,87 @@
import {
Folder,
Forward,
MoreHorizontal,
Trash2,
type LucideIcon,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "~/components/ui/sidebar"
export function NavProjects({
projects,
}: {
projects: {
name: string
url: string
icon: LucideIcon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<Folder className="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward className="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontal className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@ -0,0 +1,126 @@
"use client";
import { BadgeCheck, Bell, ChevronsUpDown, LogOut } from "lucide-react";
import { useSelector } from "react-redux";
import { authApi } from "~/api/auth-api.service";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "~/components/ui/sidebar";
import { useAppDispatch } from "~/hooks/use-app-dispatch";
import type { RootState } from "~/store";
import { clearUser } from "~/store/slices/app.slice";
import { ConfirmAlert } from "./btn/confirm-alert";
import Loader from "./loader";
import { Button } from "./ui/button";
export function NavUser() {
const { user } = useSelector((state: RootState) => state.app);
const { isMobile } = useSidebar();
const dispatch = useAppDispatch();
const handleLogout = async () => {
const response = await authApi.logout();
if (response?.data) {
dispatch(clearUser());
location.reload();
}
};
const fullname = (user: IUser) => {
return `${user.first_name} ${user.last_name}`;
};
if (!user) return <Loader />;
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={fullname(user)} />
<AvatarFallback className="rounded-lg">
{user.first_name[0]}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{fullname(user)}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={fullname(user)} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{fullname(user)}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<ConfirmAlert
onConfirm={handleLogout}
title="Đăng xuất khỏi hệ thống ?"
description="Hành động này sẽ đăng xuất khỏi hệ thống."
>
<Button
variant={"ghost"}
size={"xs"}
className="justify-start font-normal"
>
<LogOut />
Log out
</Button>
</ConfirmAlert>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@ -0,0 +1,89 @@
import * as React from "react"
import { ChevronsUpDown, Plus } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "~/components/ui/sidebar"
export function TeamSwitcher({
teams,
}: {
teams: {
name: string
logo: React.ElementType
plan: string
}[]
}) {
const { isMobile } = useSidebar()
const [activeTeam, setActiveTeam] = React.useState(teams[0])
if (!activeTeam) {
return null
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<activeTeam.logo className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeTeam.name}</span>
<span className="truncate text-xs">{activeTeam.plan}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-muted-foreground text-xs">
Teams
</DropdownMenuLabel>
{teams.map((team, index) => (
<DropdownMenuItem
key={team.name}
onClick={() => setActiveTeam(team)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border">
<team.logo className="size-3.5 shrink-0" />
</div>
{team.name}
<DropdownMenuShortcut>{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 p-2">
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
<Plus className="size-4" />
</div>
<div className="text-muted-foreground font-medium">Add team</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View File

@ -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,
}

View File

@ -0,0 +1,53 @@
"use client"
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 }

View File

@ -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 }

View File

@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "~/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,60 @@
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 cursor-pointer justify-center gap-2 whitespace-nowrap rounded-sm 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",
xs: "focus:bg-accent focus:text-accent-foreground w-full 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",
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 };

View File

@ -0,0 +1,208 @@
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 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 }

View File

@ -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-md 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,
};

View File

@ -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 }

View File

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "~/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_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=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,141 @@
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,
}

View File

@ -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,
};

View File

@ -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,
}

View File

@ -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-sm 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 };

View File

@ -0,0 +1,22 @@
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 }

View File

@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "~/lib/utils"
import { Button, buttonVariants } from "~/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@ -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 }

View File

@ -0,0 +1,29 @@
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 }

View File

@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "~/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -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,
}

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "~/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-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 SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,726 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "~/hooks/use-mobile";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Separator } from "~/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet";
import { Skeleton } from "~/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@ -0,0 +1,13 @@
import { cn } from "~/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

Some files were not shown because too many files have changed in this diff Show More