Browse Source
feat: initialize panel project structure with UI components, routing, and services
master
feat: initialize panel project structure with UI components, routing, and services
master
commit
8c51785703
103 changed files with 12508 additions and 0 deletions
-
26.dockerignore
-
6.env.example
-
11.env.production.example
-
31.gitignore
-
39Dockerfile
-
163README.md
-
39docker-compose.yml
-
27docker/40-config-js.sh
-
7docker/nginx/00-upstream-map.conf
-
76docker/nginx/default.conf.template
-
23eslint.config.js
-
17index.html
-
4873package-lock.json
-
61package.json
-
7public/config.js
-
7public/favicon.svg
-
7public/hosseinieh-icon.svg
-
24public/icons.svg
-
2runner.sh
-
57src/app/App.tsx
-
30src/app/nprogress.tsx
-
20src/app/providers.tsx
-
38src/app/router.tsx
-
147src/components/layout/AppSidebar.tsx
-
129src/components/layout/PanelSlot.tsx
-
39src/components/layout/Topbar.tsx
-
163src/components/shared/AppImage.tsx
-
44src/components/shared/CardsSkeleton.tsx
-
64src/components/shared/DeleteConfirmDialog.tsx
-
21src/components/shared/IconButton.tsx
-
34src/components/shared/PanelHeader.tsx
-
21src/components/shared/SectionHeader.tsx
-
106src/components/shared/TablePagination.tsx
-
93src/components/shared/TableRowsSkeleton.tsx
-
50src/components/shared/ViewToggle.tsx
-
41src/components/ui/accordion.tsx
-
53src/components/ui/alert-dialog.tsx
-
35src/components/ui/avatar.tsx
-
26src/components/ui/badge.tsx
-
36src/components/ui/button.tsx
-
243src/components/ui/carousel.tsx
-
23src/components/ui/checkbox.tsx
-
154src/components/ui/command.tsx
-
67src/components/ui/dialog.tsx
-
67src/components/ui/dropdown-menu.tsx
-
16src/components/ui/input.tsx
-
190src/components/ui/multi-select.tsx
-
111src/components/ui/pagination.tsx
-
25src/components/ui/popover.tsx
-
63src/components/ui/select.tsx
-
8src/components/ui/separator.tsx
-
89src/components/ui/sheet.tsx
-
476src/components/ui/sidebar.tsx
-
155src/components/ui/single-select.tsx
-
13src/components/ui/skeleton.tsx
-
19src/components/ui/slider.tsx
-
19src/components/ui/switch.tsx
-
33src/components/ui/table.tsx
-
38src/components/ui/tabs.tsx
-
16src/components/ui/textarea.tsx
-
34src/components/ui/tooltip.tsx
-
33src/config/navigation.ts
-
17src/config/site.ts
-
154src/features/auth/LoginPage.tsx
-
18src/features/auth/RequireAuth.tsx
-
40src/features/auth/use-auth.ts
-
208src/features/dashboard/DashboardHome.tsx
-
86src/features/filters/FilterDrawer.tsx
-
103src/features/global-search/GlobalSearchDialog.tsx
-
44src/features/global-search/GlobalSearchTrigger.tsx
-
217src/features/items/ItemsPage.tsx
-
235src/features/items/SampleItemModal.tsx
-
113src/features/panels/SampleDetailPanel.tsx
-
125src/features/panels/UserDetailPanel.tsx
-
121src/features/settings/SettingsPage.tsx
-
544src/features/showcase/ComponentsShowcase.tsx
-
224src/features/users/UserModal.tsx
-
230src/features/users/UsersPage.tsx
-
43src/hooks/use-carousel-infinite.ts
-
16src/hooks/use-debounced-value.ts
-
49src/hooks/use-infinite-scroll.ts
-
32src/hooks/use-mobile.ts
-
106src/hooks/use-panel-routing.ts
-
110src/icons.tsx
-
205src/index.css
-
37src/lib/jalali.ts
-
12src/lib/query-client.ts
-
29src/lib/runtime-config.ts
-
32src/lib/utils.ts
-
13src/main.tsx
-
49src/services/auth.ts
-
126src/services/http.ts
-
154src/services/sample-api.ts
-
27src/services/upload.ts
-
120src/services/users-api.ts
-
40src/stores/auth.ts
-
23src/stores/filters.ts
-
27src/stores/global-search.ts
-
30src/stores/view-mode.ts
-
84src/types.ts
@ -0,0 +1,26 @@ |
|||||
|
# خروجی build و وابستگیها — داخل image دوباره ساخته میشوند |
||||
|
node_modules |
||||
|
dist |
||||
|
dist-ssr |
||||
|
|
||||
|
# کنترل نسخه و متادیتا |
||||
|
.git |
||||
|
.gitignore |
||||
|
|
||||
|
# فایلهای env — نباید به build context/image نشت کنند |
||||
|
.env |
||||
|
.env.* |
||||
|
|
||||
|
# مستندات، اسکرینشاتها و ابزارهای توسعه (در image لازم نیستند) |
||||
|
*.md |
||||
|
*.png |
||||
|
*.local |
||||
|
.playwright-mcp |
||||
|
.agents |
||||
|
.codex |
||||
|
.claude |
||||
|
.vscode |
||||
|
.idea |
||||
|
|
||||
|
# خود فایلهای داکر (در build context لازم نیستند جز آنهایی که COPY میشوند) |
||||
|
docker-compose.yml |
||||
@ -0,0 +1,6 @@ |
|||||
|
# ─── تنظیمات اتصال به API و بکاند (توسعه محلی) ─── |
||||
|
# آدرس پایه API که کلاینت صدا میزند (پیشفرض: /api) |
||||
|
VITE_API_BASE_URL=/api |
||||
|
|
||||
|
# مقصد پراکسی توسعه Vite برای هدایت درخواستهای /api به سرور بکاند |
||||
|
VITE_API_TARGET=http://localhost:8000 |
||||
@ -0,0 +1,11 @@ |
|||||
|
# ─── تنظیمات پروداکشن (نمونه) ─── |
||||
|
# |
||||
|
# این فایل را به .env.production کپی کنید و مقادیر را تنظیم نمایید: |
||||
|
# cp .env.production.example .env.production |
||||
|
# docker-compose با env_file این فایل را میخواند. |
||||
|
|
||||
|
# آدرس سرور بکاند (برای پراکسی Nginx در کانتینر) |
||||
|
BACKEND_UPSTREAM=api.example.com |
||||
|
|
||||
|
# پایه آدرس API کلاینت |
||||
|
VITE_API_BASE_URL=/api |
||||
@ -0,0 +1,31 @@ |
|||||
|
# Logs |
||||
|
logs |
||||
|
*.log |
||||
|
npm-debug.log* |
||||
|
yarn-debug.log* |
||||
|
pnpm-debug.log* |
||||
|
|
||||
|
# Dependencies & Build |
||||
|
node_modules |
||||
|
dist |
||||
|
dist-ssr |
||||
|
*.local |
||||
|
|
||||
|
# Environment variables (محیطهای محرمانه هرگز کامیت نشوند) |
||||
|
.env |
||||
|
.env.production |
||||
|
.env.*.local |
||||
|
!.env.example |
||||
|
!.env.production.example |
||||
|
|
||||
|
# Editor & OS |
||||
|
.vscode/* |
||||
|
!.vscode/extensions.json |
||||
|
.idea |
||||
|
.DS_Store |
||||
|
*.suo |
||||
|
*.ntvs* |
||||
|
*.njsproj |
||||
|
*.sln |
||||
|
*.sw? |
||||
|
*.playwright-mcp |
||||
@ -0,0 +1,39 @@ |
|||||
|
# syntax=docker/dockerfile:1.7 |
||||
|
|
||||
|
# ────────────────────────────────────────────────────────────────────────── |
||||
|
# مرحلهٔ ۱ — build: کامپایل SPA با Vite |
||||
|
# ────────────────────────────────────────────────────────────────────────── |
||||
|
FROM node:22-alpine AS builder |
||||
|
|
||||
|
WORKDIR /app |
||||
|
|
||||
|
# نصب وابستگیها با کش npm و وابسته به package-lock |
||||
|
# نکته: npm ci درست است؛ اگر fail شد یعنی package-lock.json باید sync شود. |
||||
|
COPY package.json package-lock.json ./ |
||||
|
RUN --mount=type=cache,target=/root/.npm npm ci |
||||
|
|
||||
|
# کپی سورس و build |
||||
|
COPY . . |
||||
|
RUN npm run build |
||||
|
|
||||
|
# ────────────────────────────────────────────────────────────────────────── |
||||
|
# مرحلهٔ ۲ — runtime: سرو استاتیک با nginx + تزریق env در زمان اجرا |
||||
|
# ────────────────────────────────────────────────────────────────────────── |
||||
|
FROM nginx:1.27-alpine AS runtime |
||||
|
|
||||
|
# خروجی build |
||||
|
COPY --from=builder /app/dist /usr/share/nginx/html |
||||
|
|
||||
|
# پیکربندی nginx |
||||
|
COPY docker/nginx/00-upstream-map.conf /etc/nginx/conf.d/00-upstream-map.conf |
||||
|
COPY docker/nginx/default.conf.template /etc/nginx/templates/default.conf.template |
||||
|
|
||||
|
# تولید config.js در زمان start توسط entrypoint رسمی nginx |
||||
|
COPY docker/40-config-js.sh /docker-entrypoint.d/40-config-js.sh |
||||
|
RUN chmod +x /docker-entrypoint.d/40-config-js.sh |
||||
|
|
||||
|
# envsubst فقط همین متغیر را جایگزین کند تا $uri/$host nginx خراب نشود |
||||
|
ENV NGINX_ENVSUBST_FILTER=^BACKEND_UPSTREAM$ |
||||
|
ENV BACKEND_UPSTREAM=host.docker.internal:8001 |
||||
|
|
||||
|
EXPOSE 80 |
||||
@ -0,0 +1,163 @@ |
|||||
|
# 🚀 قالب آماده و مدرن داشبورد مدیریتی (Dashboard Starter Kit) |
||||
|
|
||||
|
یک استارتر کیت پیشرفته، ماژولار و کاملاً راستچین (RTL) بر پایه **React 19**، **Tailwind CSS v4**، **Vite 8** و **Shadcn UI** طراحیشده برای ساخت سریع پنلهای ادمین و داشبوردهای سازمانی. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ✨ ویژگیهای کلیدی |
||||
|
|
||||
|
- **🎨 طراحی مدرن و کاملاً راستچین (RTL-First):** همراه با فونت وزیرمتن (Vazirmatn) و متغیرهای مدرن CSS. |
||||
|
- **🏛 شل سه ستونه هوشمند (Triple-Column Layout):** |
||||
|
- **سایدبار راست (Right Sidebar):** منوی جمعشونده (Collapsible Offcanvas) بر اساس کانفیگ متمرکز. |
||||
|
- **محتوای میانی (Main Content):** هدر ناوبری (Topbar) همراه با Breadcrumb پویا. |
||||
|
- **پنل کشویی چپ (Left Slide-over Panel):** باز شدن جزئیات هر رکورد از چپ بدون ترک یا فشرده شدن صفحه اصلی. |
||||
|
- **⚡ مدیریت پنلها و مودالها از طریق URL (`usePanelRouting`):** |
||||
|
- قابلیت اشتراکگذاری لینک مودال یا پنل باز (Deep Linking). |
||||
|
- هماهنگ با دکمه Back/Forward مرورگر و کلید Escape. |
||||
|
- **🔍 ابزار جستجوی سراسری هوشمند (Command Palette):** میانبر `Ctrl + K` یا `Cmd + K` برای دسترسی آنی به تمامی صفحات و اکشنهای سریع. |
||||
|
- **🔒 سیستم احراز هویت یکپارچه (Auth Flow):** |
||||
|
- صفحه لاگین با حالت آزمایشی (Demo Mode) برای تست سریع بدون نیاز به بکاند. |
||||
|
- کلاینت HTTP با هدرهای خودکار Bearer Token و مدیریت خطای ۴۰۱ (Auto-Logout). |
||||
|
- گارد امنیتی مسیرها (`RequireAuth`). |
||||
|
- **🧩 بیش از ۲۵ کامپوننت آماده Shadcn UI + Radix:** |
||||
|
- Dialog / Modal، Alert Dialog، Table، Tabs، Sheet، Select، MultiSelect، Switch، Dropdown، Accordion، Pagination و... |
||||
|
- **📁 معماری تمیز و فیچرمحور (Feature-Based Architecture):** سازماندهی بر اساس موجودیتها به همراه دو صفحه نمونه کامل CRUD (کاربران و آیتمها). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🛠 راهاندازی و اجرا |
||||
|
|
||||
|
```bash |
||||
|
# ۱. نصب پکیجها |
||||
|
npm install |
||||
|
|
||||
|
# ۲. اجرای سرور توسعه |
||||
|
npm run dev |
||||
|
|
||||
|
# ۳. بررسی خطاهای تایپاسکریپت |
||||
|
npm run typecheck |
||||
|
|
||||
|
# ۴. بیلد نهایی پروداکشن |
||||
|
npm run build |
||||
|
``` |
||||
|
|
||||
|
> **اطلاعات حساب دمو (پیشفرض):** |
||||
|
> - **ایمیل:** `admin@example.com` |
||||
|
> - **رمز عبور:** `admin` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📂 ساختار پوشهها |
||||
|
|
||||
|
```text |
||||
|
dashboard-template/ |
||||
|
├── src/ |
||||
|
│ ├── app/ # روتینگ، گارد احراز هویت و شل اصلی |
||||
|
│ │ ├── App.tsx # بدنه اصلی (Sidebar + Topbar + PanelSlot) |
||||
|
│ │ ├── router.tsx # تعریف مسیرهای پروژه |
||||
|
│ │ └── providers.tsx # کانتکستهای عمومی (React Query و ...) |
||||
|
│ │ |
||||
|
│ ├── config/ # تنظیمات متمرکز سیستم |
||||
|
│ │ ├── site.ts # نام پروژه، لوگو و اطلاعات برند |
||||
|
│ │ └── navigation.ts # لیست صفحات و منوهای سایدبار |
||||
|
│ │ |
||||
|
│ ├── components/ |
||||
|
│ │ ├── ui/ # کامپوننتهای پایه Shadcn (Button, Dialog, Table, ...) |
||||
|
│ │ ├── layout/ # اجزای قالب (AppSidebar, Topbar, PanelSlot) |
||||
|
│ │ └── shared/ # کامپوننتهای پرتکرار (TablePagination, DeleteConfirmDialog, ...) |
||||
|
│ │ |
||||
|
│ ├── features/ # ماژولهای بیزینسی (Feature-based) |
||||
|
│ │ ├── auth/ # ورود، خروج و گارد دسترسی |
||||
|
│ │ ├── dashboard/ # صفحه داشبورد و آمار |
||||
|
│ │ ├── users/ # مدیریت کاربران (جدول، مودال ایجاد/ویرایش) |
||||
|
│ │ ├── items/ # مدیریت نمونه آیتمها (CRUD الگو) |
||||
|
│ │ ├── panels/ # پنلهای کشویی جزئیات رکوردها |
||||
|
│ │ ├── filters/ # دراور فیلترهای پیشرفته |
||||
|
│ │ └── settings/ # صفحه تنظیمات پنل |
||||
|
│ │ |
||||
|
│ ├── services/ # ارتباط با سرور و API |
||||
|
│ │ ├── http.ts # کلاینت مرکزی Fetch با اینترسپتور توکن |
||||
|
│ │ ├── auth.ts # سرویس لاگین و پروفایل |
||||
|
│ │ ├── users-api.ts # سرویس کاربران (همراه با Mock fallback) |
||||
|
│ │ └── sample-api.ts # سرویس نمونه CRUD |
||||
|
│ │ |
||||
|
│ ├── stores/ # استیتهای کلاینت با Zustand (Auth, Search, ...) |
||||
|
│ ├── hooks/ # هوکهای سفارشی (usePanelRouting, useMobile, ...) |
||||
|
│ ├── types.ts # تایپهای عمومی، Pagination و دامنهها |
||||
|
│ └── index.css # توکنهای طراحی، متغیرهای CSS و رنگهای قالب |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📖 راهنمای استفاده در پروژههای جدید |
||||
|
|
||||
|
### ۱. چگونه نام و برند داشبورد را تغییر دهم؟ |
||||
|
فایل `src/config/site.ts` را باز کرده و عنوان و توضیحات را ویرایش کنید: |
||||
|
```ts |
||||
|
export const siteConfig = { |
||||
|
name: 'پنل مدیریت من', |
||||
|
shortName: 'داشبورد', |
||||
|
description: 'سیستم یکپارچه مدیریت سازمانی', |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### ۲. چگونه منوی جدید به سایدبار اضافه کنم؟ |
||||
|
فایل `src/config/navigation.ts` را ویرایش کنید. منو بهصورت خودکار در سایدبار و در پنجره `Ctrl+K` نمایش داده میشود: |
||||
|
```ts |
||||
|
{ |
||||
|
title: 'محصولات و انبار', |
||||
|
items: [ |
||||
|
{ title: 'لیست کالاها', to: '/products', icon: 'grid' }, |
||||
|
{ title: 'سفارشها', to: '/orders', icon: 'folder', badge: '۳ جدید' }, |
||||
|
], |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### ۳. چگونه یک مودال (Modal) ایجاد و باز کنم؟ |
||||
|
از هوک `usePanelRouting` استفاده کنید: |
||||
|
```tsx |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
|
||||
|
function MyComponent() { |
||||
|
const { openModal } = usePanelRouting() |
||||
|
|
||||
|
return ( |
||||
|
<Button onClick={() => openModal('user', 'new')}> |
||||
|
افزودن کاربر |
||||
|
</Button> |
||||
|
) |
||||
|
} |
||||
|
``` |
||||
|
این کار پارامتر `?edit=user:new` را در URL ست میکند که باعث باز شدن مودال مربوطه میشود. برای ویرایش، شناسه رکورد را بفرستید: `openModal('user', '123')`. |
||||
|
|
||||
|
### ۴. چگونه پنل کشویی جزئیات (Detail Drawer) را از سمت چپ باز کنم؟ |
||||
|
به سادگی شناسه و نوع را با `openPanel` صدا بزنید: |
||||
|
```tsx |
||||
|
const { openPanel } = usePanelRouting() |
||||
|
|
||||
|
// در رویداد کلیک روی سطر جدول: |
||||
|
<TableRow onClick={() => openPanel('user', user.id)}> |
||||
|
``` |
||||
|
سپس در `src/components/layout/PanelSlot.tsx` کامپوننت پنل جدیدتان را به `switch-case` اضافه نمایید. |
||||
|
|
||||
|
### ۵. چگونه رنگ اصلی برند (Primary Color) را تغییر دهم؟ |
||||
|
در فایل `src/index.css`، مقدار متغیر `--primary` را با کد رنگ دلخواهتان جایگزین کنید: |
||||
|
```css |
||||
|
:root { |
||||
|
--primary: #3b82f6; /* آبی کاربنی */ |
||||
|
/* یا #10b981 (زمردی) یا #8b5cf6 (بنفش) */ |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 💻 کپی برای پروژه جدید |
||||
|
|
||||
|
برای ساخت یک پروژه جدید بر اساس این تمپلیت، کافیست محتویات این پوشه را کپی کرده و شروع به توسعه فیچرهای جدید کنید: |
||||
|
|
||||
|
```bash |
||||
|
cp -r /path/to/dashboard-template /path/to/my-new-app |
||||
|
cd /path/to/my-new-app |
||||
|
npm install |
||||
|
npm run dev |
||||
|
``` |
||||
@ -0,0 +1,39 @@ |
|||||
|
# docker-compose برای داشبورد حسینیه (SPA پشت nginx). |
||||
|
# |
||||
|
# منبع واحد env در پروداکشن: فایل .env.production (با env_file خوانده میشود). |
||||
|
# همهٔ متغیرها از همان یک جا میآیند و در runtime به کانتینر تزریق میشوند. |
||||
|
|
||||
|
services: |
||||
|
web: |
||||
|
build: |
||||
|
context: . |
||||
|
dockerfile: Dockerfile |
||||
|
image: hosseinieh-dashboard:latest |
||||
|
container_name: hosseinieh-dashboard |
||||
|
restart: unless-stopped |
||||
|
|
||||
|
# ← تنها منبع متغیرهای محیط در پروداکشن |
||||
|
env_file: |
||||
|
- .env.production |
||||
|
|
||||
|
# ← پورت 5010 میزبان به 80 کانتینر (nginx) |
||||
|
ports: |
||||
|
- "5010:80" |
||||
|
|
||||
|
# دسترسی به بکاندی که روی خود میزبان اجرا میشود (host.docker.internal) |
||||
|
# extra_hosts: |
||||
|
# - "host.docker.internal:host-gateway" |
||||
|
|
||||
|
healthcheck: |
||||
|
test: ["CMD", "wget", "-qO-", "http://localhost/healthz"] |
||||
|
interval: 30s |
||||
|
timeout: 3s |
||||
|
retries: 3 |
||||
|
start_period: 10s |
||||
|
networks: |
||||
|
- najm_najm |
||||
|
|
||||
|
networks: |
||||
|
najm_najm: |
||||
|
external: true |
||||
|
|
||||
@ -0,0 +1,27 @@ |
|||||
|
#!/bin/sh |
||||
|
# تولید /config.js از متغیرهای محیط — «منبع واحدِ env در runtime». |
||||
|
# |
||||
|
# nginx رسمی هر اسکریپت اجراییِ /docker-entrypoint.d/*.sh را پیش از استارت سرور |
||||
|
# اجرا میکند. اینجا مقادیر env (که از .env.production → docker-compose میآیند) |
||||
|
# را در window.__ENV__ مینویسیم تا app آنها را در runtime بخواند — بدون rebuild. |
||||
|
# |
||||
|
# توجه: کلید Soniox از این مسیر نمیآید — در کد هاردکد شده است (نه env، نه API). |
||||
|
set -eu |
||||
|
|
||||
|
TARGET="/usr/share/nginx/html/config.js" |
||||
|
|
||||
|
# پیشفرضها (همراستا با fallbackهای کد). با env قابل override. |
||||
|
VITE_API_BASE_URL="${VITE_API_BASE_URL:-/api}" |
||||
|
|
||||
|
# escape امنِ مقادیر برای قرارگیری داخل رشتهٔ JS (backslash، کوتیشن، خط جدید). |
||||
|
js_escape() { |
||||
|
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e "s/'/\\\\'/g" | tr '\n' ' ' |
||||
|
} |
||||
|
|
||||
|
{ |
||||
|
echo "window.__ENV__ = {" |
||||
|
echo " VITE_API_BASE_URL: '$(js_escape "$VITE_API_BASE_URL")'," |
||||
|
echo "};" |
||||
|
} > "$TARGET" |
||||
|
|
||||
|
echo "[40-config-js] wrote runtime env to $TARGET" |
||||
@ -0,0 +1,7 @@ |
|||||
|
# تعریف $connection_upgrade در http context — لازمهٔ پراکسیِ WebSocket/upgrade. |
||||
|
# این فایل مستقیم در /etc/nginx/conf.d/ کپی میشود (بدون envsubst) و قبل از |
||||
|
# default.conf بارگذاری میشود (ترتیب الفبایی: 00- مقدم است). |
||||
|
map $http_upgrade $connection_upgrade { |
||||
|
default upgrade; |
||||
|
'' close; |
||||
|
} |
||||
@ -0,0 +1,76 @@ |
|||||
|
# پیکربندی nginx برای داشبورد SPA. |
||||
|
# |
||||
|
# این یک template است: nginx رسمی هنگام استارت با envsubst فقط ${BACKEND_UPSTREAM} |
||||
|
# را جایگزین میکند و خروجی را در /etc/nginx/conf.d/default.conf مینویسد. |
||||
|
|
||||
|
server { |
||||
|
listen 80; |
||||
|
server_name _; |
||||
|
|
||||
|
root /usr/share/nginx/html; |
||||
|
index index.html; |
||||
|
|
||||
|
# فشردهسازی متن/اسکریپت/استایل/SVG |
||||
|
gzip on; |
||||
|
gzip_vary on; |
||||
|
gzip_min_length 1024; |
||||
|
gzip_proxied any; |
||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml; |
||||
|
|
||||
|
# ── healthcheck سبک (بدون لاگ) ── |
||||
|
location = /healthz { |
||||
|
access_log off; |
||||
|
add_header Content-Type text/plain; |
||||
|
return 200 'ok'; |
||||
|
} |
||||
|
|
||||
|
# ── پراکسیِ API → بکاند (${BACKEND_UPSTREAM}) ── |
||||
|
# اسلش انتهاییِ proxy_pass پیشوند «/api» را حذف میکند تا به ریشهٔ بکاند برسد |
||||
|
# (معادل rewrite: p.replace(/^\/api/, '') در vite.config.ts برای dev). |
||||
|
# |
||||
|
# چون به یک هاست خارجیِ HTTPS پراکسی میکنیم: |
||||
|
# • Host را برابر خودِ بکاند میگذاریم (نه $host=localhost:5010) چون Django |
||||
|
# روی ALLOWED_HOSTS/CORS بر اساس Host تصمیم میگیرد. |
||||
|
# • SNI را با proxy_ssl_server_name/proxy_ssl_name روشن میکنیم تا TLS |
||||
|
# handshake با نام درست انجام شود. |
||||
|
location /api/ { |
||||
|
proxy_pass https://${BACKEND_UPSTREAM}/; |
||||
|
proxy_http_version 1.1; |
||||
|
proxy_set_header Host ${BACKEND_UPSTREAM}; |
||||
|
proxy_set_header X-Real-IP $remote_addr; |
||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
||||
|
proxy_set_header X-Forwarded-Proto $scheme; |
||||
|
|
||||
|
# TLS به سمت بکاند خارجی |
||||
|
proxy_ssl_server_name on; |
||||
|
proxy_ssl_name ${BACKEND_UPSTREAM}; |
||||
|
|
||||
|
# پشتیبانی از WebSocket/upgrade (برای آینده، مثلاً استریم) |
||||
|
proxy_set_header Upgrade $http_upgrade; |
||||
|
proxy_set_header Connection $connection_upgrade; |
||||
|
proxy_read_timeout 300s; |
||||
|
} |
||||
|
|
||||
|
# ── پیکربندی runtime: همیشه تازه (تا تغییر env بدون کش اعمال شود) ── |
||||
|
location = /config.js { |
||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate"; |
||||
|
try_files /config.js =404; |
||||
|
} |
||||
|
|
||||
|
# ── shell اپ: کش نشود تا دیپلوی جدید فوراً دیده شود ── |
||||
|
location = /index.html { |
||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate"; |
||||
|
} |
||||
|
|
||||
|
# ── داراییهای هشدار: کش طولانیمدت و immutable ── |
||||
|
location /assets/ { |
||||
|
expires 1y; |
||||
|
add_header Cache-Control "public, immutable"; |
||||
|
try_files $uri =404; |
||||
|
} |
||||
|
|
||||
|
# ── SPA fallback: هر مسیر ناشناخته به index.html (روتینگ سمت کلاینت) ── |
||||
|
location / { |
||||
|
try_files $uri $uri/ /index.html; |
||||
|
} |
||||
|
} |
||||
@ -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.flat.recommended, |
||||
|
reactRefresh.configs.vite, |
||||
|
], |
||||
|
languageOptions: { |
||||
|
globals: globals.browser, |
||||
|
parserOptions: { ecmaFeatures: { jsx: true } }, |
||||
|
}, |
||||
|
}, |
||||
|
]) |
||||
@ -0,0 +1,17 @@ |
|||||
|
<!doctype html> |
||||
|
<html lang="fa" dir="rtl"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8" /> |
||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
|
<title>داشبورد مدیریت</title> |
||||
|
<!-- پیکربندی محیطِ runtime: در پروداکشن توسط entrypoint کانتینر بازنویسی |
||||
|
میشود (window.__ENV__). در dev این استابِ خالی است و app به |
||||
|
import.meta.env (از .env) fallback میکند. باید قبل از باندل لود شود. --> |
||||
|
<script src="/config.js"></script> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div id="root"></div> |
||||
|
<script type="module" src="/src/main.tsx"></script> |
||||
|
</body> |
||||
|
</html> |
||||
4873
package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,61 @@ |
|||||
|
{ |
||||
|
"name": "dashboard-template", |
||||
|
"private": true, |
||||
|
"version": "1.0.0", |
||||
|
"type": "module", |
||||
|
"scripts": { |
||||
|
"dev": "vite", |
||||
|
"build": "tsc --noEmit && vite build", |
||||
|
"typecheck": "tsc --noEmit", |
||||
|
"test": "vitest run", |
||||
|
"lint": "eslint .", |
||||
|
"preview": "vite preview" |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@radix-ui/react-accordion": "^1.2.13", |
||||
|
"@radix-ui/react-alert-dialog": "^1.1.16", |
||||
|
"@radix-ui/react-avatar": "^1.1.12", |
||||
|
"@radix-ui/react-checkbox": "^1.3.4", |
||||
|
"@radix-ui/react-dialog": "^1.1.16", |
||||
|
"@radix-ui/react-direction": "^1.1.2", |
||||
|
"@radix-ui/react-dropdown-menu": "^2.1.17", |
||||
|
"@radix-ui/react-popover": "^1.1.16", |
||||
|
"@radix-ui/react-scroll-area": "^1.2.11", |
||||
|
"@radix-ui/react-select": "^2.3.0", |
||||
|
"@radix-ui/react-separator": "^1.1.9", |
||||
|
"@radix-ui/react-slider": "^1.4.0", |
||||
|
"@radix-ui/react-slot": "^1.2.5", |
||||
|
"@radix-ui/react-switch": "^1.3.0", |
||||
|
"@radix-ui/react-tabs": "^1.1.14", |
||||
|
"@radix-ui/react-tooltip": "^1.2.9", |
||||
|
"@tailwindcss/vite": "^4.3.0", |
||||
|
"@tanstack/react-query": "^5.101.0", |
||||
|
"class-variance-authority": "^0.7.1", |
||||
|
"clsx": "^2.1.1", |
||||
|
"cmdk": "^1.1.1", |
||||
|
"embla-carousel-react": "^8.6.0", |
||||
|
"nprogress": "^0.2.0", |
||||
|
"react": "^19.2.6", |
||||
|
"react-dom": "^19.2.6", |
||||
|
"react-router-dom": "^7.17.0", |
||||
|
"tailwind-merge": "^3.6.0", |
||||
|
"tailwindcss": "^4.3.0", |
||||
|
"zustand": "^5.0.14" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"@eslint/js": "^10.0.1", |
||||
|
"@types/node": "^25.9.3", |
||||
|
"@types/nprogress": "^0.2.3", |
||||
|
"@types/react": "^19.2.14", |
||||
|
"@types/react-dom": "^19.2.3", |
||||
|
"@vitejs/plugin-react": "^6.0.1", |
||||
|
"eslint": "^10.3.0", |
||||
|
"eslint-plugin-react-hooks": "^7.1.1", |
||||
|
"eslint-plugin-react-refresh": "^0.5.2", |
||||
|
"globals": "^17.6.0", |
||||
|
"typescript": "^6.0.3", |
||||
|
"typescript-eslint": "^8.61.0", |
||||
|
"vite": "^8.0.12", |
||||
|
"vitest": "^4.1.8" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
/* استابِ پیکربندی runtime (توسعهٔ محلی). |
||||
|
* |
||||
|
* در پروداکشن، entrypoint کانتینر این فایل را با مقادیر واقعیِ env بازنویسی |
||||
|
* میکند (نگاه کنید به docker/40-config-js.sh). نگهداشتن یک __ENV__ خالی در |
||||
|
* dev باعث میشود getEnv() به import.meta.env (از .env) fallback کند و تگ |
||||
|
* <script src="/config.js"> در dev خطای 404 ندهد. */ |
||||
|
window.__ENV__ = {} |
||||
@ -0,0 +1,7 @@ |
|||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="38.72" height="40.277" viewBox="0 0 38.72 40.277"> |
||||
|
<g id="par-04" transform="translate(-63.28 -49.67)"> |
||||
|
<path id="Path_1621" data-name="Path 1621" d="M67.1,90.615h0a.855.855,0,0,1,1.047.6l8.35,30.79a.855.855,0,0,1-.6,1.047h0a.855.855,0,0,1-1.047-.6L66.5,91.662A.855.855,0,0,1,67.1,90.615Z" transform="translate(-2.58 -33.139)" fill="#fff"/> |
||||
|
<circle id="Ellipse_266" data-name="Ellipse 266" cx="1.31" cy="1.31" r="1.31" transform="translate(63.28 56.429)" fill="#fff"/> |
||||
|
<path id="Path_1622" data-name="Path 1622" d="M110.17,49.67s-3.357,6.4-15.648,4.624S80.33,58.983,80.33,58.983l5.449,20.652s3.041-6.97,15.332-4.814,14.7-4.434,14.7-4.434ZM91.063,65.955c.046-.156.12-.4.211-.709.12.055.258.12.433.19a2.281,2.281,0,0,1,.4.22.95.95,0,0,1,.23.3.739.739,0,0,1,.065.359,2.026,2.026,0,0,1-.074.479,1.653,1.653,0,0,1-.211.515.464.464,0,0,1-.19.19.6.6,0,0,1-.258-.084c-.139-.065-.268-.12-.38-.175s-.211-.1-.295-.148-.129-.091-.139-.139C90.962,66.458,91.017,66.111,91.063,65.955Zm7.711,3.231c-.12.323-.23.5-.314.515s-.19-.036-.38-.148a4.39,4.39,0,0,0-.5-.258c-.139.654-.295,1-.46,1.042a.35.35,0,0,1-.2-.055c-.091-.046-.23-.11-.424-.19s-.295-.148-.295-.175a2.223,2.223,0,0,1,.129-.783,2.42,2.42,0,0,1,.239-.737c.084-.055.23-.029.443.091a2.979,2.979,0,0,1,.507.34c.065-.2.129-.424.2-.673s.129-.38.175-.4a.771.771,0,0,1,.553.165.977.977,0,0,1,.433.469,1.294,1.294,0,0,1-.11.794Zm3.463-12.544a2.392,2.392,0,0,1-.036.534.694.694,0,0,1-.148.34c-.029.029-.074.074-.129.12a2.486,2.486,0,0,0-.239.285q-.165.207-.608.857c-.295.433-.627.884-.986,1.355l-.015-.019v.019l-.074.1a.475.475,0,0,1-.165.165c-.074.046-.12.084-.148.1a.852.852,0,0,0-.139.156c-.036.055-.091.12-.148.19a.262.262,0,0,1-.165.12.49.49,0,0,0-.19.12.829.829,0,0,0-.15.241.9.9,0,0,1-.3.331c-.22.036-.34.055-.369.065-.156.036-.323.314-.488.819a1.676,1.676,0,0,1-.38.644,1.076,1.076,0,0,1-.4.255h-.036a.907.907,0,0,1-.57-.044,1.234,1.234,0,0,1-.525-.443,1.823,1.823,0,0,1-.344.654,2.887,2.887,0,0,1-.813.595v.044a2.821,2.821,0,0,1,.07.43c.015.137.027.27.036.4a10.307,10.307,0,0,1-.017,2.2,8.492,8.492,0,0,1-.469,1.9,3.947,3.947,0,0,1-2.661,2.57,2.121,2.121,0,0,1-1.65-.239,2.387,2.387,0,0,1-.994-1.456,7.493,7.493,0,0,1,.156-3.815c.074-.295.139-.4.19-.314a.5.5,0,0,1,.065.285,5.977,5.977,0,0,0-.1,2.617,1.562,1.562,0,0,0,.867,1.057,2.248,2.248,0,0,0,1.446.055,4.094,4.094,0,0,0,1.851-1.059,3.912,3.912,0,0,0,.838-1.483,4.72,4.72,0,0,0-.129-2.893,3.633,3.633,0,0,0-.165-.443,4.311,4.311,0,0,1-.165-.443.439.439,0,0,1,.046-.4.393.393,0,0,1,.3-.165h0v-.01a2.454,2.454,0,0,0,.608-.12,1.3,1.3,0,0,0,.848-1.422c.029-.22.084-.331.165-.35s.156.084.211.285a1.357,1.357,0,0,0,.091.2,1.225,1.225,0,0,0,.129.175.686.686,0,0,0,.156.129.363.363,0,0,0,.19.055,1.062,1.062,0,0,0,.711-.22,2.494,2.494,0,0,0,.57-.6,1.315,1.315,0,0,1,.369-.414,1,1,0,0,1,.2-.036,1.4,1.4,0,0,0,.211-.019.435.435,0,0,0,.285-.258,1.873,1.873,0,0,1,.139-.276.355.355,0,0,0,.268-.036.7.7,0,0,0,.19-.369.266.266,0,0,1,.074-.19,2.108,2.108,0,0,0,.23-.046l.084-.074h0v-.015c.359-.424.76-.967,1.216-1.621a4.291,4.291,0,0,0-.534-.1,2.011,2.011,0,0,0-.673,0c-.165.046-.331.22-.507.544q-.055.1-.11.084c-.036,0-.065-.046-.065-.11a.868.868,0,0,1,.046-.295,5.987,5.987,0,0,1,.22-.608.671.671,0,0,1,.57-.433c.046,0,.331,0,.857.019a2.357,2.357,0,0,0,.589-.046,1.04,1.04,0,0,0,.314-.165c.065-.055.165-.148.3-.285.055-.019.084,0,.091.046a.45.45,0,0,1,.011.165Zm1.834,8.439a2.864,2.864,0,0,1-.515-1.14l-.015-.048h0c-.1-.414-.2-1-.314-1.77s-.19-1.621-.268-2.56c.019-.285.074-.793.156-1.521,0-.074.036-.1.065-.1a.078.078,0,0,1,.065.065c.074.331.165.967.285,1.916s.211,1.595.268,1.944.1.534.11.57a1.016,1.016,0,0,1,.017.1l.038.148a1.143,1.143,0,0,0,.6.848.57.57,0,0,0,.46-.019.515.515,0,0,0,.276-.314,2.266,2.266,0,0,0,.091-.46c0-.175.046-.268.091-.276s.091.036.11.156a4.832,4.832,0,0,1,.065.977,3.193,3.193,0,0,1-.19,1.178.933.933,0,0,1-.6.618.777.777,0,0,1-.794-.316Zm3.132,1.935c-.12.323-.23.5-.314.515s-.19-.036-.38-.148a4.392,4.392,0,0,0-.5-.258c-.139.654-.295,1-.46,1.042a.35.35,0,0,1-.2-.055c-.091-.046-.23-.11-.424-.19s-.3-.139-.314-.175a2.779,2.779,0,0,1,.148-.783,2.42,2.42,0,0,1,.239-.737c.084-.055.23-.029.443.091a2.98,2.98,0,0,1,.507.34c.065-.2.129-.424.2-.673s.129-.38.175-.4a.771.771,0,0,1,.553.165.977.977,0,0,1,.433.469,1.294,1.294,0,0,1-.11.791Z" transform="translate(-13.809)" fill="#fff"/> |
||||
|
</g> |
||||
|
</svg> |
||||
@ -0,0 +1,7 @@ |
|||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="38.72" height="40.277" viewBox="0 0 38.72 40.277"> |
||||
|
<g id="par-04" transform="translate(-63.28 -49.67)"> |
||||
|
<path id="Path_1621" data-name="Path 1621" d="M67.1,90.615h0a.855.855,0,0,1,1.047.6l8.35,30.79a.855.855,0,0,1-.6,1.047h0a.855.855,0,0,1-1.047-.6L66.5,91.662A.855.855,0,0,1,67.1,90.615Z" transform="translate(-2.58 -33.139)" fill="#fff"/> |
||||
|
<circle id="Ellipse_266" data-name="Ellipse 266" cx="1.31" cy="1.31" r="1.31" transform="translate(63.28 56.429)" fill="#fff"/> |
||||
|
<path id="Path_1622" data-name="Path 1622" d="M110.17,49.67s-3.357,6.4-15.648,4.624S80.33,58.983,80.33,58.983l5.449,20.652s3.041-6.97,15.332-4.814,14.7-4.434,14.7-4.434ZM91.063,65.955c.046-.156.12-.4.211-.709.12.055.258.12.433.19a2.281,2.281,0,0,1,.4.22.95.95,0,0,1,.23.3.739.739,0,0,1,.065.359,2.026,2.026,0,0,1-.074.479,1.653,1.653,0,0,1-.211.515.464.464,0,0,1-.19.19.6.6,0,0,1-.258-.084c-.139-.065-.268-.12-.38-.175s-.211-.1-.295-.148-.129-.091-.139-.139C90.962,66.458,91.017,66.111,91.063,65.955Zm7.711,3.231c-.12.323-.23.5-.314.515s-.19-.036-.38-.148a4.39,4.39,0,0,0-.5-.258c-.139.654-.295,1-.46,1.042a.35.35,0,0,1-.2-.055c-.091-.046-.23-.11-.424-.19s-.295-.148-.295-.175a2.223,2.223,0,0,1,.129-.783,2.42,2.42,0,0,1,.239-.737c.084-.055.23-.029.443.091a2.979,2.979,0,0,1,.507.34c.065-.2.129-.424.2-.673s.129-.38.175-.4a.771.771,0,0,1,.553.165.977.977,0,0,1,.433.469,1.294,1.294,0,0,1-.11.794Zm3.463-12.544a2.392,2.392,0,0,1-.036.534.694.694,0,0,1-.148.34c-.029.029-.074.074-.129.12a2.486,2.486,0,0,0-.239.285q-.165.207-.608.857c-.295.433-.627.884-.986,1.355l-.015-.019v.019l-.074.1a.475.475,0,0,1-.165.165c-.074.046-.12.084-.148.1a.852.852,0,0,0-.139.156c-.036.055-.091.12-.148.19a.262.262,0,0,1-.165.12.49.49,0,0,0-.19.12.829.829,0,0,0-.15.241.9.9,0,0,1-.3.331c-.22.036-.34.055-.369.065-.156.036-.323.314-.488.819a1.676,1.676,0,0,1-.38.644,1.076,1.076,0,0,1-.4.255h-.036a.907.907,0,0,1-.57-.044,1.234,1.234,0,0,1-.525-.443,1.823,1.823,0,0,1-.344.654,2.887,2.887,0,0,1-.813.595v.044a2.821,2.821,0,0,1,.07.43c.015.137.027.27.036.4a10.307,10.307,0,0,1-.017,2.2,8.492,8.492,0,0,1-.469,1.9,3.947,3.947,0,0,1-2.661,2.57,2.121,2.121,0,0,1-1.65-.239,2.387,2.387,0,0,1-.994-1.456,7.493,7.493,0,0,1,.156-3.815c.074-.295.139-.4.19-.314a.5.5,0,0,1,.065.285,5.977,5.977,0,0,0-.1,2.617,1.562,1.562,0,0,0,.867,1.057,2.248,2.248,0,0,0,1.446.055,4.094,4.094,0,0,0,1.851-1.059,3.912,3.912,0,0,0,.838-1.483,4.72,4.72,0,0,0-.129-2.893,3.633,3.633,0,0,0-.165-.443,4.311,4.311,0,0,1-.165-.443.439.439,0,0,1,.046-.4.393.393,0,0,1,.3-.165h0v-.01a2.454,2.454,0,0,0,.608-.12,1.3,1.3,0,0,0,.848-1.422c.029-.22.084-.331.165-.35s.156.084.211.285a1.357,1.357,0,0,0,.091.2,1.225,1.225,0,0,0,.129.175.686.686,0,0,0,.156.129.363.363,0,0,0,.19.055,1.062,1.062,0,0,0,.711-.22,2.494,2.494,0,0,0,.57-.6,1.315,1.315,0,0,1,.369-.414,1,1,0,0,1,.2-.036,1.4,1.4,0,0,0,.211-.019.435.435,0,0,0,.285-.258,1.873,1.873,0,0,1,.139-.276.355.355,0,0,0,.268-.036.7.7,0,0,0,.19-.369.266.266,0,0,1,.074-.19,2.108,2.108,0,0,0,.23-.046l.084-.074h0v-.015c.359-.424.76-.967,1.216-1.621a4.291,4.291,0,0,0-.534-.1,2.011,2.011,0,0,0-.673,0c-.165.046-.331.22-.507.544q-.055.1-.11.084c-.036,0-.065-.046-.065-.11a.868.868,0,0,1,.046-.295,5.987,5.987,0,0,1,.22-.608.671.671,0,0,1,.57-.433c.046,0,.331,0,.857.019a2.357,2.357,0,0,0,.589-.046,1.04,1.04,0,0,0,.314-.165c.065-.055.165-.148.3-.285.055-.019.084,0,.091.046a.45.45,0,0,1,.011.165Zm1.834,8.439a2.864,2.864,0,0,1-.515-1.14l-.015-.048h0c-.1-.414-.2-1-.314-1.77s-.19-1.621-.268-2.56c.019-.285.074-.793.156-1.521,0-.074.036-.1.065-.1a.078.078,0,0,1,.065.065c.074.331.165.967.285,1.916s.211,1.595.268,1.944.1.534.11.57a1.016,1.016,0,0,1,.017.1l.038.148a1.143,1.143,0,0,0,.6.848.57.57,0,0,0,.46-.019.515.515,0,0,0,.276-.314,2.266,2.266,0,0,0,.091-.46c0-.175.046-.268.091-.276s.091.036.11.156a4.832,4.832,0,0,1,.065.977,3.193,3.193,0,0,1-.19,1.178.933.933,0,0,1-.6.618.777.777,0,0,1-.794-.316Zm3.132,1.935c-.12.323-.23.5-.314.515s-.19-.036-.38-.148a4.392,4.392,0,0,0-.5-.258c-.139.654-.295,1-.46,1.042a.35.35,0,0,1-.2-.055c-.091-.046-.23-.11-.424-.19s-.3-.139-.314-.175a2.779,2.779,0,0,1,.148-.783,2.42,2.42,0,0,1,.239-.737c.084-.055.23-.029.443.091a2.98,2.98,0,0,1,.507.34c.065-.2.129-.424.2-.673s.129-.38.175-.4a.771.771,0,0,1,.553.165.977.977,0,0,1,.433.469,1.294,1.294,0,0,1-.11.791Z" transform="translate(-13.809)" fill="#fff"/> |
||||
|
</g> |
||||
|
</svg> |
||||
@ -0,0 +1,24 @@ |
|||||
|
<svg xmlns="http://www.w3.org/2000/svg"> |
||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17"> |
||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g> |
||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs> |
||||
|
</symbol> |
||||
|
<symbol id="discord-icon" viewBox="0 0 20 19"> |
||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/> |
||||
|
</symbol> |
||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20"> |
||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/> |
||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/> |
||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/> |
||||
|
</symbol> |
||||
|
<symbol id="github-icon" viewBox="0 0 19 19"> |
||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/> |
||||
|
</symbol> |
||||
|
<symbol id="social-icon" viewBox="0 0 20 20"> |
||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/> |
||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/> |
||||
|
</symbol> |
||||
|
<symbol id="x-icon" viewBox="0 0 19 19"> |
||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/> |
||||
|
</symbol> |
||||
|
</svg> |
||||
@ -0,0 +1,2 @@ |
|||||
|
|
||||
|
git pull origin master && docker compose up -d --build |
||||
@ -0,0 +1,57 @@ |
|||||
|
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar' |
||||
|
import AppSidebar from '@/components/layout/AppSidebar' |
||||
|
import Topbar from '@/components/layout/Topbar' |
||||
|
import PanelSlot from '@/components/layout/PanelSlot' |
||||
|
import GlobalSearchDialog from '@/features/global-search/GlobalSearchDialog' |
||||
|
import AppRouter from '@/app/router' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
import { useAuth } from '@/features/auth/use-auth' |
||||
|
|
||||
|
/* |
||||
|
* شل اصلی داشبورد (دولایه و راستچین RTL): |
||||
|
* |
||||
|
* AppSidebar (راست) │ SidebarInset (Topbar + main) │ PanelSlot (چپ) |
||||
|
* |
||||
|
* ستون راست: نویگیشن و منوها |
||||
|
* وسط: هدر بالا و صفحات متغیر |
||||
|
* ستون چپ: پنل کشویی بازشونده جهت نمایش جزئیات هر آیتم بدون ترک صفحه |
||||
|
*/ |
||||
|
export default function App() { |
||||
|
const { isAuthenticated } = useAuth() |
||||
|
|
||||
|
if (!isAuthenticated) { |
||||
|
return <AppRouter /> |
||||
|
} |
||||
|
|
||||
|
return <AuthenticatedShell /> |
||||
|
} |
||||
|
|
||||
|
function AuthenticatedShell() { |
||||
|
const { isPanelOpen } = usePanelRouting() |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
<SidebarProvider className="min-h-0"> |
||||
|
<AppSidebar /> |
||||
|
|
||||
|
<SidebarInset className="min-w-0"> |
||||
|
<Topbar /> |
||||
|
<main |
||||
|
className={cn( |
||||
|
'min-w-0 flex-1 overflow-y-auto px-4 pb-10 md:px-8', |
||||
|
isPanelOpen && 'no-scrollbar', |
||||
|
)} |
||||
|
> |
||||
|
<AppRouter /> |
||||
|
</main> |
||||
|
</SidebarInset> |
||||
|
|
||||
|
<PanelSlot /> |
||||
|
</SidebarProvider> |
||||
|
|
||||
|
{/* ابزار جستجوی سراسری (Ctrl+K) */} |
||||
|
<GlobalSearchDialog /> |
||||
|
</> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { useEffect, useRef } from 'react' |
||||
|
import { useLocation } from 'react-router-dom' |
||||
|
import NProgress from 'nprogress' |
||||
|
|
||||
|
/* R3 — نوار پیشرفت روتینگ: گرادیان حسینیه، ۳px، بدون spinner (استایل در index.css) */ |
||||
|
NProgress.configure({ showSpinner: false, trickleSpeed: 120, minimum: 0.12 }) |
||||
|
|
||||
|
/** با هر تغییر pathname نوار شروع میشود (بهجز اولین رندر). */ |
||||
|
export function RouteProgressStart() { |
||||
|
const { pathname } = useLocation() |
||||
|
const first = useRef(true) |
||||
|
useEffect(() => { |
||||
|
if (first.current) { |
||||
|
first.current = false |
||||
|
return |
||||
|
} |
||||
|
NProgress.start() |
||||
|
}, [pathname]) |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
/** بعد از mount صفحهٔ lazy، نوار تمام میشود. */ |
||||
|
export function PageReady({ children }: { children: React.ReactNode }) { |
||||
|
const { pathname } = useLocation() |
||||
|
useEffect(() => { |
||||
|
NProgress.done() |
||||
|
}, [pathname]) |
||||
|
return children |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { QueryClientProvider } from '@tanstack/react-query' |
||||
|
import { BrowserRouter } from 'react-router-dom' |
||||
|
import { DirectionProvider } from '@radix-ui/react-direction' |
||||
|
import { queryClient } from '@/lib/query-client' |
||||
|
|
||||
|
import { TooltipProvider } from '@/components/ui/tooltip' |
||||
|
|
||||
|
/* پروایدرهای سراسری: React Query + RTL + Tooltips + Router */ |
||||
|
export default function Providers({ children }: { children: React.ReactNode }) { |
||||
|
return ( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<DirectionProvider dir="rtl"> |
||||
|
<TooltipProvider delayDuration={200}> |
||||
|
<BrowserRouter>{children}</BrowserRouter> |
||||
|
</TooltipProvider> |
||||
|
</DirectionProvider> |
||||
|
</QueryClientProvider> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
import { lazy, Suspense } from 'react' |
||||
|
import { Routes, Route, Navigate, Outlet } from 'react-router-dom' |
||||
|
import { RouteProgressStart, PageReady } from './nprogress' |
||||
|
import RequireAuth from '@/features/auth/RequireAuth' |
||||
|
|
||||
|
/* لود تنبل (Lazy Loading) صفحات */ |
||||
|
const LoginPage = lazy(() => import('@/features/auth/LoginPage')) |
||||
|
const DashboardHome = lazy(() => import('@/features/dashboard/DashboardHome')) |
||||
|
const UsersPage = lazy(() => import('@/features/users/UsersPage')) |
||||
|
const ItemsPage = lazy(() => import('@/features/items/ItemsPage')) |
||||
|
const SettingsPage = lazy(() => import('@/features/settings/SettingsPage')) |
||||
|
const ComponentsShowcase = lazy(() => import('@/features/showcase/ComponentsShowcase')) |
||||
|
|
||||
|
export default function AppRouter() { |
||||
|
return ( |
||||
|
<> |
||||
|
<RouteProgressStart /> |
||||
|
<Suspense fallback={null}> |
||||
|
<Routes> |
||||
|
{/* مسیر عمومی ورود به سیستم */} |
||||
|
<Route path="/login" element={<PageReady><LoginPage /></PageReady>} /> |
||||
|
|
||||
|
{/* تمام مسیرهای محافظتشده پشت گارد امنیتی */} |
||||
|
<Route element={<RequireAuth><Outlet /></RequireAuth>}> |
||||
|
<Route path="/" element={<PageReady><DashboardHome /></PageReady>} /> |
||||
|
<Route path="/users" element={<PageReady><UsersPage /></PageReady>} /> |
||||
|
<Route path="/items" element={<PageReady><ItemsPage /></PageReady>} /> |
||||
|
<Route path="/components" element={<PageReady><ComponentsShowcase /></PageReady>} /> |
||||
|
<Route path="/settings" element={<PageReady><SettingsPage /></PageReady>} /> |
||||
|
|
||||
|
{/* مسیرهای نامعتبر → هدایت به صفحه اصلی */} |
||||
|
<Route path="*" element={<Navigate to="/" replace />} /> |
||||
|
</Route> |
||||
|
</Routes> |
||||
|
</Suspense> |
||||
|
</> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,147 @@ |
|||||
|
import { NavLink, useLocation } from 'react-router-dom' |
||||
|
import { Ic, type IconName } from '@/icons' |
||||
|
import { |
||||
|
Sidebar, |
||||
|
SidebarHeader, |
||||
|
SidebarContent, |
||||
|
SidebarFooter, |
||||
|
SidebarSeparator, |
||||
|
SidebarGroup, |
||||
|
SidebarGroupLabel, |
||||
|
SidebarMenu, |
||||
|
SidebarMenuItem, |
||||
|
SidebarMenuButton, |
||||
|
SidebarMenuBadge, |
||||
|
} from '@/components/ui/sidebar' |
||||
|
import { useAuth, useLogout } from '@/features/auth/use-auth' |
||||
|
import { navigationConfig } from '@/config/navigation' |
||||
|
import { siteConfig } from '@/config/site' |
||||
|
|
||||
|
type NavItemProps = { |
||||
|
to?: string |
||||
|
icon: IconName |
||||
|
label: string |
||||
|
badge?: string |
||||
|
soon?: boolean |
||||
|
} |
||||
|
|
||||
|
function Tail({ badge, soon }: Pick<NavItemProps, 'badge' | 'soon'>) { |
||||
|
if (soon) { |
||||
|
return ( |
||||
|
<span className="ms-auto rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-grey-3"> |
||||
|
بهزودی |
||||
|
</span> |
||||
|
) |
||||
|
} |
||||
|
if (badge) return <SidebarMenuBadge>{badge}</SidebarMenuBadge> |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
function NavItem({ to, icon, label, badge, soon }: NavItemProps) { |
||||
|
const { pathname } = useLocation() |
||||
|
const isActive = to ? (to === '/' ? pathname === '/' : pathname.startsWith(to)) : false |
||||
|
const inner = ( |
||||
|
<> |
||||
|
<Ic name={icon} /> |
||||
|
<span className="truncate">{label}</span> |
||||
|
<Tail badge={badge} soon={soon} /> |
||||
|
</> |
||||
|
) |
||||
|
|
||||
|
return ( |
||||
|
<SidebarMenuItem> |
||||
|
{to ? ( |
||||
|
<SidebarMenuButton asChild isActive={isActive} tooltip={label}> |
||||
|
<NavLink to={to}>{inner}</NavLink> |
||||
|
</SidebarMenuButton> |
||||
|
) : ( |
||||
|
<SidebarMenuButton tooltip={label} className={soon ? 'opacity-55' : undefined}> |
||||
|
{inner} |
||||
|
</SidebarMenuButton> |
||||
|
)} |
||||
|
</SidebarMenuItem> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function SidebarUserFooter() { |
||||
|
const { user } = useAuth() |
||||
|
const logout = useLogout() |
||||
|
|
||||
|
const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'م' |
||||
|
|
||||
|
return ( |
||||
|
<SidebarFooter> |
||||
|
<SidebarSeparator className="mb-1" /> |
||||
|
<div className="flex items-center gap-2.5 px-2 py-1"> |
||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-bold text-white"> |
||||
|
{firstLetter} |
||||
|
</div> |
||||
|
<div className="min-w-0 flex-1"> |
||||
|
<div className="truncate text-xs font-semibold text-grey-1"> |
||||
|
{user?.name?.trim() || 'مدیر سیستم'} |
||||
|
</div> |
||||
|
<div dir="ltr" className="truncate text-start text-[10px] text-grey-3"> |
||||
|
{user?.email ?? 'admin@example.com'} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<SidebarMenu> |
||||
|
<SidebarMenuItem> |
||||
|
<SidebarMenuButton |
||||
|
tooltip="خروج" |
||||
|
onClick={logout} |
||||
|
className="text-grey-2 hover:bg-rose-500/10 hover:text-rose-400" |
||||
|
> |
||||
|
<Ic name="logout" /> |
||||
|
<span className="truncate">خروج از سیستم</span> |
||||
|
</SidebarMenuButton> |
||||
|
</SidebarMenuItem> |
||||
|
</SidebarMenu> |
||||
|
</SidebarFooter> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default function AppSidebar() { |
||||
|
return ( |
||||
|
<Sidebar side="right" collapsible="offcanvas"> |
||||
|
<SidebarHeader className="pt-[6px] pb-[6px]"> |
||||
|
<div className="flex items-center gap-3 px-2 py-1.5"> |
||||
|
<div className="flex size-[40px] items-center justify-center rounded-xl bg-primary text-white shadow-md"> |
||||
|
<Ic name="sparkles" className="size-6" /> |
||||
|
</div> |
||||
|
<div className="min-w-0"> |
||||
|
<div className="truncate text-sm font-extrabold leading-tight text-foreground"> |
||||
|
{siteConfig.name} |
||||
|
</div> |
||||
|
<div className="truncate text-[10px] text-grey-3 mt-0.5"> |
||||
|
{siteConfig.description} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</SidebarHeader> |
||||
|
<SidebarSeparator /> |
||||
|
|
||||
|
<SidebarContent className="no-scrollbar px-1"> |
||||
|
{navigationConfig.map((group) => ( |
||||
|
<SidebarGroup key={group.title}> |
||||
|
<SidebarGroupLabel>{group.title}</SidebarGroupLabel> |
||||
|
<SidebarMenu> |
||||
|
{group.items.map((item) => ( |
||||
|
<NavItem |
||||
|
key={item.title} |
||||
|
to={item.to} |
||||
|
icon={item.icon} |
||||
|
label={item.title} |
||||
|
badge={item.badge} |
||||
|
soon={item.soon} |
||||
|
/> |
||||
|
))} |
||||
|
</SidebarMenu> |
||||
|
</SidebarGroup> |
||||
|
))} |
||||
|
</SidebarContent> |
||||
|
|
||||
|
<SidebarUserFooter /> |
||||
|
</Sidebar> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,129 @@ |
|||||
|
import { useEffect, useState, type CSSProperties } from 'react' |
||||
|
import { useIsPanelOverlay } from '@/hooks/use-mobile' |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
import { useGlobalSearchStore } from '@/stores/global-search' |
||||
|
import { |
||||
|
Sheet, |
||||
|
SheetContent, |
||||
|
SheetHeader, |
||||
|
SheetTitle, |
||||
|
SheetDescription, |
||||
|
} from '@/components/ui/sheet' |
||||
|
import { Sidebar, SidebarContext, useSidebar } from '@/components/ui/sidebar' |
||||
|
import UserDetailPanel from '@/features/panels/UserDetailPanel' |
||||
|
import SampleDetailPanel from '@/features/panels/SampleDetailPanel' |
||||
|
import FilterDrawer from '@/features/filters/FilterDrawer' |
||||
|
|
||||
|
/* |
||||
|
* شل سمت چپ (RTL): پنل کشویی جزئیات رکوردها / دراور فیلترها (قرینهٔ AppSidebar). |
||||
|
* |
||||
|
* دسکتاپ ≥1280px → Sidebar تمامارتفاع offcanvas از چپ اسلاید میکند بدون اینکه محتوای وسط فشرده شود. |
||||
|
* زیر 1280px → Sheet استاندارد به صورت Modal Drawer از چپ باز میشود. |
||||
|
*/ |
||||
|
export default function PanelSlot() { |
||||
|
const isOverlay = useIsPanelOverlay() |
||||
|
const sidebar = useSidebar() |
||||
|
const { activePanel, drawerParam, closePanel, closeDrawer, isPanelOpen } = usePanelRouting() |
||||
|
|
||||
|
const onClose = drawerParam ? closeDrawer : closePanel |
||||
|
|
||||
|
const [activeState, setActiveState] = useState<{ type: string; id?: string } | null>(activePanel) |
||||
|
useEffect(() => { |
||||
|
if (activePanel) { |
||||
|
const frame = requestAnimationFrame(() => setActiveState(activePanel)) |
||||
|
return () => cancelAnimationFrame(frame) |
||||
|
} |
||||
|
}, [activePanel]) |
||||
|
|
||||
|
/* رندر پنل مورد نظر بر اساس نوع موجود در URL */ |
||||
|
const renderPanel = (type: string, id?: string) => { |
||||
|
switch (type) { |
||||
|
case 'user': |
||||
|
return <UserDetailPanel id={id} onClose={closePanel} /> |
||||
|
case 'item': |
||||
|
return <SampleDetailPanel id={id} onClose={closePanel} /> |
||||
|
case 'filters': |
||||
|
return <FilterDrawer onClose={closeDrawer} /> |
||||
|
default: |
||||
|
return ( |
||||
|
<div className="flex h-full flex-col p-5"> |
||||
|
<div className="flex items-center justify-between border-b pb-3"> |
||||
|
<span className="font-bold">پنل جزئیات</span> |
||||
|
<button onClick={closePanel} className="text-sm text-grey-3">بستن</button> |
||||
|
</div> |
||||
|
<div className="flex flex-1 items-center justify-center text-sm text-grey-3"> |
||||
|
پنل ناشناخته ({type}) |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const [mounted, setMounted] = useState(isPanelOpen) |
||||
|
useEffect(() => { |
||||
|
if (isPanelOpen) { |
||||
|
const frame = requestAnimationFrame(() => setMounted(true)) |
||||
|
return () => cancelAnimationFrame(frame) |
||||
|
} |
||||
|
const t = setTimeout(() => setMounted(false), 250) |
||||
|
return () => clearTimeout(t) |
||||
|
}, [isPanelOpen]) |
||||
|
|
||||
|
// بستن با کلید Escape
|
||||
|
useEffect(() => { |
||||
|
if (!isPanelOpen) return |
||||
|
const handleEsc = (e: KeyboardEvent) => { |
||||
|
if (e.key !== 'Escape') return |
||||
|
if (useGlobalSearchStore.getState().open) return |
||||
|
onClose() |
||||
|
} |
||||
|
window.addEventListener('keydown', handleEsc, true) |
||||
|
return () => window.removeEventListener('keydown', handleEsc, true) |
||||
|
}, [isPanelOpen, onClose]) |
||||
|
|
||||
|
const shouldRender = isPanelOpen || mounted |
||||
|
const current = isPanelOpen ? (activePanel || (drawerParam ? { type: 'filters' } : null)) : activeState |
||||
|
const content = shouldRender && current ? renderPanel(current.type, current.id) : null |
||||
|
|
||||
|
/* ── در ابعاد کوچک (<1280px): نمایش به صورت Sheet کشویی ── */ |
||||
|
if (isOverlay) { |
||||
|
if (!shouldRender) return null |
||||
|
return ( |
||||
|
<Sheet open={isPanelOpen} onOpenChange={(o) => !o && onClose()}> |
||||
|
<SheetContent |
||||
|
side="left" |
||||
|
className="w-[min(88vw,var(--panel-width))] max-w-none border-border-soft bg-[#26272c] p-0 [&>button]:hidden" |
||||
|
> |
||||
|
<SheetHeader className="sr-only"> |
||||
|
<SheetTitle>پنل جزئیات</SheetTitle> |
||||
|
<SheetDescription>مشاهده اطلاعات تکمیلی</SheetDescription> |
||||
|
</SheetHeader> |
||||
|
<div className="flex h-full w-full flex-col"> |
||||
|
{content} |
||||
|
</div> |
||||
|
</SheetContent> |
||||
|
</Sheet> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/* ── دسکتاپ (≥1280px): استفاده از شل کناری مستقل Sidebar ── */ |
||||
|
const panelContext = { |
||||
|
...sidebar, |
||||
|
isMobile: false, |
||||
|
open: isPanelOpen, |
||||
|
state: isPanelOpen ? 'expanded' : 'collapsed', |
||||
|
} as typeof sidebar |
||||
|
|
||||
|
return ( |
||||
|
<SidebarContext.Provider value={panelContext}> |
||||
|
<div |
||||
|
className="contents" |
||||
|
style={{ '--sidebar-width': 'var(--panel-width)', '--sidebar': '#26272c' } as CSSProperties} |
||||
|
> |
||||
|
<Sidebar side="left" collapsible="offcanvas"> |
||||
|
{content} |
||||
|
</Sidebar> |
||||
|
</div> |
||||
|
</SidebarContext.Provider> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,39 @@ |
|||||
|
import { useLocation } from 'react-router-dom' |
||||
|
import { SidebarTrigger } from '@/components/ui/sidebar' |
||||
|
import GlobalSearchTrigger from '@/features/global-search/GlobalSearchTrigger' |
||||
|
import { navigationConfig } from '@/config/navigation' |
||||
|
import { siteConfig } from '@/config/site' |
||||
|
|
||||
|
export default function Topbar() { |
||||
|
const { pathname } = useLocation() |
||||
|
|
||||
|
// پیدا کردن عنوان صفحه بر اساس مسیر از کانفیگ
|
||||
|
let currentTitle = siteConfig.name |
||||
|
for (const group of navigationConfig) { |
||||
|
const found = group.items.find((it) => it.to === pathname) |
||||
|
if (found) { |
||||
|
currentTitle = found.title |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
if (pathname === '/') currentTitle = 'داشبورد مدیریت' |
||||
|
|
||||
|
return ( |
||||
|
<header className="flex h-16 shrink-0 items-center gap-3 border-b border-border-soft bg-background px-4 md:px-8"> |
||||
|
<SidebarTrigger className="-ms-1 shrink-0" /> |
||||
|
|
||||
|
{/* عنوان و مسیر */} |
||||
|
<div className="min-w-0 flex-1"> |
||||
|
<p className="truncate text-[11px] text-grey-3"> |
||||
|
{siteConfig.shortName} / {currentTitle} |
||||
|
</p> |
||||
|
<h1 className="truncate text-base font-extrabold text-foreground md:text-lg"> |
||||
|
{currentTitle} |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
{/* ابزار جستجوی سراسری (Cmd+K) */} |
||||
|
<GlobalSearchTrigger /> |
||||
|
</header> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,163 @@ |
|||||
|
/* setStateِ همگام درونِ effect اینجا عمدی است: state دو لایه (top/bottom/loaded) |
||||
|
باید با تغییرِ propِ src هماهنگ شود تا crossfade راه بیفتد — همان الگوی |
||||
|
«تنظیمِ state هنگام تغییرِ prop». پس این قاعده برای کلِ فایل غیرفعال است. */ |
||||
|
/* eslint-disable react-hooks/set-state-in-effect */ |
||||
|
import { useEffect, useRef, useState } from 'react' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
/* AppImage — معادلِ وبیِ CachedNetworkImage فلاتر. |
||||
|
* |
||||
|
* مشکلی که حل میکند: <img> خام در وب تصویر را بهصورتِ progressive (خطبهخط از |
||||
|
* بالا به پایین، همانطور که بایتها میرسند) رندر میکند. فلاتر با CachedNetworkImage |
||||
|
* تا دیکودِ کاملِ تصویر چیزی نشان نمیدهد و سپس آن را یکجا با fade-in نرم نمایش میدهد. |
||||
|
* |
||||
|
* استراتژیِ دو لایه (Two-Layer Crossfade) — چرا و چطور: |
||||
|
* در upgrade (placeholderِ کمکیفیتِ کششده → نسخهٔ پرکیفیتِ hero) اگر src همان |
||||
|
* المانِ زندهای را که الان opacity-100 است عوض کنیم، مرورگر (بهویژه در WebView و |
||||
|
* تصاویرِ cross-origin) ممکن است دوباره fetch کند و بایتهای جدید را خطبهخط روی |
||||
|
* تصویرِ فعلی نقاشی کند (افکتِ «کرکرهای»). decode()ِ خارج از DOM هم این را قطعی |
||||
|
* حل نمیکند. پس بهجایش دو لایه میسازیم: |
||||
|
* - لایهٔ پایین (bottom): آخرین تصویرِ کاملاً لودشده؛ روی صفحه میماند تا سیاهی/پرش |
||||
|
* دیده نشود. |
||||
|
* - لایهٔ بالا (top): تصویرِ جدید روی یک تگِ تازه با opacity-0 (نامرئی) دانلود |
||||
|
* میشود؛ خودِ DOM نامحسوس fetch میکند. بهمحضِ onLoad (لودِ کامل) با |
||||
|
* transition-opacity روی لایهٔ پایین fade-in میشود و سپس لایهٔ پایین پاک میگردد. |
||||
|
* چون لایهٔ بالا تا لودِ کامل نامرئی است، هیچوقت رندرِ ناقص دیده نمیشود. |
||||
|
* |
||||
|
* - یک لایهٔ placeholder/شیمر فقط وقتی هیچ تصویری برای نمایش نیست (بارِ اول). |
||||
|
* - onError: در بارِ اول fallback نشان داده میشود؛ در upgrade به تصویرِ قبلی |
||||
|
* برمیگردیم (هیچوقت downgrade یا سیاهی). |
||||
|
* |
||||
|
* نکتهٔ استفاده: ریشهٔ این کامپوننت `absolute inset-0` است؛ پس والدش باید `relative` |
||||
|
* و دارای ابعادِ مشخص باشد (مثلِ کارتهای خانه: Avatar / EventCard / SongCard). */ |
||||
|
type AppImageProps = { |
||||
|
src?: string | null |
||||
|
alt?: string |
||||
|
className?: string |
||||
|
/** کلاسِ لایهٔ placeholder که تا آمادهشدنِ تصویر دیده میشود (پیشفرض: پسزمینهٔ ملایم). */ |
||||
|
placeholderClassName?: string |
||||
|
/** محتوای fallback هنگام خطا یا نبودِ src (مثلِ Pattern/گرادیان). اگر ندهیم placeholder میماند. */ |
||||
|
fallback?: React.ReactNode |
||||
|
/** برای تصاویرِ above-the-fold (hero): بارگذاریِ زودهنگام و پراولویت بهجای lazy. */ |
||||
|
eager?: boolean |
||||
|
onError?: () => void |
||||
|
} |
||||
|
|
||||
|
export function AppImage({ |
||||
|
src, |
||||
|
alt = '', |
||||
|
className, |
||||
|
placeholderClassName, |
||||
|
fallback, |
||||
|
eager = false, |
||||
|
onError, |
||||
|
}: AppImageProps) { |
||||
|
/* top = تصویرِ هدف (لایهٔ بالا، در حال یا پس از لود). bottom = آخرین تصویرِ |
||||
|
کاملاً لودشده که تا fade-inِ top روی صفحه نگه داشته میشود. */ |
||||
|
const [top, setTop] = useState<string | null>(src ?? null) |
||||
|
const [bottom, setBottom] = useState<string | null>(null) |
||||
|
const [loaded, setLoaded] = useState(false) // آیا لایهٔ بالا کامل لود شده؟
|
||||
|
const [failed, setFailed] = useState(false) |
||||
|
const topImgRef = useRef<HTMLImageElement>(null) |
||||
|
const topRef = useRef<string | null>(src ?? null) // برای تشخیصِ no-op بدونِ افزودنِ top به deps
|
||||
|
const shownRef = useRef<string | null>(null) // آخرین src که کاملاً لود و نمایش داده شد
|
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!src) { |
||||
|
topRef.current = null |
||||
|
setTop(null) |
||||
|
setBottom(null) |
||||
|
setLoaded(false) |
||||
|
setFailed(false) |
||||
|
shownRef.current = null |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
setFailed(false) |
||||
|
if (topRef.current === src) return // همان تصویرِ هدف؛ کاری لازم نیست
|
||||
|
|
||||
|
/* src عوض شد: تصویرِ فعلیِ روی صفحه (آخرین لودشده) را به لایهٔ پایین میبریم تا |
||||
|
حین دانلودِ نامرئیِ لایهٔ بالا دیده شود. سپس لایهٔ بالا را با تگِ تازه (تغییرِ |
||||
|
key) و opacity-0 میسازیم تا fetchِ جدید روی المانِ نامرئی انجام شود. */ |
||||
|
const underneath = shownRef.current && shownRef.current !== src ? shownRef.current : null |
||||
|
if (underneath) setBottom(underneath) |
||||
|
topRef.current = src |
||||
|
setTop(src) |
||||
|
setLoaded(false) |
||||
|
}, [src]) |
||||
|
|
||||
|
/* پوششِ تصاویری که از کش فوراً completeاند و ممکن است onLoadِ React برایشان فایر |
||||
|
نشود — پس از هر تغییرِ top، کاملی را مستقیم چک میکنیم تا crossfade شروع شود. */ |
||||
|
useEffect(() => { |
||||
|
if (!top) return |
||||
|
const el = topImgRef.current |
||||
|
if (el && el.src === top && el.complete && el.naturalWidth > 0) { |
||||
|
shownRef.current = top |
||||
|
setLoaded(true) |
||||
|
} |
||||
|
}, [top]) |
||||
|
|
||||
|
const handleTopError = () => { |
||||
|
if (bottom) { |
||||
|
// upgrade ناموفق: تصویرِ شکستهٔ بالا را دور میریزیم و روی همان تصویرِ قبلیِ
|
||||
|
// لایهٔ پایین میمانیم (بدونِ سیاهی، بدونِ downgradeِ آشکار به کاربر).
|
||||
|
topRef.current = bottom |
||||
|
setTop(bottom) |
||||
|
setBottom(null) |
||||
|
setLoaded(true) |
||||
|
} else { |
||||
|
setFailed(true) |
||||
|
setLoaded(false) |
||||
|
onError?.() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const showFallback = !src || failed |
||||
|
// شیمر فقط وقتی هیچ تصویری (نه بالا، نه پایین) برای نمایش نیست: یعنی بارِ اول.
|
||||
|
const showSkeleton = !showFallback && !bottom && !loaded |
||||
|
|
||||
|
return ( |
||||
|
<span className="absolute inset-0 block overflow-hidden"> |
||||
|
{showSkeleton && ( |
||||
|
<span className={cn('absolute inset-0 animate-pulse bg-white/[0.06]', placeholderClassName)} aria-hidden /> |
||||
|
)} |
||||
|
|
||||
|
{showFallback && fallback} |
||||
|
|
||||
|
{/* لایهٔ پایین — آخرین تصویرِ لودشده؛ از کش فوری رندر میشود (بدونِ کرکره). */} |
||||
|
{!showFallback && bottom && ( |
||||
|
<img |
||||
|
key={`bottom-${bottom}`} |
||||
|
src={bottom} |
||||
|
alt="" |
||||
|
aria-hidden |
||||
|
decoding="async" |
||||
|
className={cn('absolute inset-0 size-full object-cover', className)} |
||||
|
/> |
||||
|
)} |
||||
|
|
||||
|
{/* لایهٔ بالا — تصویرِ هدف؛ تا onLoad نامرئی (opacity-0) دانلود میشود و سپس |
||||
|
روی لایهٔ پایین fade-in میگردد. تغییرِ key با هر src یعنی تگِ کاملاً تازه، |
||||
|
پس fetchِ جدید هیچوقت روی یک المانِ visible دیده نمیشود. */} |
||||
|
{!showFallback && top && ( |
||||
|
<img |
||||
|
key={`top-${top}`} |
||||
|
ref={topImgRef} |
||||
|
src={top} |
||||
|
alt={alt} |
||||
|
loading={eager ? 'eager' : 'lazy'} |
||||
|
fetchPriority={eager ? 'high' : 'auto'} |
||||
|
decoding="async" |
||||
|
onLoad={() => { shownRef.current = top; setLoaded(true) }} |
||||
|
onError={handleTopError} |
||||
|
onTransitionEnd={(e) => { if (e.propertyName === 'opacity' && loaded) setBottom(null) }} |
||||
|
className={cn( |
||||
|
'absolute inset-0 size-full object-cover transition-opacity duration-[400ms] ease-out', |
||||
|
loaded ? 'opacity-100' : 'opacity-0', |
||||
|
className, |
||||
|
)} |
||||
|
/> |
||||
|
)} |
||||
|
</span> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
import { cn } from '@/lib/utils' |
||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||
|
|
||||
|
type CardsSkeletonProps = { |
||||
|
/** آواتار/کاور گرد باشد (پروایدرها) یا مربعِ گردگوشه (آلبوم/آهنگ) */ |
||||
|
round?: boolean |
||||
|
/** تعداد کارتهای skeleton (پیشفرض ۸) */ |
||||
|
count?: number |
||||
|
} |
||||
|
|
||||
|
/* اسکلتون کارتها برای حالت loading در نمای grid — همساختار با *GridCard */ |
||||
|
export default function CardsSkeleton({ round = false, count = 8 }: CardsSkeletonProps) { |
||||
|
return ( |
||||
|
<> |
||||
|
{Array.from({ length: count }).map((_, i) => ( |
||||
|
<div |
||||
|
key={i} |
||||
|
className="flex flex-col gap-3 rounded-xl border border-border-soft bg-card/55 p-4" |
||||
|
> |
||||
|
<div className="flex items-start gap-3"> |
||||
|
<Skeleton className={cn('size-14 shrink-0', round ? 'rounded-full' : 'rounded-xl')} /> |
||||
|
<div className="flex-1 space-y-2"> |
||||
|
<Skeleton className="h-3.5 w-32" /> |
||||
|
<Skeleton className="h-3 w-20" /> |
||||
|
<Skeleton className="h-5 w-16 rounded-full" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="flex gap-1.5"> |
||||
|
<Skeleton className="h-5 w-14 rounded-full" /> |
||||
|
<Skeleton className="h-5 w-10 rounded-full" /> |
||||
|
</div> |
||||
|
<div className="flex items-center justify-between border-t border-border-soft pt-3"> |
||||
|
<Skeleton className="h-4 w-12" /> |
||||
|
<div className="flex gap-1.5"> |
||||
|
<Skeleton className="size-8 rounded-md" /> |
||||
|
<Skeleton className="size-8 rounded-md" /> |
||||
|
<Skeleton className="size-8 rounded-md" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
import { |
||||
|
AlertDialog, |
||||
|
AlertDialogAction, |
||||
|
AlertDialogCancel, |
||||
|
AlertDialogContent, |
||||
|
AlertDialogDescription, |
||||
|
AlertDialogFooter, |
||||
|
AlertDialogHeader, |
||||
|
AlertDialogTitle, |
||||
|
} from '@/components/ui/alert-dialog' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
|
||||
|
interface DeleteConfirmDialogProps { |
||||
|
open: boolean |
||||
|
onOpenChange: (open: boolean) => void |
||||
|
title?: string |
||||
|
description?: string |
||||
|
confirmText?: string |
||||
|
cancelText?: string |
||||
|
isLoading?: boolean |
||||
|
onConfirm: () => void |
||||
|
} |
||||
|
|
||||
|
export default function DeleteConfirmDialog({ |
||||
|
open, |
||||
|
onOpenChange, |
||||
|
title = 'آیا از حذف این مورد اطمینان دارید؟', |
||||
|
description = 'این عملیات غیرقابل بازگشت است و رکورد مورد نظر به طور کامل حذف خواهد شد.', |
||||
|
confirmText = 'بله، حذف شود', |
||||
|
cancelText = 'انصراف', |
||||
|
isLoading = false, |
||||
|
onConfirm, |
||||
|
}: DeleteConfirmDialogProps) { |
||||
|
return ( |
||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}> |
||||
|
<AlertDialogContent className="sm:max-w-[425px]"> |
||||
|
<AlertDialogHeader className="text-start"> |
||||
|
<AlertDialogTitle className="text-base font-bold text-foreground"> |
||||
|
{title} |
||||
|
</AlertDialogTitle> |
||||
|
<AlertDialogDescription className="text-xs leading-relaxed text-grey-3"> |
||||
|
{description} |
||||
|
</AlertDialogDescription> |
||||
|
</AlertDialogHeader> |
||||
|
<AlertDialogFooter className="mt-4 gap-2 sm:justify-start"> |
||||
|
<Button |
||||
|
variant="destructive" |
||||
|
disabled={isLoading} |
||||
|
onClick={() => { |
||||
|
onConfirm() |
||||
|
}} |
||||
|
> |
||||
|
{isLoading ? 'در حال حذف...' : confirmText} |
||||
|
</Button> |
||||
|
<AlertDialogCancel asChild> |
||||
|
<Button variant="outline" disabled={isLoading}> |
||||
|
{cancelText} |
||||
|
</Button> |
||||
|
</AlertDialogCancel> |
||||
|
</AlertDialogFooter> |
||||
|
</AlertDialogContent> |
||||
|
</AlertDialog> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { Ic, type IconName } from '@/icons' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
type IconButtonProps = React.ComponentProps<'button'> & { icon?: IconName } |
||||
|
|
||||
|
/* دکمهٔ گرد آیکونی با حاشیهٔ نرم — اکشنهای دایرهای Topbar و … */ |
||||
|
export function IconButton({ icon, className, children, ...props }: IconButtonProps) { |
||||
|
return ( |
||||
|
<button |
||||
|
className={cn( |
||||
|
'relative flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border-soft bg-white/5 text-grey-2 transition-colors hover:bg-white/8 [&_svg]:size-[17px]', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
{icon && <Ic name={icon} />} |
||||
|
{children} |
||||
|
</button> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
|
||||
|
type PanelHeaderProps = { |
||||
|
title: React.ReactNode |
||||
|
action?: React.ReactNode |
||||
|
onShare?: () => void |
||||
|
onClose?: () => void |
||||
|
className?: string |
||||
|
} |
||||
|
|
||||
|
/* هدر مشترک ستون چپ (پنل جزئیات / دراور فیلتر): عنوان + اکشنها + بستن */ |
||||
|
export function PanelHeader({ title, action, onShare, onClose, className }: PanelHeaderProps) { |
||||
|
return ( |
||||
|
<div className={cn('flex items-center gap-2 px-4 py-3', className)}> |
||||
|
<div className="min-w-0 flex-1">{title}</div> |
||||
|
{action} |
||||
|
{onShare && ( |
||||
|
<Button variant="ghost" size="icon-sm" onClick={onShare} className="rounded-full bg-white/7 [&_svg]:size-3.5"> |
||||
|
<Ic name="share" /> |
||||
|
</Button> |
||||
|
)} |
||||
|
{onClose && ( |
||||
|
<Button variant="ghost" size="icon-sm" onClick={onClose} className="rounded-full bg-white/7 [&_svg]:size-3.5"> |
||||
|
<Ic name="x" /> |
||||
|
</Button> |
||||
|
)} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default PanelHeader |
||||
@ -0,0 +1,21 @@ |
|||||
|
type SectionHeaderProps = { |
||||
|
title: string |
||||
|
hint?: string |
||||
|
onSeeAll?: () => void |
||||
|
} |
||||
|
|
||||
|
/* تیتر سکشن: عنوان + توضیح + «مشاهده همه» */ |
||||
|
export function SectionHeader({ title, hint, onSeeAll }: SectionHeaderProps) { |
||||
|
return ( |
||||
|
<div className="mb-4 mt-8 flex items-baseline gap-3"> |
||||
|
<h2 className="text-[16.5px] font-extrabold">{title}</h2> |
||||
|
{hint && <span className="text-[11px] text-grey-4">{hint}</span>} |
||||
|
<a |
||||
|
onClick={onSeeAll} |
||||
|
className="ms-auto cursor-pointer text-[12px] text-[#ff8d8d] hover:underline" |
||||
|
> |
||||
|
مشاهده همه ← |
||||
|
</a> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
import { toFa } from '@/lib/utils' |
||||
|
import { |
||||
|
Pagination, |
||||
|
PaginationContent, |
||||
|
PaginationItem, |
||||
|
PaginationLink, |
||||
|
PaginationPrevious, |
||||
|
PaginationNext, |
||||
|
PaginationEllipsis, |
||||
|
} from '@/components/ui/pagination' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* صفحهبندی مشترک جدولهای مدیریت (تگها/ژانرها/…). |
||||
|
* |
||||
|
* server-side: total = count کل از بکاند، page صفحهٔ فعلی. تعداد صفحات از |
||||
|
* count/pageSize محاسبه میشود. اگر فقط یک صفحه باشد چیزی رندر نمیشود. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
type TablePaginationProps = { |
||||
|
page?: number |
||||
|
currentPage?: number |
||||
|
pageSize?: number |
||||
|
total?: number |
||||
|
totalPages?: number |
||||
|
onPageChange: (page: number) => void |
||||
|
} |
||||
|
|
||||
|
/** بازهٔ صفحاتِ قابلنمایش حول صفحهٔ فعلی (حداکثر ۵ عدد). */ |
||||
|
function pageWindow(page: number, totalPages: number): number[] { |
||||
|
const span = 2 |
||||
|
let start = Math.max(1, page - span) |
||||
|
let end = Math.min(totalPages, page + span) |
||||
|
// پنجره را تا ۵ عدد پر میکنیم وقتی نزدیک ابتدا/انتها هستیم.
|
||||
|
if (end - start < 4) { |
||||
|
if (start === 1) end = Math.min(totalPages, start + 4) |
||||
|
else if (end === totalPages) start = Math.max(1, end - 4) |
||||
|
} |
||||
|
const pages: number[] = [] |
||||
|
for (let p = start; p <= end; p++) pages.push(p) |
||||
|
return pages |
||||
|
} |
||||
|
|
||||
|
export default function TablePagination({ |
||||
|
page, |
||||
|
currentPage, |
||||
|
pageSize = 10, |
||||
|
total, |
||||
|
totalPages: propTotalPages, |
||||
|
onPageChange, |
||||
|
}: TablePaginationProps) { |
||||
|
const activePage = currentPage ?? page ?? 1 |
||||
|
const totalPages = propTotalPages ?? (total !== undefined ? Math.max(1, Math.ceil(total / pageSize)) : 1) |
||||
|
if (totalPages <= 1) return null |
||||
|
|
||||
|
const pages = pageWindow(activePage, totalPages) |
||||
|
const first = pages[0] |
||||
|
const last = pages[pages.length - 1] |
||||
|
|
||||
|
return ( |
||||
|
<Pagination className="mx-0 w-auto"> |
||||
|
<PaginationContent> |
||||
|
<PaginationItem> |
||||
|
<PaginationPrevious disabled={activePage <= 1} onClick={() => onPageChange(activePage - 1)} /> |
||||
|
</PaginationItem> |
||||
|
|
||||
|
{first > 1 && ( |
||||
|
<> |
||||
|
<PaginationItem> |
||||
|
<PaginationLink onClick={() => onPageChange(1)}>{toFa(1)}</PaginationLink> |
||||
|
</PaginationItem> |
||||
|
{first > 2 && ( |
||||
|
<PaginationItem> |
||||
|
<PaginationEllipsis /> |
||||
|
</PaginationItem> |
||||
|
)} |
||||
|
</> |
||||
|
)} |
||||
|
|
||||
|
{pages.map((p) => ( |
||||
|
<PaginationItem key={p}> |
||||
|
<PaginationLink isActive={p === activePage} onClick={() => onPageChange(p)}> |
||||
|
{toFa(p)} |
||||
|
</PaginationLink> |
||||
|
</PaginationItem> |
||||
|
))} |
||||
|
|
||||
|
{last < totalPages && ( |
||||
|
<> |
||||
|
{last < totalPages - 1 && ( |
||||
|
<PaginationItem> |
||||
|
<PaginationEllipsis /> |
||||
|
</PaginationItem> |
||||
|
)} |
||||
|
<PaginationItem> |
||||
|
<PaginationLink onClick={() => onPageChange(totalPages)}>{toFa(totalPages)}</PaginationLink> |
||||
|
</PaginationItem> |
||||
|
</> |
||||
|
)} |
||||
|
|
||||
|
<PaginationItem> |
||||
|
<PaginationNext disabled={activePage >= totalPages} onClick={() => onPageChange(activePage + 1)} /> |
||||
|
</PaginationItem> |
||||
|
</PaginationContent> |
||||
|
</Pagination> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,93 @@ |
|||||
|
import { cn } from '@/lib/utils' |
||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||
|
import { TableRow, TableCell } from '@/components/ui/table' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* Skeleton ردیفهای جدول — قابلاستفادهٔ مجدد در همهٔ جدولهای مدیریتی. |
||||
|
* فقط داخل <TableBody> رندر میشود تا هدر/فیلتر/فوتر ثابت بمانند و ساختار نپرد. |
||||
|
* |
||||
|
* هر ستون با یک descriptor توصیف میشود؛ تعداد و ترتیب ستونها باید با هدر |
||||
|
* جدول یکی باشد: |
||||
|
* <TableBody> |
||||
|
* {isLoading ? <TableRowsSkeleton columns={COLS} /> : rows.map(...)} |
||||
|
* </TableBody> |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
export type SkeletonCell = |
||||
|
/** ستون شماره/ردیف — باریک */ |
||||
|
| { type: 'index' } |
||||
|
/** متن ساده با عرض دلخواه (پیشفرض w-20) */ |
||||
|
| { type: 'text'; w?: string } |
||||
|
/** سلول هویت: تصویر/آواتار + دو خط متن (عنوان + زیرعنوان) */ |
||||
|
| { type: 'media'; round?: boolean; lines?: [string, string]; size?: string } |
||||
|
/** یک badge گرد */ |
||||
|
| { type: 'badge'; w?: string } |
||||
|
/** چند تگ/زبان کنار هم */ |
||||
|
| { type: 'tags'; count?: number } |
||||
|
/** دکمههای اکشن مربعی */ |
||||
|
| { type: 'actions'; count?: number } |
||||
|
|
||||
|
function CellSkeleton({ cell }: { cell: SkeletonCell }) { |
||||
|
switch (cell.type) { |
||||
|
case 'index': |
||||
|
return <Skeleton className="h-4 w-5" /> |
||||
|
case 'text': |
||||
|
return <Skeleton className={cn('h-4', cell.w ?? 'w-20')} /> |
||||
|
case 'media': |
||||
|
return ( |
||||
|
<div className="flex items-center gap-3"> |
||||
|
<Skeleton className={cn('shrink-0', cell.size ?? 'size-10', cell.round ? 'rounded-full' : 'rounded-lg')} /> |
||||
|
<div className="space-y-2"> |
||||
|
<Skeleton className={cn('h-3', cell.lines?.[0] ?? 'w-32')} /> |
||||
|
<Skeleton className={cn('h-3', cell.lines?.[1] ?? 'w-20')} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
case 'badge': |
||||
|
return <Skeleton className={cn('h-5 rounded-full', cell.w ?? 'w-14')} /> |
||||
|
case 'tags': |
||||
|
return ( |
||||
|
<div className="flex gap-1"> |
||||
|
{Array.from({ length: cell.count ?? 2 }).map((_, i) => ( |
||||
|
<Skeleton key={i} className="h-5 w-8 rounded-full" /> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
case 'actions': |
||||
|
return ( |
||||
|
<div className="flex gap-1.5"> |
||||
|
{Array.from({ length: cell.count ?? 3 }).map((_, i) => ( |
||||
|
<Skeleton key={i} className="size-8 rounded-md" /> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
type TableRowsSkeletonProps = { |
||||
|
/** descriptor هر ستون — به ترتیب ستونهای جدول */ |
||||
|
columns?: SkeletonCell[] |
||||
|
/** یا تعداد ستونهای ساده */ |
||||
|
cols?: number |
||||
|
/** تعداد ردیفهای skeleton (پیشفرض ۸) */ |
||||
|
rows?: number |
||||
|
} |
||||
|
|
||||
|
export default function TableRowsSkeleton({ columns, cols, rows = 8 }: TableRowsSkeletonProps) { |
||||
|
const finalCols: SkeletonCell[] = |
||||
|
columns ?? Array.from({ length: cols ?? 5 }, () => ({ type: 'text' as const })) |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
{Array.from({ length: rows }).map((_, r) => ( |
||||
|
<TableRow key={r} className="hover:bg-transparent"> |
||||
|
{finalCols.map((cell, c) => ( |
||||
|
<TableCell key={c}> |
||||
|
<CellSkeleton cell={cell} /> |
||||
|
</TableCell> |
||||
|
))} |
||||
|
</TableRow> |
||||
|
))} |
||||
|
</> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
import { Ic } from '@/icons' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import type { ViewMode } from '@/stores/view-mode' |
||||
|
|
||||
|
type ViewToggleProps = { |
||||
|
value: ViewMode |
||||
|
onChange: (mode: ViewMode) => void |
||||
|
className?: string |
||||
|
} |
||||
|
|
||||
|
const OPTIONS: { mode: ViewMode; icon: 'list' | 'grid'; title: string }[] = [ |
||||
|
{ mode: 'list', icon: 'list', title: 'نمایش جدولی' }, |
||||
|
{ mode: 'grid', icon: 'grid', title: 'نمایش کارتی' }, |
||||
|
] |
||||
|
|
||||
|
/* کنترل سگمنتی دو حالته برای جابهجایی بین نمای جدول (list) و کارت (grid) */ |
||||
|
export default function ViewToggle({ value, onChange, className }: ViewToggleProps) { |
||||
|
return ( |
||||
|
<div |
||||
|
role="group" |
||||
|
aria-label="حالت نمایش" |
||||
|
className={cn( |
||||
|
'flex h-10 items-center gap-1 rounded-lg border border-border-soft bg-white/5 p-1', |
||||
|
className, |
||||
|
)} |
||||
|
> |
||||
|
{OPTIONS.map(({ mode, icon, title }) => { |
||||
|
const active = value === mode |
||||
|
return ( |
||||
|
<button |
||||
|
key={mode} |
||||
|
type="button" |
||||
|
onClick={() => onChange(mode)} |
||||
|
title={title} |
||||
|
aria-label={title} |
||||
|
aria-pressed={active} |
||||
|
className={cn( |
||||
|
'flex size-8 items-center justify-center rounded-md transition-colors [&_svg]:size-4', |
||||
|
active |
||||
|
? 'bg-white/10 text-foreground' |
||||
|
: 'text-grey-3 hover:text-grey-2', |
||||
|
)} |
||||
|
> |
||||
|
<Ic name={icon} /> |
||||
|
</button> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,41 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as AccordionPrimitive from '@radix-ui/react-accordion' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const Accordion = AccordionPrimitive.Root |
||||
|
|
||||
|
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) { |
||||
|
return <AccordionPrimitive.Item className={cn('border-b border-border-soft', className)} {...props} /> |
||||
|
} |
||||
|
|
||||
|
function AccordionTrigger({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) { |
||||
|
return ( |
||||
|
<AccordionPrimitive.Header className="flex"> |
||||
|
<AccordionPrimitive.Trigger |
||||
|
className={cn( |
||||
|
'flex flex-1 items-center justify-between py-4 text-[13.5px] font-semibold text-grey-1 cursor-pointer transition-all outline-none [&[data-state=open]>svg]:rotate-180', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="text-grey-3 transition-transform duration-200"> |
||||
|
<polyline points="6 9 12 15 18 9" /> |
||||
|
</svg> |
||||
|
</AccordionPrimitive.Trigger> |
||||
|
</AccordionPrimitive.Header> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function AccordionContent({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Content>) { |
||||
|
return ( |
||||
|
<AccordionPrimitive.Content |
||||
|
className="overflow-hidden data-[state=closed]:animate-[accordion-up_.18s_ease] data-[state=open]:animate-[accordion-down_.18s_ease]" |
||||
|
{...props} |
||||
|
> |
||||
|
<div className={cn('pb-4', className)}>{children}</div> |
||||
|
</AccordionPrimitive.Content> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } |
||||
@ -0,0 +1,53 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const AlertDialog = AlertDialogPrimitive.Root |
||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger |
||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal |
||||
|
const AlertDialogCancel = AlertDialogPrimitive.Cancel |
||||
|
const AlertDialogAction = AlertDialogPrimitive.Action |
||||
|
|
||||
|
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) { |
||||
|
return ( |
||||
|
<AlertDialogPrimitive.Overlay |
||||
|
className={cn('fixed inset-0 z-50 bg-black/55 backdrop-blur-[3px]', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function AlertDialogContent({ className, children, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) { |
||||
|
return ( |
||||
|
<AlertDialogPortal> |
||||
|
<AlertDialogOverlay /> |
||||
|
<AlertDialogPrimitive.Content |
||||
|
className={cn( |
||||
|
'fixed left-1/2 top-1/2 z-50 w-full max-w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border-soft bg-[#27282e] shadow-[0_30px_80px_rgba(0,0,0,.55)] anim-pop outline-none p-6', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
</AlertDialogPrimitive.Content> |
||||
|
</AlertDialogPortal> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function AlertDialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { |
||||
|
return <div className={cn('space-y-1.5 text-center', className)} {...props} /> |
||||
|
} |
||||
|
|
||||
|
function AlertDialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { |
||||
|
return <div className={cn('flex items-center justify-center gap-3 pt-5', className)} {...props} /> |
||||
|
} |
||||
|
|
||||
|
const AlertDialogTitle = AlertDialogPrimitive.Title |
||||
|
const AlertDialogDescription = AlertDialogPrimitive.Description |
||||
|
|
||||
|
export { |
||||
|
AlertDialog, AlertDialogTrigger, AlertDialogContent, |
||||
|
AlertDialogHeader, AlertDialogFooter, |
||||
|
AlertDialogTitle, AlertDialogDescription, |
||||
|
AlertDialogCancel, AlertDialogAction, |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
type AvatarProps = React.ComponentProps<'div'> & { |
||||
|
tone?: string |
||||
|
letter?: string |
||||
|
ring?: boolean |
||||
|
/** URL تصویر واقعی؛ اگر باشد بهجای گرادیان+حرف نمایش داده میشود. */ |
||||
|
src?: string | null |
||||
|
} |
||||
|
|
||||
|
/* Hosseinieh letter-avatar: gradient circle + Persian initial |
||||
|
اگر src داده شود، تصویر واقعی نمایش داده میشود و گرادیان حذف میشود. */ |
||||
|
function Avatar({ className, tone, letter = 'م', ring = false, src, children, ...props }: AvatarProps) { |
||||
|
return ( |
||||
|
<div |
||||
|
className={cn( |
||||
|
'relative flex shrink-0 items-center justify-center rounded-full text-white select-none', |
||||
|
!src && tone, |
||||
|
src && 'overflow-hidden', |
||||
|
ring && 'ring-2 ring-red-mid/70 ring-offset-2 ring-offset-background', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
{src ? ( |
||||
|
<img src={src} alt="" loading="lazy" className="size-full object-cover" /> |
||||
|
) : ( |
||||
|
children ?? letter |
||||
|
)} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Avatar } |
||||
@ -0,0 +1,26 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { cva, type VariantProps } from 'class-variance-authority' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const badgeVariants = cva( |
||||
|
'inline-flex items-center justify-center gap-1 whitespace-nowrap font-medium rounded-full px-2.5 py-0.5 text-[11.5px] leading-5', |
||||
|
{ |
||||
|
variants: { |
||||
|
variant: { |
||||
|
default: 'bg-primary text-primary-foreground', |
||||
|
secondary: 'bg-white/8 text-grey-2 border border-border-soft', |
||||
|
outline: 'border border-border-soft text-foreground bg-transparent', |
||||
|
destructive: 'bg-destructive/15 text-rose-400 border border-destructive/30', |
||||
|
success: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30', |
||||
|
count: 'bg-white/10 text-grey-2 min-w-5 px-1.5 text-[11px]', |
||||
|
}, |
||||
|
}, |
||||
|
defaultVariants: { variant: 'default' }, |
||||
|
}, |
||||
|
) |
||||
|
|
||||
|
function Badge({ className, variant, ...props }: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) { |
||||
|
return <span className={cn(badgeVariants({ variant }), className)} {...props} /> |
||||
|
} |
||||
|
|
||||
|
export { Badge, badgeVariants } |
||||
@ -0,0 +1,36 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { cva, type VariantProps } from 'class-variance-authority' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const buttonVariants = cva( |
||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring outline-none [&_svg]:shrink-0', |
||||
|
{ |
||||
|
variants: { |
||||
|
variant: { |
||||
|
default: 'bg-primary text-primary-foreground shadow-sm hover:brightness-110', |
||||
|
destructive: 'bg-destructive text-white hover:bg-destructive/90 shadow-sm', |
||||
|
secondary: 'bg-white/8 text-grey-1 border border-border-soft hover:bg-white/12', |
||||
|
ghost: 'text-grey-2 hover:bg-white/8 hover:text-foreground', |
||||
|
outline: 'border border-border bg-transparent text-grey-1 hover:bg-white/6', |
||||
|
white: 'bg-white text-[#1c1c20] hover:bg-grey-1', |
||||
|
}, |
||||
|
size: { |
||||
|
default: 'h-10 px-5 rounded-md text-[13.5px]', |
||||
|
sm: 'h-8 px-3.5 rounded-sm text-[12.5px]', |
||||
|
lg: 'h-11 px-6 rounded-lg text-sm', |
||||
|
icon: 'size-10 rounded-md', |
||||
|
'icon-sm': 'size-8 rounded-sm', |
||||
|
}, |
||||
|
}, |
||||
|
defaultVariants: { variant: 'default', size: 'default' }, |
||||
|
}, |
||||
|
) |
||||
|
|
||||
|
type ButtonProps = React.ComponentProps<'button'> & VariantProps<typeof buttonVariants> |
||||
|
|
||||
|
function Button({ className, variant, size, ...props }: ButtonProps) { |
||||
|
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} /> |
||||
|
} |
||||
|
|
||||
|
export { Button, buttonVariants } |
||||
|
export type { ButtonProps } |
||||
@ -0,0 +1,243 @@ |
|||||
|
import * as React from 'react' |
||||
|
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import { Ic } from '@/icons' |
||||
|
|
||||
|
type CarouselApi = UseEmblaCarouselType[1] |
||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> |
||||
|
type CarouselOptions = UseCarouselParameters[0] |
||||
|
type CarouselPlugin = UseCarouselParameters[1] |
||||
|
|
||||
|
type CarouselProps = { |
||||
|
opts?: CarouselOptions |
||||
|
plugins?: CarouselPlugin |
||||
|
orientation?: 'horizontal' | 'vertical' |
||||
|
setApi?: (api: CarouselApi) => void |
||||
|
} |
||||
|
|
||||
|
type CarouselContextProps = { |
||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0] |
||||
|
api: CarouselApi |
||||
|
scrollPrev: () => void |
||||
|
scrollNext: () => void |
||||
|
canScrollPrev: boolean |
||||
|
canScrollNext: boolean |
||||
|
selectedIndex: number |
||||
|
scrollSnaps: number[] |
||||
|
} & CarouselProps |
||||
|
|
||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null) |
||||
|
|
||||
|
function useCarousel() { |
||||
|
const context = React.useContext(CarouselContext) |
||||
|
if (!context) throw new Error('useCarousel must be used within a <Carousel />') |
||||
|
return context |
||||
|
} |
||||
|
|
||||
|
const Carousel = React.forwardRef< |
||||
|
HTMLDivElement, |
||||
|
React.HTMLAttributes<HTMLDivElement> & CarouselProps |
||||
|
>(({ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }, ref) => { |
||||
|
const [carouselRef, api] = useEmblaCarousel( |
||||
|
{ ...opts, axis: orientation === 'horizontal' ? 'x' : 'y', direction: 'rtl' }, |
||||
|
plugins, |
||||
|
) |
||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false) |
||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false) |
||||
|
const [selectedIndex, setSelectedIndex] = React.useState(0) |
||||
|
const [scrollSnaps, setScrollSnaps] = React.useState<number[]>([]) |
||||
|
|
||||
|
const onSelect = React.useCallback((api: CarouselApi) => { |
||||
|
if (!api) return |
||||
|
setCanScrollPrev(api.canScrollPrev()) |
||||
|
setCanScrollNext(api.canScrollNext()) |
||||
|
setSelectedIndex(api.selectedScrollSnap()) |
||||
|
}, []) |
||||
|
|
||||
|
const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]) |
||||
|
const scrollNext = React.useCallback(() => api?.scrollNext(), [api]) |
||||
|
|
||||
|
const handleKeyDown = React.useCallback( |
||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => { |
||||
|
if (event.key === 'ArrowLeft') { |
||||
|
event.preventDefault() |
||||
|
scrollNext() |
||||
|
} else if (event.key === 'ArrowRight') { |
||||
|
event.preventDefault() |
||||
|
scrollPrev() |
||||
|
} |
||||
|
}, |
||||
|
[scrollPrev, scrollNext], |
||||
|
) |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
if (!api || !setApi) return |
||||
|
setApi(api) |
||||
|
}, [api, setApi]) |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
if (!api) return |
||||
|
setScrollSnaps(api.scrollSnapList()) |
||||
|
onSelect(api) |
||||
|
api.on('reInit', onSelect) |
||||
|
api.on('select', onSelect) |
||||
|
return () => { |
||||
|
api.off('select', onSelect) |
||||
|
} |
||||
|
}, [api, onSelect]) |
||||
|
|
||||
|
return ( |
||||
|
<CarouselContext.Provider |
||||
|
value={{ |
||||
|
carouselRef, |
||||
|
api, |
||||
|
opts, |
||||
|
orientation, |
||||
|
scrollPrev, |
||||
|
scrollNext, |
||||
|
canScrollPrev, |
||||
|
canScrollNext, |
||||
|
selectedIndex, |
||||
|
scrollSnaps, |
||||
|
}} |
||||
|
> |
||||
|
<div |
||||
|
ref={ref} |
||||
|
onKeyDownCapture={handleKeyDown} |
||||
|
className={cn('relative', className)} |
||||
|
role="region" |
||||
|
aria-roledescription="carousel" |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
</div> |
||||
|
</CarouselContext.Provider> |
||||
|
) |
||||
|
}) |
||||
|
Carousel.displayName = 'Carousel' |
||||
|
|
||||
|
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( |
||||
|
({ className, ...props }, ref) => { |
||||
|
const { carouselRef, orientation } = useCarousel() |
||||
|
return ( |
||||
|
<div ref={carouselRef} className="overflow-hidden"> |
||||
|
<div |
||||
|
ref={ref} |
||||
|
className={cn( |
||||
|
'flex', |
||||
|
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
</div> |
||||
|
) |
||||
|
}, |
||||
|
) |
||||
|
CarouselContent.displayName = 'CarouselContent' |
||||
|
|
||||
|
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( |
||||
|
({ className, ...props }, ref) => { |
||||
|
const { orientation } = useCarousel() |
||||
|
return ( |
||||
|
<div |
||||
|
ref={ref} |
||||
|
role="group" |
||||
|
aria-roledescription="slide" |
||||
|
className={cn( |
||||
|
'min-w-0 shrink-0 grow-0 basis-full', |
||||
|
orientation === 'horizontal' ? 'pl-4' : 'pt-4', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
}, |
||||
|
) |
||||
|
CarouselItem.displayName = 'CarouselItem' |
||||
|
|
||||
|
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'>>( |
||||
|
({ className, ...props }, ref) => { |
||||
|
const { orientation, scrollPrev, canScrollPrev, canScrollNext } = useCarousel() |
||||
|
/* وقتی همهٔ آیتمها در یک نما جا میشوند (چیزی برای اسکرول نیست) دکمهها نمایش داده نمیشوند */ |
||||
|
if (!canScrollPrev && !canScrollNext) return null |
||||
|
return ( |
||||
|
<button |
||||
|
ref={ref} |
||||
|
disabled={!canScrollPrev} |
||||
|
onClick={scrollPrev} |
||||
|
className={cn( |
||||
|
'absolute z-10 flex size-9 items-center justify-center rounded-full border bg-background shadow-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-30', |
||||
|
orientation === 'horizontal' |
||||
|
? '-right-4 top-1/2 -translate-y-1/2' |
||||
|
: '-top-4 left-1/2 -translate-x-1/2 rotate-90', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
<Ic name="chevronRight" className="size-4" /> |
||||
|
<span className="sr-only">قبلی</span> |
||||
|
</button> |
||||
|
) |
||||
|
}, |
||||
|
) |
||||
|
CarouselPrevious.displayName = 'CarouselPrevious' |
||||
|
|
||||
|
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'>>( |
||||
|
({ className, ...props }, ref) => { |
||||
|
const { orientation, scrollNext, canScrollPrev, canScrollNext } = useCarousel() |
||||
|
/* وقتی همهٔ آیتمها در یک نما جا میشوند (چیزی برای اسکرول نیست) دکمهها نمایش داده نمیشوند */ |
||||
|
if (!canScrollPrev && !canScrollNext) return null |
||||
|
return ( |
||||
|
<button |
||||
|
ref={ref} |
||||
|
disabled={!canScrollNext} |
||||
|
onClick={scrollNext} |
||||
|
className={cn( |
||||
|
'absolute z-10 flex size-9 items-center justify-center rounded-full border bg-background shadow-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-30', |
||||
|
orientation === 'horizontal' |
||||
|
? '-left-4 top-1/2 -translate-y-1/2' |
||||
|
: '-bottom-4 left-1/2 -translate-x-1/2 rotate-90', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
<Ic name="chevronLeft" className="size-4" /> |
||||
|
<span className="sr-only">بعدی</span> |
||||
|
</button> |
||||
|
) |
||||
|
}, |
||||
|
) |
||||
|
CarouselNext.displayName = 'CarouselNext' |
||||
|
|
||||
|
const CarouselDots = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( |
||||
|
({ className, ...props }, ref) => { |
||||
|
const { selectedIndex, scrollSnaps, api } = useCarousel() |
||||
|
if (scrollSnaps.length <= 1) return null |
||||
|
return ( |
||||
|
<div |
||||
|
ref={ref} |
||||
|
className={cn('flex items-center justify-center gap-1.5 py-3', className)} |
||||
|
{...props} |
||||
|
> |
||||
|
{scrollSnaps.map((_, i) => ( |
||||
|
<button |
||||
|
key={i} |
||||
|
onClick={() => api?.scrollTo(i)} |
||||
|
className={cn( |
||||
|
'size-2 rounded-full transition-all', |
||||
|
i === selectedIndex |
||||
|
? 'w-5 bg-g-primary' |
||||
|
: 'bg-grey-4/40 hover:bg-grey-4/60', |
||||
|
)} |
||||
|
aria-label={`Go to slide ${i + 1}`} |
||||
|
/> |
||||
|
))} |
||||
|
</div> |
||||
|
) |
||||
|
}, |
||||
|
) |
||||
|
CarouselDots.displayName = 'CarouselDots' |
||||
|
|
||||
|
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, CarouselDots } |
||||
|
export type { CarouselApi } |
||||
@ -0,0 +1,23 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { |
||||
|
return ( |
||||
|
<CheckboxPrimitive.Root |
||||
|
className={cn( |
||||
|
'peer size-[19px] shrink-0 rounded-[6px] border border-white/25 bg-white/5 cursor-pointer transition-colors data-[state=checked]:bg-g-primary data-[state=checked]:border-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-white"> |
||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round"> |
||||
|
<polyline points="20 6 9 17 4 12" /> |
||||
|
</svg> |
||||
|
</CheckboxPrimitive.Indicator> |
||||
|
</CheckboxPrimitive.Root> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Checkbox } |
||||
@ -0,0 +1,154 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { Command as CommandPrimitive } from 'cmdk' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* Command — لایهٔ نازک روی cmdk با توکنهای حسینیه و RTL. |
||||
|
* مبنای Command Palette سراسری (GlobalSearchDialog). فیلتر/مرتبسازی پیشفرض |
||||
|
* cmdk قابل غیرفعالسازی است (shouldFilter={false}) تا نتایج سمت سرویس بیایند. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) { |
||||
|
return ( |
||||
|
<CommandPrimitive |
||||
|
className={cn( |
||||
|
'flex h-full w-full flex-col overflow-hidden rounded-xl bg-[#27282e] text-foreground', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* CommandDialog — پوستهٔ Dialog مرکزی برای Command Palette. |
||||
|
* عرض ~۸۰۰px، کمی متمایل به بالا، max-h ~۷۲vh؛ روی موبایل تقریباً تمامعرض. |
||||
|
*/ |
||||
|
function CommandDialog({ |
||||
|
open, |
||||
|
onOpenChange, |
||||
|
title = 'جستجوی سراسری', |
||||
|
description = 'جستجو در آهنگها، مداحان، سخنرانان و آلبومها', |
||||
|
className, |
||||
|
children, |
||||
|
...props |
||||
|
}: React.ComponentProps<typeof Command> & { |
||||
|
open: boolean |
||||
|
onOpenChange: (open: boolean) => void |
||||
|
title?: string |
||||
|
description?: string |
||||
|
}) { |
||||
|
return ( |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent |
||||
|
className={cn( |
||||
|
'left-1/2 top-[12vh] w-[calc(100vw-2rem)] max-w-[800px] -translate-x-1/2 translate-y-0', |
||||
|
'overflow-hidden border-border-soft bg-[#27282e] p-0 shadow-[0_30px_90px_rgba(0,0,0,.6)]', |
||||
|
className, |
||||
|
)} |
||||
|
> |
||||
|
<DialogTitle className="sr-only">{title}</DialogTitle> |
||||
|
<DialogDescription className="sr-only">{description}</DialogDescription> |
||||
|
<Command |
||||
|
shouldFilter={false} |
||||
|
className="max-h-[72vh] [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-bold [&_[cmdk-group-heading]]:text-grey-3" |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
</Command> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function CommandInput({ |
||||
|
className, |
||||
|
trailing, |
||||
|
...props |
||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input> & { |
||||
|
/** اسلات انتهای فیلد (مثلاً دکمهٔ میکروفون جستجوی صوتی) */ |
||||
|
trailing?: React.ReactNode |
||||
|
}) { |
||||
|
return ( |
||||
|
<div className="flex items-center gap-2.5 border-b border-border-soft px-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-grey-3"> |
||||
|
<Ic name="search" /> |
||||
|
<CommandPrimitive.Input |
||||
|
className={cn( |
||||
|
'h-13 flex-1 bg-transparent py-3.5 text-[13.5px] text-foreground outline-none placeholder:text-grey-4 disabled:cursor-not-allowed disabled:opacity-50', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
{trailing} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) { |
||||
|
return ( |
||||
|
<CommandPrimitive.List |
||||
|
className={cn('max-h-[60vh] min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-2', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function CommandEmpty(props: React.ComponentProps<typeof CommandPrimitive.Empty>) { |
||||
|
return <CommandPrimitive.Empty className="py-10 text-center text-[12.5px] text-grey-3" {...props} /> |
||||
|
} |
||||
|
|
||||
|
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) { |
||||
|
return ( |
||||
|
<CommandPrimitive.Group |
||||
|
className={cn('overflow-hidden text-foreground', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) { |
||||
|
return ( |
||||
|
<CommandPrimitive.Separator className={cn('mx-2 my-1 h-px bg-border-soft', className)} {...props} /> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) { |
||||
|
return ( |
||||
|
<CommandPrimitive.Item |
||||
|
className={cn( |
||||
|
'relative flex cursor-pointer select-none items-center gap-3 rounded-lg px-2.5 py-2 outline-none', |
||||
|
'transition-colors data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50', |
||||
|
'data-[selected=true]:bg-[rgba(157,33,33,.18)] data-[selected=true]:text-foreground', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** میانبر صفحهکلید — در RTL سمت چپ ردیف مینشیند (ms-auto). */ |
||||
|
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { |
||||
|
return ( |
||||
|
<span |
||||
|
className={cn( |
||||
|
'ms-auto inline-flex items-center gap-1 text-[10.5px] tracking-widest text-grey-3', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
Command, |
||||
|
CommandDialog, |
||||
|
CommandInput, |
||||
|
CommandList, |
||||
|
CommandEmpty, |
||||
|
CommandGroup, |
||||
|
CommandItem, |
||||
|
CommandSeparator, |
||||
|
CommandShortcut, |
||||
|
} |
||||
@ -0,0 +1,67 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const Dialog = DialogPrimitive.Root |
||||
|
const DialogTrigger = DialogPrimitive.Trigger |
||||
|
const DialogClose = DialogPrimitive.Close |
||||
|
const DialogPortal = DialogPrimitive.Portal |
||||
|
|
||||
|
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { |
||||
|
return ( |
||||
|
<DialogPrimitive.Overlay |
||||
|
className={cn('fixed inset-0 z-50 bg-black/55 backdrop-blur-[3px]', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) { |
||||
|
return ( |
||||
|
<DialogPortal> |
||||
|
<DialogOverlay /> |
||||
|
<DialogPrimitive.Content |
||||
|
className={cn( |
||||
|
'fixed left-1/2 top-1/2 z-50 w-full max-w-[640px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border-soft bg-[#27282e] shadow-[0_30px_80px_rgba(0,0,0,.55)] anim-pop outline-none', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
</DialogPrimitive.Content> |
||||
|
</DialogPortal> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const DialogTitle = DialogPrimitive.Title |
||||
|
const DialogDescription = DialogPrimitive.Description |
||||
|
|
||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { |
||||
|
return ( |
||||
|
<div |
||||
|
className={cn('flex flex-col space-y-1.5 text-start pb-3 border-b border-border-soft', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { |
||||
|
return ( |
||||
|
<div |
||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4 border-t border-border-soft', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
Dialog, |
||||
|
DialogTrigger, |
||||
|
DialogClose, |
||||
|
DialogContent, |
||||
|
DialogHeader, |
||||
|
DialogFooter, |
||||
|
DialogTitle, |
||||
|
DialogDescription, |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,67 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const DropdownMenu = DropdownMenuPrimitive.Root |
||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger |
||||
|
|
||||
|
function DropdownMenuContent({ className, sideOffset = 6, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { |
||||
|
return ( |
||||
|
<DropdownMenuPrimitive.Portal> |
||||
|
<DropdownMenuPrimitive.Content |
||||
|
sideOffset={sideOffset} |
||||
|
className={cn( |
||||
|
'z-50 min-w-[10rem] overflow-hidden rounded-md border border-border-soft bg-popover p-1.5 text-popover-foreground shadow-[0_18px_44px_rgba(0,0,0,.5)] anim-pop', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
</DropdownMenuPrimitive.Portal> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function DropdownMenuItem({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item>) { |
||||
|
return ( |
||||
|
<DropdownMenuPrimitive.Item |
||||
|
className={cn( |
||||
|
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-3 py-2 text-[12.5px] text-grey-2 outline-none transition-colors data-[highlighted]:bg-white/8 data-[highlighted]:text-foreground', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function DropdownMenuLabel({ |
||||
|
className, |
||||
|
...props |
||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label>) { |
||||
|
return ( |
||||
|
<DropdownMenuPrimitive.Label |
||||
|
className={cn('px-2 py-1.5 text-xs font-semibold text-grey-3', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function DropdownMenuSeparator({ |
||||
|
className, |
||||
|
...props |
||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { |
||||
|
return ( |
||||
|
<DropdownMenuPrimitive.Separator |
||||
|
className={cn('-mx-1 my-1 h-px bg-border-soft', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
DropdownMenu, |
||||
|
DropdownMenuTrigger, |
||||
|
DropdownMenuContent, |
||||
|
DropdownMenuItem, |
||||
|
DropdownMenuLabel, |
||||
|
DropdownMenuSeparator, |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,16 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
function Input({ className, ...props }: React.ComponentProps<'input'>) { |
||||
|
return ( |
||||
|
<input |
||||
|
className={cn( |
||||
|
'w-full h-11 rounded-md bg-input border border-border-soft px-3.5 text-[13.5px] text-foreground placeholder:text-grey-4 outline-none transition-colors focus:border-ring', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Input } |
||||
@ -0,0 +1,190 @@ |
|||||
|
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react' |
||||
|
import { useInfiniteQuery } from '@tanstack/react-query' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { |
||||
|
Popover, |
||||
|
PopoverContent, |
||||
|
PopoverTrigger, |
||||
|
} from '@/components/ui/popover' |
||||
|
|
||||
|
type MultiSelectProps = { |
||||
|
label?: string |
||||
|
items?: string[] |
||||
|
selected: string[] |
||||
|
onChange: (selected: string[]) => void |
||||
|
placeholder?: string |
||||
|
searchPlaceholder?: string |
||||
|
className?: string |
||||
|
contentClassName?: string |
||||
|
searchable?: boolean |
||||
|
trigger?: ReactNode | ((open: boolean) => ReactNode) |
||||
|
closeOnSelect?: boolean |
||||
|
queryKey?: readonly unknown[] |
||||
|
loadOptions?: (params: { query: string; page: number }) => Promise<{ items: string[]; nextPage?: number }> |
||||
|
} |
||||
|
|
||||
|
export default function MultiSelect({ |
||||
|
label, |
||||
|
items = [], |
||||
|
selected, |
||||
|
onChange, |
||||
|
placeholder = 'انتخاب زبانها…', |
||||
|
searchPlaceholder = 'جستجو…', |
||||
|
className, |
||||
|
contentClassName, |
||||
|
searchable = true, |
||||
|
trigger, |
||||
|
closeOnSelect = false, |
||||
|
queryKey, |
||||
|
loadOptions, |
||||
|
}: MultiSelectProps) { |
||||
|
const [open, setOpen] = useState(false) |
||||
|
const [query, setQuery] = useState('') |
||||
|
const searchRef = useRef<HTMLInputElement>(null) |
||||
|
const listRef = useRef<HTMLDivElement>(null) |
||||
|
|
||||
|
const infinite = useInfiniteQuery({ |
||||
|
queryKey: [...(queryKey ?? ['multi-select-options']), query], |
||||
|
queryFn: ({ pageParam }) => loadOptions!({ query, page: pageParam }), |
||||
|
initialPageParam: 1, |
||||
|
getNextPageParam: (lastPage) => lastPage.nextPage, |
||||
|
enabled: open && !!loadOptions, |
||||
|
}) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (open && searchable) { |
||||
|
setTimeout(() => searchRef.current?.focus(), 50) |
||||
|
} |
||||
|
}, [open, searchable]) |
||||
|
|
||||
|
const setPopoverOpen = (nextOpen: boolean) => { |
||||
|
if (!nextOpen) setQuery('') |
||||
|
setOpen(nextOpen) |
||||
|
} |
||||
|
|
||||
|
const filtered = useMemo(() => { |
||||
|
if (loadOptions) return infinite.data?.pages.flatMap((page) => page.items) ?? [] |
||||
|
return query ? items.filter((i) => i.includes(query)) : items |
||||
|
}, [items, query, loadOptions, infinite.data]) |
||||
|
|
||||
|
const onListScroll = () => { |
||||
|
const list = listRef.current |
||||
|
if (!list || !loadOptions || !infinite.hasNextPage || infinite.isFetchingNextPage) return |
||||
|
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 24) infinite.fetchNextPage() |
||||
|
} |
||||
|
|
||||
|
const toggle = (lang: string) => { |
||||
|
onChange( |
||||
|
selected.includes(lang) |
||||
|
? selected.filter((l) => l !== lang) |
||||
|
: [...selected, lang], |
||||
|
) |
||||
|
|
||||
|
if (closeOnSelect) setOpen(false) |
||||
|
} |
||||
|
|
||||
|
const renderedTrigger = typeof trigger === 'function' ? trigger(open) : trigger |
||||
|
|
||||
|
return ( |
||||
|
<div className={cn(className)}> |
||||
|
{label && <p className="mb-1.5 text-[12px] font-semibold text-grey-2">{label}</p>} |
||||
|
|
||||
|
<Popover open={open} onOpenChange={setPopoverOpen}> |
||||
|
<PopoverTrigger asChild> |
||||
|
{renderedTrigger ?? ( |
||||
|
<button |
||||
|
type="button" |
||||
|
className={cn( |
||||
|
'flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-lg border border-border-soft bg-white/5 px-3 py-2 text-right text-[13px] outline-none transition-colors hover:bg-white/9', |
||||
|
open && 'border-red-mid/50', |
||||
|
)} |
||||
|
> |
||||
|
{selected.length === 0 ? ( |
||||
|
<span className="text-grey-4">{placeholder}</span> |
||||
|
) : ( |
||||
|
selected.map((l) => ( |
||||
|
<span |
||||
|
key={l} |
||||
|
className="flex items-center gap-1 rounded-md bg-red-mid/20 px-2 py-0.5 text-[11.5px] text-red-mid" |
||||
|
> |
||||
|
{l} |
||||
|
<span |
||||
|
role="button" |
||||
|
tabIndex={0} |
||||
|
onClick={(e) => { e.stopPropagation(); toggle(l) }} |
||||
|
className="cursor-pointer" |
||||
|
> |
||||
|
<Ic name="x" className="size-3" /> |
||||
|
</span> |
||||
|
</span> |
||||
|
)) |
||||
|
)} |
||||
|
<Ic name="chevDown" className="me-auto size-4 shrink-0 opacity-60" /> |
||||
|
</button> |
||||
|
)} |
||||
|
</PopoverTrigger> |
||||
|
|
||||
|
<PopoverContent |
||||
|
align="start" |
||||
|
sideOffset={6} |
||||
|
className={cn( |
||||
|
'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-lg border border-border-soft bg-[#27282e] p-0 shadow-[0_16px_48px_rgba(0,0,0,.5)]', |
||||
|
contentClassName, |
||||
|
)} |
||||
|
> |
||||
|
{searchable && ( |
||||
|
<div className="border-b border-border-soft p-2"> |
||||
|
<input |
||||
|
ref={searchRef} |
||||
|
type="text" |
||||
|
value={query} |
||||
|
onChange={(e) => setQuery(e.target.value)} |
||||
|
placeholder={searchPlaceholder} |
||||
|
className="w-full rounded-md border border-border-soft bg-white/5 px-3 py-1.5 text-[12.5px] text-grey-2 outline-none transition-colors placeholder:text-grey-4 focus:border-red-mid/50" |
||||
|
/> |
||||
|
</div> |
||||
|
)} |
||||
|
<div ref={listRef} onScroll={onListScroll} className="max-h-48 overflow-y-auto p-1.5"> |
||||
|
{infinite.isLoading ? ( |
||||
|
<p className="px-3 py-4 text-center text-[12px] text-grey-4">در حال بارگذاری…</p> |
||||
|
) : filtered.length === 0 ? ( |
||||
|
<p className="px-3 py-4 text-center text-[12px] text-grey-4">نتیجهای یافت نشد</p> |
||||
|
) : ( |
||||
|
filtered.map((lang) => { |
||||
|
const active = selected.includes(lang) |
||||
|
return ( |
||||
|
<button |
||||
|
key={lang} |
||||
|
type="button" |
||||
|
onClick={() => toggle(lang)} |
||||
|
className={cn( |
||||
|
'flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-[13px] transition-colors hover:bg-white/8', |
||||
|
active ? 'text-foreground' : 'text-grey-2', |
||||
|
)} |
||||
|
> |
||||
|
<span |
||||
|
className={cn( |
||||
|
'flex size-4 shrink-0 items-center justify-center rounded border text-[10px] transition-colors', |
||||
|
active ? 'border-red-mid bg-red-mid text-white' : 'border-border-soft', |
||||
|
)} |
||||
|
> |
||||
|
{active && <Ic name="check" className="size-3" />} |
||||
|
</span> |
||||
|
{lang} |
||||
|
</button> |
||||
|
) |
||||
|
}) |
||||
|
)} |
||||
|
{infinite.isFetchingNextPage && ( |
||||
|
<div className="flex items-center justify-center gap-2 px-3 py-2 text-[11.5px] text-grey-3"> |
||||
|
<span className="size-3 animate-spin rounded-full border border-white/20 border-t-red-mid" /> |
||||
|
بارگذاری موارد بیشتر… |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
</PopoverContent> |
||||
|
</Popover> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,111 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import { buttonVariants } from '@/components/ui/button' |
||||
|
|
||||
|
/* ── Pagination root ── */ |
||||
|
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { |
||||
|
return ( |
||||
|
<nav |
||||
|
role="navigation" |
||||
|
aria-label="pagination" |
||||
|
className={cn('mx-auto flex w-full justify-center', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
Pagination.displayName = 'Pagination' |
||||
|
|
||||
|
/* ── Content (flex row) ── */ |
||||
|
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>( |
||||
|
({ className, ...props }, ref) => ( |
||||
|
<ul ref={ref} className={cn('flex flex-row items-center gap-1', className)} {...props} /> |
||||
|
), |
||||
|
) |
||||
|
PaginationContent.displayName = 'PaginationContent' |
||||
|
|
||||
|
/* ── Item (single list item) ── */ |
||||
|
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>( |
||||
|
({ className, ...props }, ref) => <li ref={ref} className={cn('', className)} {...props} />, |
||||
|
) |
||||
|
PaginationItem.displayName = 'PaginationItem' |
||||
|
|
||||
|
/* ── Link (page number button) — uses Button's secondary variant styling ── */ |
||||
|
type PaginationLinkProps = { |
||||
|
isActive?: boolean |
||||
|
disabled?: boolean |
||||
|
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' |
||||
|
} & React.ComponentProps<'button'> |
||||
|
|
||||
|
function PaginationLink({ className, isActive, disabled, size = 'icon-sm', ...props }: PaginationLinkProps) { |
||||
|
return ( |
||||
|
<button |
||||
|
className={cn( |
||||
|
buttonVariants({ |
||||
|
variant: isActive ? 'default' : 'secondary', |
||||
|
size, |
||||
|
}), |
||||
|
disabled && 'pointer-events-none opacity-40', |
||||
|
className, |
||||
|
)} |
||||
|
disabled={disabled} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
PaginationLink.displayName = 'PaginationLink' |
||||
|
|
||||
|
/* ── Previous button ── */ |
||||
|
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) { |
||||
|
return ( |
||||
|
<PaginationLink |
||||
|
aria-label="Go to previous page" |
||||
|
size="icon-sm" |
||||
|
className={cn('gap-1 [&_svg]:size-3.5', className)} |
||||
|
{...props} |
||||
|
> |
||||
|
<Ic name="chevronRight" /> |
||||
|
</PaginationLink> |
||||
|
) |
||||
|
} |
||||
|
PaginationPrevious.displayName = 'PaginationPrevious' |
||||
|
|
||||
|
/* ── Next button ── */ |
||||
|
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) { |
||||
|
return ( |
||||
|
<PaginationLink |
||||
|
aria-label="Go to next page" |
||||
|
size="icon-sm" |
||||
|
className={cn('gap-1 [&_svg]:size-3.5', className)} |
||||
|
{...props} |
||||
|
> |
||||
|
<Ic name="chevronLeft" /> |
||||
|
</PaginationLink> |
||||
|
) |
||||
|
} |
||||
|
PaginationNext.displayName = 'PaginationNext' |
||||
|
|
||||
|
/* ── Ellipsis ── */ |
||||
|
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) { |
||||
|
return ( |
||||
|
<span |
||||
|
aria-hidden |
||||
|
className={cn('flex h-8 w-8 items-center justify-center', className)} |
||||
|
{...props} |
||||
|
> |
||||
|
<Ic name="moreHorizontal" className="size-4" /> |
||||
|
<span className="sr-only">More pages</span> |
||||
|
</span> |
||||
|
) |
||||
|
} |
||||
|
PaginationEllipsis.displayName = 'PaginationEllipsis' |
||||
|
|
||||
|
export { |
||||
|
Pagination, |
||||
|
PaginationContent, |
||||
|
PaginationLink, |
||||
|
PaginationItem, |
||||
|
PaginationPrevious, |
||||
|
PaginationNext, |
||||
|
PaginationEllipsis, |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const Popover = PopoverPrimitive.Root |
||||
|
const PopoverTrigger = PopoverPrimitive.Trigger |
||||
|
const PopoverAnchor = PopoverPrimitive.Anchor |
||||
|
|
||||
|
function PopoverContent({ className, align = 'center', sideOffset = 4, ...props }: React.ComponentProps<typeof PopoverPrimitive.Content>) { |
||||
|
return ( |
||||
|
<PopoverPrimitive.Portal> |
||||
|
<PopoverPrimitive.Content |
||||
|
align={align} |
||||
|
className={cn( |
||||
|
'z-50 w-72 rounded-lg border border-border-soft bg-[#27282e] p-3 shadow-[0_16px_48px_rgba(0,0,0,.5)] anim-pop outline-none', |
||||
|
className, |
||||
|
)} |
||||
|
sideOffset={sideOffset} |
||||
|
{...props} |
||||
|
/> |
||||
|
</PopoverPrimitive.Portal> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } |
||||
@ -0,0 +1,63 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as SelectPrimitive from '@radix-ui/react-select' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import { Ic } from '@/icons' |
||||
|
|
||||
|
const Select = SelectPrimitive.Root |
||||
|
const SelectGroup = SelectPrimitive.Group |
||||
|
const SelectValue = SelectPrimitive.Value |
||||
|
|
||||
|
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) { |
||||
|
return ( |
||||
|
<SelectPrimitive.Trigger |
||||
|
className={cn( |
||||
|
'flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-border-soft bg-white/5 px-3 py-2 text-[13px] text-grey-2 outline-none transition-colors hover:bg-white/9 focus-visible:border-red-mid/50 data-[placeholder]:text-grey-4', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
<SelectPrimitive.Icon asChild> |
||||
|
<Ic name="chevDown" className="size-4 shrink-0 opacity-60" /> |
||||
|
</SelectPrimitive.Icon> |
||||
|
</SelectPrimitive.Trigger> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function SelectContent({ className, children, position = 'popper', ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) { |
||||
|
return ( |
||||
|
<SelectPrimitive.Portal> |
||||
|
<SelectPrimitive.Content |
||||
|
className={cn( |
||||
|
'relative z-50 max-h-64 min-w-[8rem] overflow-hidden rounded-lg border border-border-soft bg-[#27282e] shadow-[0_16px_48px_rgba(0,0,0,.5)] anim-pop', |
||||
|
position === 'popper' && 'translate-y-1', |
||||
|
className, |
||||
|
)} |
||||
|
position={position} |
||||
|
{...props} |
||||
|
> |
||||
|
<SelectPrimitive.Viewport |
||||
|
className={cn('p-1', position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]')} |
||||
|
> |
||||
|
{children} |
||||
|
</SelectPrimitive.Viewport> |
||||
|
</SelectPrimitive.Content> |
||||
|
</SelectPrimitive.Portal> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) { |
||||
|
return ( |
||||
|
<SelectPrimitive.Item |
||||
|
className={cn( |
||||
|
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-3 py-2 text-[13px] text-grey-2 outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-white/8 data-[highlighted]:text-foreground', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> |
||||
|
</SelectPrimitive.Item> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem } |
||||
@ -0,0 +1,8 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
function Separator({ className, ...props }: React.ComponentProps<'div'>) { |
||||
|
return <div className={cn('h-px w-full bg-border-soft', className)} {...props} /> |
||||
|
} |
||||
|
|
||||
|
export { Separator } |
||||
@ -0,0 +1,89 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const Sheet = DialogPrimitive.Root |
||||
|
const SheetTrigger = DialogPrimitive.Trigger |
||||
|
const SheetClose = DialogPrimitive.Close |
||||
|
const SheetPortal = DialogPrimitive.Portal |
||||
|
|
||||
|
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { |
||||
|
return ( |
||||
|
<DialogPrimitive.Overlay |
||||
|
data-slot="sheet-overlay" |
||||
|
className={cn('fixed inset-0 z-40 bg-black/45 anim-fade', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const sideClasses: Record<'right' | 'left' | 'top' | 'bottom', string> = { |
||||
|
right: 'inset-y-0 right-0 w-3/4 max-w-sm border-s anim-sheet-right', |
||||
|
left: 'inset-y-0 left-0 w-3/4 max-w-sm border-e anim-sheet-left', |
||||
|
top: 'inset-x-0 top-0 h-auto border-b anim-sheet-top', |
||||
|
bottom: 'inset-x-0 bottom-0 h-auto border-t anim-sheet-bottom', |
||||
|
} |
||||
|
|
||||
|
type SheetContentProps = React.ComponentProps<typeof DialogPrimitive.Content> & { |
||||
|
side?: 'right' | 'left' | 'top' | 'bottom' |
||||
|
overlay?: boolean |
||||
|
} |
||||
|
|
||||
|
function SheetContent({ className, children, side = 'right', overlay = true, ...props }: SheetContentProps) { |
||||
|
return ( |
||||
|
<SheetPortal> |
||||
|
{overlay && <SheetOverlay />} |
||||
|
<DialogPrimitive.Content |
||||
|
data-slot="sheet-content" |
||||
|
className={cn( |
||||
|
'fixed z-50 flex flex-col gap-4 bg-[#26272c] shadow-[-18px_0_50px_rgba(0,0,0,.45)] border-border-soft outline-none', |
||||
|
sideClasses[side], |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
<DialogPrimitive.Close className="absolute end-4 top-4 flex size-7 items-center justify-center rounded-full bg-white/7 text-grey-2 transition-colors hover:bg-white/12 [&_svg]:size-3.5"> |
||||
|
<Ic name="x" /> |
||||
|
<span className="sr-only">بستن</span> |
||||
|
</DialogPrimitive.Close> |
||||
|
</DialogPrimitive.Content> |
||||
|
</SheetPortal> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { |
||||
|
return <div data-slot="sheet-header" className={cn('flex flex-col gap-1 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 DialogPrimitive.Title>) { |
||||
|
return <DialogPrimitive.Title data-slot="sheet-title" className={cn('font-semibold', className)} {...props} /> |
||||
|
} |
||||
|
|
||||
|
function SheetDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) { |
||||
|
return ( |
||||
|
<DialogPrimitive.Description |
||||
|
data-slot="sheet-description" |
||||
|
className={cn('text-[12px] text-grey-3', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
Sheet, |
||||
|
SheetTrigger, |
||||
|
SheetClose, |
||||
|
SheetPortal, |
||||
|
SheetOverlay, |
||||
|
SheetContent, |
||||
|
SheetHeader, |
||||
|
SheetFooter, |
||||
|
SheetTitle, |
||||
|
SheetDescription, |
||||
|
} |
||||
@ -0,0 +1,476 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { cva, type VariantProps } from 'class-variance-authority' |
||||
|
import { Slot } from '@radix-ui/react-slot' |
||||
|
|
||||
|
import { Ic } from '@/icons' |
||||
|
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 = '240px' |
||||
|
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) |
||||
|
|
||||
|
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) |
||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` |
||||
|
}, |
||||
|
[setOpenProp, open], |
||||
|
) |
||||
|
|
||||
|
const toggleSidebar = React.useCallback(() => { |
||||
|
return isMobile ? setOpenMobile((o) => !o) : setOpen((o) => !o) |
||||
|
}, [isMobile, setOpen, setOpenMobile]) |
||||
|
|
||||
|
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]) |
||||
|
|
||||
|
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 flex h-[calc(100svh-var(--player-h))] w-full has-data-[variant=inset]:bg-sidebar', |
||||
|
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('flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', className)} |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
if (isMobile) { |
||||
|
return ( |
||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> |
||||
|
<SheetContent |
||||
|
data-sidebar="sidebar" |
||||
|
data-slot="sidebar" |
||||
|
data-mobile="true" |
||||
|
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" |
||||
|
style={{ '--sidebar-width': SIDEBAR_WIDTH_MOBILE } as React.CSSProperties} |
||||
|
side={side} |
||||
|
> |
||||
|
<SheetHeader className="sr-only"> |
||||
|
<SheetTitle>نویگیشن</SheetTitle> |
||||
|
<SheetDescription>منوی کناری اپلیکیشن.</SheetDescription> |
||||
|
</SheetHeader> |
||||
|
<div className="flex h-full w-full flex-col">{children}</div> |
||||
|
</SheetContent> |
||||
|
</Sheet> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
className="group peer hidden text-sidebar-foreground md:block" |
||||
|
data-state={state} |
||||
|
data-collapsible={state === 'collapsed' ? collapsible : ''} |
||||
|
data-variant={variant} |
||||
|
data-side={side} |
||||
|
data-slot="sidebar" |
||||
|
> |
||||
|
{/* spacer که فاصلهٔ سایدبار را در flow رزرو میکند */} |
||||
|
<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 top-0 z-10 hidden h-[calc(100svh-var(--player-h))] 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)]', |
||||
|
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="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-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-9 [&_svg]:size-[18px]', className)} |
||||
|
onClick={(event) => { |
||||
|
onClick?.(event) |
||||
|
toggleSidebar() |
||||
|
}} |
||||
|
{...props} |
||||
|
> |
||||
|
<Ic name="list" /> |
||||
|
<span className="sr-only">باز/بستن نویگیشن</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( |
||||
|
'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] hover:after:bg-sidebar-border sm:flex', |
||||
|
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar', |
||||
|
'[[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( |
||||
|
'relative flex w-full flex-1 flex-col bg-background min-w-0', |
||||
|
'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('h-8 w-full bg-background 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('mx-2 w-auto bg-sidebar-border', 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( |
||||
|
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring 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 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-right text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! 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 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 [&>span:last-child]:truncate [&>svg]:size-[18px] [&>svg]:shrink-0', |
||||
|
{ |
||||
|
variants: { |
||||
|
variant: { |
||||
|
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', |
||||
|
outline: |
||||
|
'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]', |
||||
|
}, |
||||
|
size: { |
||||
|
default: 'h-9 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="left" 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( |
||||
|
'absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', |
||||
|
'after:absolute after:-inset-2 md:after:hidden', |
||||
|
'group-data-[collapsible=icon]:hidden', |
||||
|
showOnHover && |
||||
|
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground 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( |
||||
|
'pointer-events-none absolute end-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground 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 }) { |
||||
|
const width = React.useMemo(() => `${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> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
Sidebar, |
||||
|
SidebarContent, |
||||
|
SidebarContext, |
||||
|
SidebarFooter, |
||||
|
SidebarGroup, |
||||
|
SidebarGroupContent, |
||||
|
SidebarGroupLabel, |
||||
|
SidebarHeader, |
||||
|
SidebarInput, |
||||
|
SidebarInset, |
||||
|
SidebarMenu, |
||||
|
SidebarMenuAction, |
||||
|
SidebarMenuBadge, |
||||
|
SidebarMenuButton, |
||||
|
SidebarMenuItem, |
||||
|
SidebarMenuSkeleton, |
||||
|
SidebarProvider, |
||||
|
SidebarRail, |
||||
|
SidebarSeparator, |
||||
|
SidebarTrigger, |
||||
|
useSidebar, |
||||
|
} |
||||
@ -0,0 +1,155 @@ |
|||||
|
import { useEffect, useMemo, useRef, useState } from 'react' |
||||
|
import { useInfiniteQuery } from '@tanstack/react-query' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
||||
|
|
||||
|
type SingleSelectProps = { |
||||
|
label?: string |
||||
|
items?: string[] |
||||
|
value: string |
||||
|
onChange: (value: string) => void |
||||
|
placeholder?: string |
||||
|
searchPlaceholder?: string |
||||
|
emptyLabel?: string |
||||
|
className?: string |
||||
|
contentClassName?: string |
||||
|
searchable?: boolean |
||||
|
queryKey?: readonly unknown[] |
||||
|
loadOptions?: (params: { query: string; page: number }) => Promise<{ items: string[]; nextPage?: number }> |
||||
|
} |
||||
|
|
||||
|
export default function SingleSelect({ |
||||
|
label, |
||||
|
items = [], |
||||
|
value, |
||||
|
onChange, |
||||
|
placeholder = 'انتخاب کنید…', |
||||
|
searchPlaceholder = 'جستجو…', |
||||
|
emptyLabel, |
||||
|
className, |
||||
|
contentClassName, |
||||
|
searchable = true, |
||||
|
queryKey, |
||||
|
loadOptions, |
||||
|
}: SingleSelectProps) { |
||||
|
const [open, setOpen] = useState(false) |
||||
|
const [query, setQuery] = useState('') |
||||
|
const searchRef = useRef<HTMLInputElement>(null) |
||||
|
const listRef = useRef<HTMLDivElement>(null) |
||||
|
|
||||
|
const infinite = useInfiniteQuery({ |
||||
|
queryKey: [...(queryKey ?? ['single-select-options']), query], |
||||
|
queryFn: ({ pageParam }) => loadOptions!({ query, page: pageParam }), |
||||
|
initialPageParam: 1, |
||||
|
getNextPageParam: (lastPage) => lastPage.nextPage, |
||||
|
enabled: open && !!loadOptions, |
||||
|
}) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (open && searchable) setTimeout(() => searchRef.current?.focus(), 50) |
||||
|
}, [open, searchable]) |
||||
|
|
||||
|
const setPopoverOpen = (nextOpen: boolean) => { |
||||
|
if (!nextOpen) setQuery('') |
||||
|
setOpen(nextOpen) |
||||
|
} |
||||
|
|
||||
|
const options = useMemo(() => { |
||||
|
if (loadOptions) return infinite.data?.pages.flatMap((page) => page.items) ?? [] |
||||
|
return query ? items.filter((item) => item.includes(query)) : items |
||||
|
}, [items, query, loadOptions, infinite.data]) |
||||
|
|
||||
|
const renderedOptions = emptyLabel ? [emptyLabel, ...options] : options |
||||
|
const selectedLabel = value || '' |
||||
|
|
||||
|
const select = (item: string) => { |
||||
|
onChange(item === emptyLabel ? '' : item) |
||||
|
setOpen(false) |
||||
|
} |
||||
|
|
||||
|
const onListScroll = () => { |
||||
|
const list = listRef.current |
||||
|
if (!list || !loadOptions || !infinite.hasNextPage || infinite.isFetchingNextPage) return |
||||
|
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 24) infinite.fetchNextPage() |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className={cn(className)}> |
||||
|
{label && <p className="mb-1.5 text-[12px] font-semibold text-grey-2">{label}</p>} |
||||
|
|
||||
|
<Popover open={open} onOpenChange={setPopoverOpen}> |
||||
|
<PopoverTrigger asChild> |
||||
|
<button |
||||
|
type="button" |
||||
|
className={cn( |
||||
|
'flex h-11 w-full items-center gap-2 rounded-md border border-border-soft bg-input px-3.5 text-right text-[13.5px] outline-none transition-colors hover:bg-white/9', |
||||
|
open && 'border-red-mid/50', |
||||
|
)} |
||||
|
> |
||||
|
<span className={cn('min-w-0 flex-1 truncate', selectedLabel ? 'text-foreground' : 'text-grey-4')}> |
||||
|
{selectedLabel || placeholder} |
||||
|
</span> |
||||
|
<Ic name="chevDown" className="size-4 shrink-0 opacity-60" /> |
||||
|
</button> |
||||
|
</PopoverTrigger> |
||||
|
|
||||
|
<PopoverContent |
||||
|
align="start" |
||||
|
sideOffset={6} |
||||
|
className={cn( |
||||
|
'z-[100] max-h-56 w-[var(--radix-popover-trigger-width)] overflow-y-hidden rounded-lg border border-border-soft bg-[#27282e] p-0 shadow-[0_16px_48px_rgba(0,0,0,.5)]', |
||||
|
contentClassName, |
||||
|
)} |
||||
|
> |
||||
|
{searchable && ( |
||||
|
<div className="border-b border-border-soft p-2"> |
||||
|
<input |
||||
|
ref={searchRef} |
||||
|
type="text" |
||||
|
value={query} |
||||
|
onChange={(e) => setQuery(e.target.value)} |
||||
|
placeholder={searchPlaceholder} |
||||
|
className="w-full rounded-md border border-border-soft bg-white/5 px-3 py-1.5 text-[12.5px] text-grey-2 outline-none transition-colors placeholder:text-grey-4 focus:border-red-mid/50" |
||||
|
/> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<div ref={listRef} onScroll={onListScroll} className="max-h-48 overflow-y-auto p-1.5"> |
||||
|
{infinite.isLoading ? ( |
||||
|
<p className="px-3 py-4 text-center text-[12px] text-grey-4">در حال بارگذاری…</p> |
||||
|
) : renderedOptions.length === 0 ? ( |
||||
|
<p className="px-3 py-4 text-center text-[12px] text-grey-4">نتیجهای یافت نشد</p> |
||||
|
) : ( |
||||
|
renderedOptions.map((item) => { |
||||
|
const active = item === selectedLabel || (item === emptyLabel && !selectedLabel) |
||||
|
return ( |
||||
|
<button |
||||
|
key={item} |
||||
|
type="button" |
||||
|
onClick={() => select(item)} |
||||
|
className={cn( |
||||
|
'flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-[13px] transition-colors hover:bg-white/8', |
||||
|
active ? 'text-foreground' : 'text-grey-2', |
||||
|
)} |
||||
|
> |
||||
|
<span className="flex size-4 shrink-0 items-center justify-center text-red-mid"> |
||||
|
{active && <Ic name="check" className="size-3.5" />} |
||||
|
</span> |
||||
|
{item} |
||||
|
</button> |
||||
|
) |
||||
|
}) |
||||
|
)} |
||||
|
{infinite.isFetchingNextPage && ( |
||||
|
<div className="flex items-center justify-center gap-2 px-3 py-2 text-[11.5px] text-grey-3"> |
||||
|
<span className="size-3 animate-spin rounded-full border border-white/20 border-t-red-mid" /> |
||||
|
بارگذاری موارد بیشتر… |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
</PopoverContent> |
||||
|
</Popover> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
function Skeleton({ className, ...props }) { |
||||
|
return ( |
||||
|
<div |
||||
|
data-slot="skeleton" |
||||
|
className={cn('animate-pulse rounded-md bg-white/8', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Skeleton } |
||||
@ -0,0 +1,19 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as SliderPrimitive from '@radix-ui/react-slider' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
function Slider({ className, ...props }: React.ComponentProps<typeof SliderPrimitive.Root>) { |
||||
|
return ( |
||||
|
<SliderPrimitive.Root |
||||
|
className={cn('relative flex w-full touch-none select-none items-center cursor-pointer', className)} |
||||
|
{...props} |
||||
|
> |
||||
|
<SliderPrimitive.Track className="relative h-[5px] w-full grow overflow-hidden rounded-full bg-white/12"> |
||||
|
<SliderPrimitive.Range className="absolute h-full bg-g-progress" /> |
||||
|
</SliderPrimitive.Track> |
||||
|
<SliderPrimitive.Thumb className="block size-3.5 rounded-full bg-white shadow-[0_2px_8px_rgba(0,0,0,.5)] outline-none focus-visible:ring-2 focus-visible:ring-ring" /> |
||||
|
</SliderPrimitive.Root> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Slider } |
||||
@ -0,0 +1,19 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as SwitchPrimitive from '@radix-ui/react-switch' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) { |
||||
|
return ( |
||||
|
<SwitchPrimitive.Root |
||||
|
className={cn( |
||||
|
'peer inline-flex h-[22px] w-[40px] shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors data-[state=checked]:bg-[#2ecc71] data-[state=unchecked]:bg-white/15 outline-none focus-visible:ring-2 focus-visible:ring-ring', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
<SwitchPrimitive.Thumb className="pointer-events-none block size-[17px] rounded-full bg-white shadow transition-transform data-[state=checked]:-translate-x-[19px] data-[state=unchecked]:-translate-x-[2px]" /> |
||||
|
</SwitchPrimitive.Root> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Switch } |
||||
@ -0,0 +1,33 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
function Table({ className, ...props }: React.ComponentProps<'table'>) { |
||||
|
return <table className={cn('w-full caption-bottom text-[13px]', className)} {...props} /> |
||||
|
} |
||||
|
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { |
||||
|
return <thead className={cn('[&_tr]:border-b [&_tr]:border-border-soft', className)} {...props} /> |
||||
|
} |
||||
|
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { |
||||
|
return <tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} /> |
||||
|
} |
||||
|
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { |
||||
|
return ( |
||||
|
<tr |
||||
|
className={cn('border-b border-border-soft transition-colors hover:bg-white/[.03]', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
function TableHead({ className, ...props }: React.ComponentProps<'th'>) { |
||||
|
return ( |
||||
|
<th |
||||
|
className={cn('h-12 px-4 text-right align-middle text-[12px] font-semibold text-grey-3', className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
function TableCell({ className, ...props }: React.ComponentProps<'td'>) { |
||||
|
return <td className={cn('px-4 py-3.5 align-middle text-grey-2', className)} {...props} /> |
||||
|
} |
||||
|
|
||||
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } |
||||
@ -0,0 +1,38 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
const Tabs = TabsPrimitive.Root |
||||
|
|
||||
|
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) { |
||||
|
return ( |
||||
|
<TabsPrimitive.List |
||||
|
className={cn( |
||||
|
'inline-flex h-10 items-center justify-center rounded-lg border border-border-soft bg-white/5 p-1 text-grey-3', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) { |
||||
|
return ( |
||||
|
<TabsPrimitive.Trigger |
||||
|
className={cn( |
||||
|
'inline-flex h-8 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[12px] font-semibold text-grey-3 transition-colors', |
||||
|
'hover:bg-white/8 hover:text-grey-1', |
||||
|
'data-[state=active]:bg-red-mid data-[state=active]:text-white data-[state=active]:shadow-[0_6px_18px_rgba(209,39,39,.25)]', |
||||
|
'disabled:pointer-events-none disabled:opacity-50', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) { |
||||
|
return <TabsPrimitive.Content className={cn('outline-none', className)} {...props} /> |
||||
|
} |
||||
|
|
||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent } |
||||
@ -0,0 +1,16 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) { |
||||
|
return ( |
||||
|
<textarea |
||||
|
className={cn( |
||||
|
'w-full min-h-[72px] rounded-md bg-input border border-border-soft px-3.5 py-2.5 text-[13.5px] text-foreground placeholder:text-grey-4 outline-none transition-colors focus:border-ring resize-y', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Textarea } |
||||
@ -0,0 +1,34 @@ |
|||||
|
import * as React from 'react' |
||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) { |
||||
|
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} /> |
||||
|
} |
||||
|
|
||||
|
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) { |
||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> |
||||
|
} |
||||
|
|
||||
|
const TooltipTrigger = TooltipPrimitive.Trigger |
||||
|
|
||||
|
function TooltipContent({ className, sideOffset = 4, children, ...props }: React.ComponentProps<typeof TooltipPrimitive.Content>) { |
||||
|
return ( |
||||
|
<TooltipPrimitive.Portal> |
||||
|
<TooltipPrimitive.Content |
||||
|
data-slot="tooltip-content" |
||||
|
sideOffset={sideOffset} |
||||
|
className={cn( |
||||
|
'z-50 anim-pop rounded-md bg-popover px-2.5 py-1.5 text-[11.5px] text-popover-foreground shadow-md', |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
{children} |
||||
|
<TooltipPrimitive.Arrow className="fill-popover" /> |
||||
|
</TooltipPrimitive.Content> |
||||
|
</TooltipPrimitive.Portal> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } |
||||
@ -0,0 +1,33 @@ |
|||||
|
import type { IconName } from '@/icons' |
||||
|
|
||||
|
export interface NavItemConfig { |
||||
|
title: string |
||||
|
to?: string |
||||
|
icon: IconName |
||||
|
badge?: string |
||||
|
soon?: boolean |
||||
|
} |
||||
|
|
||||
|
export interface NavGroupConfig { |
||||
|
title: string |
||||
|
items: NavItemConfig[] |
||||
|
} |
||||
|
|
||||
|
export const navigationConfig: NavGroupConfig[] = [ |
||||
|
{ |
||||
|
title: 'اصلی', |
||||
|
items: [ |
||||
|
{ title: 'داشبورد', to: '/', icon: 'home' }, |
||||
|
{ title: 'مدیریت کاربران', to: '/users', icon: 'users', badge: 'CRUD' }, |
||||
|
{ title: 'آیتمهای نمونه', to: '/items', icon: 'grid' }, |
||||
|
], |
||||
|
}, |
||||
|
{ |
||||
|
title: 'دیزاینسیستم و ابزارها', |
||||
|
items: [ |
||||
|
{ title: 'ویترین کامپوننتها (UI)', to: '/components', icon: 'sparkles', badge: 'کاتالوگ' }, |
||||
|
{ title: 'تنظیمات سیستم', to: '/settings', icon: 'settings' }, |
||||
|
{ title: 'آمار و گزارشها', to: '/reports', icon: 'chart', soon: true }, |
||||
|
], |
||||
|
}, |
||||
|
] |
||||
@ -0,0 +1,17 @@ |
|||||
|
export interface SiteConfig { |
||||
|
name: string |
||||
|
shortName: string |
||||
|
description: string |
||||
|
version: string |
||||
|
logoText: string |
||||
|
defaultTheme: 'dark' | 'light' |
||||
|
} |
||||
|
|
||||
|
export const siteConfig: SiteConfig = { |
||||
|
name: 'پنل مدیریت', |
||||
|
shortName: 'داشبورد', |
||||
|
description: 'قالب پیشرفته و مدرن داشبورد مدیریتی با React 19، Tailwind CSS v4 و Shadcn UI', |
||||
|
version: '1.0.0', |
||||
|
logoText: 'DASHBOARD', |
||||
|
defaultTheme: 'dark', |
||||
|
} |
||||
@ -0,0 +1,154 @@ |
|||||
|
import { useState } from 'react' |
||||
|
import { Navigate, useLocation } from 'react-router-dom' |
||||
|
import type { Location } from 'react-router-dom' |
||||
|
import { Ic, Pattern } from '@/icons' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { useAuth, useLoginMutation } from '@/features/auth/use-auth' |
||||
|
import { ApiError } from '@/services/http' |
||||
|
import { siteConfig } from '@/config/site' |
||||
|
|
||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) { |
||||
|
return ( |
||||
|
<div className="space-y-1.5"> |
||||
|
<p className="text-xs font-semibold text-grey-2">{label}</p> |
||||
|
{children} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default function LoginPage() { |
||||
|
const location = useLocation() |
||||
|
const { isAuthenticated } = useAuth() |
||||
|
const loginMutation = useLoginMutation() |
||||
|
|
||||
|
const [email, setEmail] = useState('') |
||||
|
const [password, setPassword] = useState('') |
||||
|
const [showPassword, setShowPassword] = useState(false) |
||||
|
|
||||
|
const from = (location.state as { from?: Location } | null)?.from?.pathname ?? '/' |
||||
|
|
||||
|
if (isAuthenticated) { |
||||
|
return <Navigate to={from} replace /> |
||||
|
} |
||||
|
|
||||
|
const valid = email.trim().length > 0 && password.length > 0 |
||||
|
|
||||
|
const submit = (e: React.FormEvent) => { |
||||
|
e.preventDefault() |
||||
|
if (!valid || loginMutation.isPending) return |
||||
|
loginMutation.mutate({ email: email.trim(), password }) |
||||
|
} |
||||
|
|
||||
|
const fillDemo = () => { |
||||
|
setEmail('admin@example.com') |
||||
|
setPassword('admin') |
||||
|
} |
||||
|
|
||||
|
const errorMessage = |
||||
|
loginMutation.error instanceof ApiError |
||||
|
? loginMutation.error.message |
||||
|
: loginMutation.error |
||||
|
? 'ورود ناموفق بود. دوباره تلاش کنید.' |
||||
|
: null |
||||
|
|
||||
|
return ( |
||||
|
<div className="relative flex min-h-svh items-center justify-center overflow-hidden bg-background px-4"> |
||||
|
<div className="pointer-events-none absolute inset-0 opacity-[0.04]"> |
||||
|
<Pattern /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="relative w-full max-w-[420px]"> |
||||
|
{/* لوگو و نام برند */} |
||||
|
<div className="mb-6 flex flex-col items-center gap-2.5 text-center"> |
||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-white shadow-lg"> |
||||
|
<Ic name="sparkles" className="size-8" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<h1 className="text-xl font-black text-foreground">{siteConfig.name}</h1> |
||||
|
<p className="text-xs text-grey-3 mt-1">ورود به پنل مدیریت و داشبورد</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* کارت لاگین */} |
||||
|
<form |
||||
|
onSubmit={submit} |
||||
|
className="space-y-4 rounded-2xl border border-border-soft bg-card/60 p-6 shadow-2xl backdrop-blur" |
||||
|
> |
||||
|
{/* راهنمای لاگین دمو */} |
||||
|
<div className="flex items-center justify-between rounded-xl border border-primary/20 bg-primary/10 p-3 text-xs"> |
||||
|
<div> |
||||
|
<span className="font-bold text-primary block">حساب آزمایشی آماده:</span> |
||||
|
<span dir="ltr" className="text-[11px] text-grey-2 font-mono">admin@example.com / admin</span> |
||||
|
</div> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={fillDemo} |
||||
|
className="h-7 text-[11px]" |
||||
|
> |
||||
|
پر کردن خودکار |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<Field label="ایمیل حساب کاربری"> |
||||
|
<Input |
||||
|
type="email" |
||||
|
dir="ltr" |
||||
|
className="text-left" |
||||
|
autoComplete="username" |
||||
|
placeholder="you@example.com" |
||||
|
value={email} |
||||
|
onChange={(e) => setEmail(e.target.value)} |
||||
|
autoFocus |
||||
|
/> |
||||
|
</Field> |
||||
|
|
||||
|
<Field label="رمز عبور"> |
||||
|
<div className="relative"> |
||||
|
<Input |
||||
|
type={showPassword ? 'text' : 'password'} |
||||
|
dir="ltr" |
||||
|
className="text-left pe-11" |
||||
|
autoComplete="current-password" |
||||
|
placeholder="••••••••" |
||||
|
value={password} |
||||
|
onChange={(e) => setPassword(e.target.value)} |
||||
|
/> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setShowPassword((v) => !v)} |
||||
|
className="absolute inset-y-0 start-0 flex w-11 items-center justify-center text-grey-3 transition-colors hover:text-grey-1 [&_svg]:size-4" |
||||
|
aria-label={showPassword ? 'مخفیکردن رمز' : 'نمایش رمز'} |
||||
|
tabIndex={-1} |
||||
|
> |
||||
|
<Ic name={showPassword ? 'eyeOff' : 'eye'} /> |
||||
|
</button> |
||||
|
</div> |
||||
|
</Field> |
||||
|
|
||||
|
{errorMessage && ( |
||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-rose-400"> |
||||
|
<Ic name="alert" className="size-4 shrink-0 mt-0.5" /> |
||||
|
<span>{errorMessage}</span> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<Button |
||||
|
type="submit" |
||||
|
size="lg" |
||||
|
className="w-full" |
||||
|
disabled={!valid || loginMutation.isPending} |
||||
|
> |
||||
|
{loginMutation.isPending ? 'در حال ورود…' : 'ورود به حساب کاربری'} |
||||
|
</Button> |
||||
|
</form> |
||||
|
|
||||
|
<p className="mt-4 text-center text-xs text-grey-4"> |
||||
|
پشتیبانی از قالبهای سفارشی و اتصال مستقیم به API |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
import { Navigate, useLocation } from 'react-router-dom' |
||||
|
import { useAuth } from '@/features/auth/use-auth' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* گاردِ روتر — تا وقتی کاربر وارد نشده، هیچ محتوای محافظتشدهای رندر نمیشود و |
||||
|
* مسیر فعلی به /login هدایت میشود. مسیر مقصد در state.from نگهداری میشود تا |
||||
|
* پس از لاگین موفق به همانجا بازگردیم. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
export default function RequireAuth({ children }: { children: React.ReactNode }) { |
||||
|
const { isAuthenticated } = useAuth() |
||||
|
const location = useLocation() |
||||
|
|
||||
|
if (!isAuthenticated) { |
||||
|
return <Navigate to="/login" replace state={{ from: location }} /> |
||||
|
} |
||||
|
|
||||
|
return <>{children}</> |
||||
|
} |
||||
@ -0,0 +1,40 @@ |
|||||
|
import { useCallback } from 'react' |
||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query' |
||||
|
import { login } from '@/services/auth' |
||||
|
import { useAuthStore } from '@/stores/auth' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* هوکهای احراز هویت — پل بین React Query، استور Zustand و کامپوننتها. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
/** وضعیت احراز هویت + کاربر فعلی (از استور persist). */ |
||||
|
export function useAuth() { |
||||
|
const user = useAuthStore((s) => s.user) |
||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated) |
||||
|
return { user, isAuthenticated } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* mutation لاگین: روی موفقیت، توکن + کاربر را در استور (و localStorage) ذخیره |
||||
|
* میکند. خطا را به فراخواننده میسپارد تا فرم پیام بکاند را نمایش دهد. |
||||
|
*/ |
||||
|
export function useLoginMutation() { |
||||
|
const setAuth = useAuthStore((s) => s.setAuth) |
||||
|
return useMutation({ |
||||
|
mutationFn: login, |
||||
|
onSuccess: ({ token, user }) => setAuth(token, user), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* خروج: استور را پاک میکند و کل کش React Query را خالی میکند تا هیچ دادهٔ |
||||
|
* کاربر قبلی باقی نماند. |
||||
|
*/ |
||||
|
export function useLogout() { |
||||
|
const clearAuth = useAuthStore((s) => s.clearAuth) |
||||
|
const qc = useQueryClient() |
||||
|
return useCallback(() => { |
||||
|
clearAuth() |
||||
|
qc.clear() |
||||
|
}, [clearAuth, qc]) |
||||
|
} |
||||
@ -0,0 +1,208 @@ |
|||||
|
import { Ic } from '@/icons' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Badge } from '@/components/ui/badge' |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
import { useNavigate } from 'react-router-dom' |
||||
|
|
||||
|
export default function DashboardHome() { |
||||
|
const { openModal } = usePanelRouting() |
||||
|
const navigate = useNavigate() |
||||
|
|
||||
|
const stats = [ |
||||
|
{ |
||||
|
title: 'کل کاربران', |
||||
|
value: '۱,۴۸۲', |
||||
|
change: '+۱۲٪', |
||||
|
trend: 'up', |
||||
|
desc: 'نسبت به ماه گذشته', |
||||
|
icon: 'users' as const, |
||||
|
}, |
||||
|
{ |
||||
|
title: 'تراکنشهای موفق', |
||||
|
value: '۸,۲۴۰,۰۰۰ تومان', |
||||
|
change: '+۲۳.۵٪', |
||||
|
trend: 'up', |
||||
|
desc: 'فروش خالص این ماه', |
||||
|
icon: 'sparkles' as const, |
||||
|
}, |
||||
|
{ |
||||
|
title: 'آیتمهای فعال', |
||||
|
value: '۱۵۴', |
||||
|
change: '+۵ مورد', |
||||
|
trend: 'up', |
||||
|
desc: 'در حال ارائه و سرویس', |
||||
|
icon: 'grid' as const, |
||||
|
}, |
||||
|
{ |
||||
|
title: 'وضعیت سرورها', |
||||
|
value: '۹۹.۹٪', |
||||
|
change: 'پایدار', |
||||
|
trend: 'neutral', |
||||
|
desc: 'پاسخگویی به موقع', |
||||
|
icon: 'globe' as const, |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
const recentActivities = [ |
||||
|
{ |
||||
|
id: '1', |
||||
|
user: 'علیرضا محمدی', |
||||
|
action: 'ورود به پنل مدیریت', |
||||
|
date: '۱۰ دقیقه پیش', |
||||
|
status: 'success', |
||||
|
}, |
||||
|
{ |
||||
|
id: '2', |
||||
|
user: 'سارا رضایی', |
||||
|
action: 'ویرایش آیتم "درگاه پرداخت سپهر"', |
||||
|
date: '۴۵ دقیقه پیش', |
||||
|
status: 'info', |
||||
|
}, |
||||
|
{ |
||||
|
id: '3', |
||||
|
user: 'محمد حسینی', |
||||
|
action: 'ثبت کاربر جدید در سیستم', |
||||
|
date: '۲ ساعت پیش', |
||||
|
status: 'success', |
||||
|
}, |
||||
|
{ |
||||
|
id: '4', |
||||
|
user: 'مریم کمالی', |
||||
|
action: 'خروج از حساب کاربری', |
||||
|
date: 'دیروز ۱۸:۳۰', |
||||
|
status: 'neutral', |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-6 pt-2"> |
||||
|
{/* هدر صفحهٔ خوشآمدگویی */} |
||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
||||
|
<div> |
||||
|
<h1 className="text-2xl font-black text-foreground">داشبورد مدیریت</h1> |
||||
|
<p className="mt-1 text-xs text-grey-3"> |
||||
|
به پنل مدیریت خوش آمدید. خلاصه وضعیت سیستم و گزارشهای سریع را در این صفحه مشاهده نمایید. |
||||
|
</p> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Button onClick={() => openModal('user', 'new')} className="gap-2"> |
||||
|
<Ic name="plus" className="size-4" /> |
||||
|
کاربر جدید |
||||
|
</Button> |
||||
|
<Button variant="outline" onClick={() => openModal('item', 'new')} className="gap-2"> |
||||
|
<Ic name="plus" className="size-4" /> |
||||
|
آیتم جدید |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* کارتهای آماری بالا */} |
||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> |
||||
|
{stats.map((stat) => ( |
||||
|
<div |
||||
|
key={stat.title} |
||||
|
className="rounded-2xl border border-border-soft bg-card/60 p-5 transition-all duration-200 hover:border-primary/30" |
||||
|
> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs font-semibold text-grey-3">{stat.title}</span> |
||||
|
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary"> |
||||
|
<Ic name={stat.icon} className="size-5" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-3 text-2xl font-black text-foreground">{stat.value}</div> |
||||
|
<div className="mt-2 flex items-center gap-2 text-[11.5px]"> |
||||
|
<span className="font-semibold text-emerald-400">{stat.change}</span> |
||||
|
<span className="text-grey-4">{stat.desc}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
|
||||
|
{/* بخش پایینی: جدول فعالیتهای اخیر + کارتهای دسترسی سریع */} |
||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> |
||||
|
{/* جدول فعالیتهای اخیر */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/50 p-5 lg:col-span-2"> |
||||
|
<div className="flex items-center justify-between pb-4 border-b border-border-soft"> |
||||
|
<h2 className="text-sm font-bold text-foreground">آخرین رویدادهای سیستم</h2> |
||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/users')} className="text-xs text-grey-3"> |
||||
|
مشاهده همه |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<div className="divide-y divide-border-soft"> |
||||
|
{recentActivities.map((act) => ( |
||||
|
<div key={act.id} className="flex items-center justify-between py-3.5"> |
||||
|
<div className="flex items-center gap-3"> |
||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-white/6 text-xs font-bold text-grey-2"> |
||||
|
{act.user.charAt(0)} |
||||
|
</div> |
||||
|
<div> |
||||
|
<div className="text-xs font-semibold text-foreground">{act.user}</div> |
||||
|
<div className="text-[11px] text-grey-3 mt-0.5">{act.action}</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-3"> |
||||
|
<span className="text-[11px] text-grey-4">{act.date}</span> |
||||
|
<Badge variant="outline" className="text-[10px] text-emerald-400 border-emerald-500/20 bg-emerald-500/10"> |
||||
|
موفق |
||||
|
</Badge> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* دسترسیهای سریع */} |
||||
|
<div className="space-y-4 rounded-2xl border border-border-soft bg-card/50 p-5"> |
||||
|
<h2 className="text-sm font-bold text-foreground pb-2 border-b border-border-soft"> |
||||
|
دسترسیهای سریع |
||||
|
</h2> |
||||
|
<div className="space-y-2"> |
||||
|
<button |
||||
|
onClick={() => navigate('/users')} |
||||
|
className="flex w-full items-center justify-between rounded-xl bg-white/4 p-3 text-start transition-colors hover:bg-white/8" |
||||
|
> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<Ic name="users" className="size-4 text-primary" /> |
||||
|
<span className="text-xs font-medium text-foreground">مدیریت کاربران</span> |
||||
|
</div> |
||||
|
<Ic name="chevronLeft" className="size-4 text-grey-4" /> |
||||
|
</button> |
||||
|
|
||||
|
<button |
||||
|
onClick={() => navigate('/items')} |
||||
|
className="flex w-full items-center justify-between rounded-xl bg-white/4 p-3 text-start transition-colors hover:bg-white/8" |
||||
|
> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<Ic name="grid" className="size-4 text-primary" /> |
||||
|
<span className="text-xs font-medium text-foreground">آیتمهای نمونه (CRUD)</span> |
||||
|
</div> |
||||
|
<Ic name="chevronLeft" className="size-4 text-grey-4" /> |
||||
|
</button> |
||||
|
|
||||
|
<button |
||||
|
onClick={() => navigate('/settings')} |
||||
|
className="flex w-full items-center justify-between rounded-xl bg-white/4 p-3 text-start transition-colors hover:bg-white/8" |
||||
|
> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<Ic name="settings" className="size-4 text-primary" /> |
||||
|
<span className="text-xs font-medium text-foreground">تنظیمات سیستم</span> |
||||
|
</div> |
||||
|
<Ic name="chevronLeft" className="size-4 text-grey-4" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-xl border border-primary/20 bg-primary/5 p-3.5 text-xs"> |
||||
|
<div className="flex items-center gap-1.5 font-bold text-primary mb-1"> |
||||
|
<Ic name="sparkles" className="size-4" /> |
||||
|
راهنمای قالب |
||||
|
</div> |
||||
|
<p className="text-[11.5px] leading-relaxed text-grey-3"> |
||||
|
این قالب با قابلیت باز شدن پنل جزئیات (Drawer) از چپ و مدیریت انواع مودالها طراحی شده است. |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,86 @@ |
|||||
|
import { useState } from 'react' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Checkbox } from '@/components/ui/checkbox' |
||||
|
import { |
||||
|
Accordion, AccordionItem, AccordionTrigger, AccordionContent, |
||||
|
} from '@/components/ui/accordion' |
||||
|
import PanelHeader from '@/components/shared/PanelHeader' |
||||
|
import { useFiltersStore } from '@/stores/filters' |
||||
|
|
||||
|
export default function FilterDrawer({ onClose }: { onClose: () => void }) { |
||||
|
const currentStatus = useFiltersStore((s) => s.status) |
||||
|
const setStatus = useFiltersStore((s) => s.setStatus) |
||||
|
const resetFilters = useFiltersStore((s) => s.reset) |
||||
|
|
||||
|
const [draftStatus, setDraftStatus] = useState<string[]>(currentStatus) |
||||
|
|
||||
|
const toggleStatus = (val: string) => { |
||||
|
setDraftStatus((prev) => |
||||
|
prev.includes(val) ? prev.filter((s) => s !== val) : [...prev, val], |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const onApply = () => { |
||||
|
setStatus(draftStatus) |
||||
|
onClose() |
||||
|
} |
||||
|
|
||||
|
const clearAll = () => { |
||||
|
setDraftStatus([]) |
||||
|
resetFilters() |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex h-full flex-col"> |
||||
|
<PanelHeader |
||||
|
className="py-3.5" |
||||
|
title={<span className="text-[15px] font-extrabold">فیلترهای پیشرفته</span>} |
||||
|
action={ |
||||
|
<button |
||||
|
onClick={clearAll} |
||||
|
className="flex cursor-pointer items-center gap-1 text-[11.5px] text-grey-3 hover:text-foreground [&_svg]:size-3" |
||||
|
> |
||||
|
<Ic name="x" /> پاککردن همه |
||||
|
</button> |
||||
|
} |
||||
|
onClose={onClose} |
||||
|
/> |
||||
|
|
||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-2"> |
||||
|
<Accordion type="single" collapsible defaultValue="status"> |
||||
|
<AccordionItem value="status"> |
||||
|
<AccordionTrigger className="text-sm font-semibold">وضعیت</AccordionTrigger> |
||||
|
<AccordionContent className="space-y-1"> |
||||
|
{[ |
||||
|
{ label: 'فعال / منتشر شده', value: 'active' }, |
||||
|
{ label: 'پیشنویس / در انتظار', value: 'pending' }, |
||||
|
{ label: 'غیرفعال / بایگانی', value: 'inactive' }, |
||||
|
].map((item) => ( |
||||
|
<label |
||||
|
key={item.value} |
||||
|
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 transition-colors hover:bg-white/4" |
||||
|
> |
||||
|
<Checkbox |
||||
|
checked={draftStatus.includes(item.value)} |
||||
|
onCheckedChange={() => toggleStatus(item.value)} |
||||
|
/> |
||||
|
<span className="text-[13px]">{item.label}</span> |
||||
|
</label> |
||||
|
))} |
||||
|
</AccordionContent> |
||||
|
</AccordionItem> |
||||
|
</Accordion> |
||||
|
</div> |
||||
|
|
||||
|
<div className="border-t border-border-soft p-4 flex gap-2"> |
||||
|
<Button className="flex-1" onClick={onApply}> |
||||
|
اعمال فیلترها |
||||
|
</Button> |
||||
|
<Button variant="outline" onClick={onClose}> |
||||
|
انصراف |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,103 @@ |
|||||
|
import { useEffect } from 'react' |
||||
|
import { useNavigate } from 'react-router-dom' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { |
||||
|
CommandDialog, |
||||
|
CommandInput, |
||||
|
CommandList, |
||||
|
CommandEmpty, |
||||
|
CommandGroup, |
||||
|
CommandItem, |
||||
|
CommandSeparator, |
||||
|
} from '@/components/ui/command' |
||||
|
import { useGlobalSearchStore } from '@/stores/global-search' |
||||
|
import { navigationConfig } from '@/config/navigation' |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
import { useLogout } from '@/features/auth/use-auth' |
||||
|
|
||||
|
export default function GlobalSearchDialog() { |
||||
|
const { open, setOpen } = useGlobalSearchStore() |
||||
|
const navigate = useNavigate() |
||||
|
const { openModal } = usePanelRouting() |
||||
|
const logout = useLogout() |
||||
|
|
||||
|
// میانبر صفحهکلید Cmd+K یا Ctrl+K
|
||||
|
useEffect(() => { |
||||
|
const down = (e: KeyboardEvent) => { |
||||
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { |
||||
|
e.preventDefault() |
||||
|
setOpen(!open) |
||||
|
} |
||||
|
} |
||||
|
document.addEventListener('keydown', down) |
||||
|
return () => document.removeEventListener('keydown', down) |
||||
|
}, [open, setOpen]) |
||||
|
|
||||
|
const runAction = (fn: () => void) => { |
||||
|
setOpen(false) |
||||
|
fn() |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<CommandDialog open={open} onOpenChange={setOpen}> |
||||
|
<CommandInput placeholder="جستجوی صفحات، عملیاتها یا فرمانها... (Ctrl+K)" /> |
||||
|
<CommandList className="max-h-[350px] overflow-y-auto p-2"> |
||||
|
<CommandEmpty className="py-6 text-center text-xs text-grey-3"> |
||||
|
هیچ نتیجهای یافت نشد. |
||||
|
</CommandEmpty> |
||||
|
|
||||
|
{/* گروههای نویگیشن */} |
||||
|
{navigationConfig.map((group) => ( |
||||
|
<CommandGroup key={group.title} heading={group.title}> |
||||
|
{group.items.map((item) => ( |
||||
|
<CommandItem |
||||
|
key={item.title} |
||||
|
onSelect={() => { |
||||
|
if (item.to) { |
||||
|
runAction(() => navigate(item.to!)) |
||||
|
} |
||||
|
}} |
||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs" |
||||
|
> |
||||
|
<Ic name={item.icon} className="size-4 text-grey-3" /> |
||||
|
<span>{item.title}</span> |
||||
|
{item.badge && ( |
||||
|
<span className="ms-auto text-[10px] text-primary">{item.badge}</span> |
||||
|
)} |
||||
|
</CommandItem> |
||||
|
))} |
||||
|
</CommandGroup> |
||||
|
))} |
||||
|
|
||||
|
<CommandSeparator className="my-2" /> |
||||
|
|
||||
|
{/* اکشنهای سریع */} |
||||
|
<CommandGroup heading="عملیات سریع"> |
||||
|
<CommandItem |
||||
|
onSelect={() => runAction(() => openModal('user', 'new'))} |
||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs" |
||||
|
> |
||||
|
<Ic name="plus" className="size-4 text-primary" /> |
||||
|
<span>افزودن کاربر جدید</span> |
||||
|
</CommandItem> |
||||
|
|
||||
|
<CommandItem |
||||
|
onSelect={() => runAction(() => openModal('item', 'new'))} |
||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs" |
||||
|
> |
||||
|
<Ic name="plus" className="size-4 text-primary" /> |
||||
|
<span>افزودن آیتم جدید</span> |
||||
|
</CommandItem> |
||||
|
|
||||
|
<CommandItem |
||||
|
onSelect={() => runAction(() => logout())} |
||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-rose-400" |
||||
|
> |
||||
|
<Ic name="logout" className="size-4 text-rose-400" /> |
||||
|
<span>خروج از حساب کاربری</span> |
||||
|
</CommandItem> |
||||
|
</CommandGroup> |
||||
|
</CommandList> |
||||
|
</CommandDialog> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
import { Ic } from '@/icons' |
||||
|
import { useGlobalSearchStore } from '@/stores/global-search' |
||||
|
|
||||
|
const isMac = |
||||
|
typeof navigator !== 'undefined' && |
||||
|
/mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent) |
||||
|
|
||||
|
export default function GlobalSearchTrigger() { |
||||
|
const setOpen = useGlobalSearchStore((s) => s.setOpen) |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
{/* دسکتاپ: پیلِ شبیه اینپوت با میانبر صفحهکلید */} |
||||
|
<div className="hidden h-10 w-[300px] items-center gap-1.5 rounded-full border border-border-soft bg-white/5 ps-4 pe-2 transition-colors focus-within:border-ring hover:bg-white/8 md:flex"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setOpen(true)} |
||||
|
aria-label="باز کردن جستجو" |
||||
|
className="flex min-w-0 flex-1 cursor-text items-center gap-2.5 bg-transparent text-right outline-none [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-grey-3" |
||||
|
> |
||||
|
<Ic name="search" /> |
||||
|
<span className="min-w-0 flex-1 truncate text-xs text-grey-3"> |
||||
|
جستجو یا اجرای فرمان... |
||||
|
</span> |
||||
|
<kbd className="flex shrink-0 items-center gap-0.5 rounded-md border border-border-soft bg-white/5 px-1.5 py-0.5 text-[10px] font-sans text-grey-3"> |
||||
|
{isMac ? '⌘' : 'Ctrl'} K |
||||
|
</kbd> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
{/* موبایل: دکمهٔ آیکونی کوچک */} |
||||
|
<div className="flex items-center gap-1.5 md:hidden"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setOpen(true)} |
||||
|
aria-label="باز کردن جستجو" |
||||
|
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border-soft bg-white/5 text-grey-3 transition-colors hover:bg-white/8 [&_svg]:size-4" |
||||
|
> |
||||
|
<Ic name="search" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
</> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,217 @@ |
|||||
|
import { useState } from 'react' |
||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' |
||||
|
import { getSampleItems, deleteSampleItem } from '@/services/sample-api' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { Badge } from '@/components/ui/badge' |
||||
|
import { |
||||
|
Table, |
||||
|
TableHeader, |
||||
|
TableBody, |
||||
|
TableHead, |
||||
|
TableRow, |
||||
|
TableCell, |
||||
|
} from '@/components/ui/table' |
||||
|
import TablePagination from '@/components/shared/TablePagination' |
||||
|
import TableRowsSkeleton from '@/components/shared/TableRowsSkeleton' |
||||
|
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog' |
||||
|
import SampleItemModal from './SampleItemModal' |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
import type { SampleItem } from '@/types' |
||||
|
|
||||
|
export default function ItemsPage() { |
||||
|
const queryClient = useQueryClient() |
||||
|
const { openPanel, openModal, closeModal, activeModal } = usePanelRouting() |
||||
|
|
||||
|
const [page, setPage] = useState(1) |
||||
|
const [search, setSearch] = useState('') |
||||
|
const [deleteTarget, setDeleteTarget] = useState<SampleItem | null>(null) |
||||
|
|
||||
|
const { data, isLoading } = useQuery({ |
||||
|
queryKey: ['items', { page, search }], |
||||
|
queryFn: () => getSampleItems({ page, pageSize: 10, search }), |
||||
|
}) |
||||
|
|
||||
|
const deleteMutation = useMutation({ |
||||
|
mutationFn: (id: string) => deleteSampleItem(id), |
||||
|
onSuccess: () => { |
||||
|
queryClient.invalidateQueries({ queryKey: ['items'] }) |
||||
|
setDeleteTarget(null) |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const isItemModalOpen = activeModal?.type === 'item' |
||||
|
|
||||
|
const statusBadge = (status: string) => { |
||||
|
switch (status) { |
||||
|
case 'published': |
||||
|
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20">منتشر شده</Badge> |
||||
|
case 'draft': |
||||
|
return <Badge className="bg-amber-500/15 text-amber-400 border-amber-500/20">پیشنویس</Badge> |
||||
|
default: |
||||
|
return <Badge className="bg-grey-4/40 text-grey-3 border-grey-4">بایگانی</Badge> |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-5 pt-2"> |
||||
|
{/* هدر صفحه */} |
||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
||||
|
<div> |
||||
|
<h1 className="text-xl font-bold text-foreground">مدیریت آیتمها (الگوی CRUD)</h1> |
||||
|
<p className="mt-0.5 text-xs text-grey-3"> |
||||
|
نمونه کامل صفحه مدیریت داده با جدول، فیلتر، مودال و نمایش کشویی جزئیات |
||||
|
</p> |
||||
|
</div> |
||||
|
<Button onClick={() => openModal('item', 'new')} className="gap-2"> |
||||
|
<Ic name="plus" className="size-4" /> |
||||
|
افزودن آیتم جدید |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
{/* جستجو و ابزار */} |
||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-3 sm:flex-row sm:items-center sm:justify-between"> |
||||
|
<div className="relative flex-1 sm:max-w-xs"> |
||||
|
<Ic name="search" className="absolute start-3 top-1/2 size-4 -translate-y-1/2 text-grey-3" /> |
||||
|
<Input |
||||
|
value={search} |
||||
|
onChange={(e) => { |
||||
|
setSearch(e.target.value) |
||||
|
setPage(1) |
||||
|
}} |
||||
|
placeholder="جستجوی عنوان یا دسته..." |
||||
|
className="ps-9 text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="text-xs text-grey-3"> |
||||
|
مجموع: <span className="font-bold text-foreground">{data?.total || 0}</span> مورد |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* جدول دادهها */} |
||||
|
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40"> |
||||
|
<Table> |
||||
|
<TableHeader> |
||||
|
<TableRow> |
||||
|
<TableHead className="w-12 text-center">#</TableHead> |
||||
|
<TableHead>عنوان آیتم</TableHead> |
||||
|
<TableHead>دستهبندی</TableHead> |
||||
|
<TableHead>ارزش / مبلغ</TableHead> |
||||
|
<TableHead>وضعیت</TableHead> |
||||
|
<TableHead>تاریخ ثبت</TableHead> |
||||
|
<TableHead className="w-24 text-center">عملیات</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{isLoading ? ( |
||||
|
<TableRowsSkeleton cols={7} rows={5} /> |
||||
|
) : !data?.items || data.items.length === 0 ? ( |
||||
|
<TableRow> |
||||
|
<TableCell colSpan={7} className="py-10 text-center text-xs text-grey-3"> |
||||
|
موردی یافت نشد. |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
) : ( |
||||
|
data.items.map((item, idx) => ( |
||||
|
<TableRow |
||||
|
key={item.id} |
||||
|
className="cursor-pointer transition-colors hover:bg-white/4" |
||||
|
onClick={() => openPanel('item', item.id)} |
||||
|
> |
||||
|
<TableCell className="text-center text-xs text-grey-4 font-mono"> |
||||
|
{(page - 1) * 10 + idx + 1} |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
<div className="font-semibold text-foreground text-xs">{item.title}</div> |
||||
|
{item.description && ( |
||||
|
<div className="line-clamp-1 text-[11px] text-grey-3 mt-0.5 max-w-sm"> |
||||
|
{item.description} |
||||
|
</div> |
||||
|
)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-xs text-grey-2"> |
||||
|
<span className="rounded-md bg-white/4 px-2 py-0.5 text-xs"> |
||||
|
{item.category} |
||||
|
</span> |
||||
|
</TableCell> |
||||
|
<TableCell className="text-xs font-medium text-foreground"> |
||||
|
{item.price ? `${item.price.toLocaleString('fa-IR')} تومان` : 'رایگان'} |
||||
|
</TableCell> |
||||
|
<TableCell>{statusBadge(item.status)}</TableCell> |
||||
|
<TableCell className="text-xs text-grey-3">{item.createdAt}</TableCell> |
||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}> |
||||
|
<div className="flex items-center justify-center gap-1"> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="icon" |
||||
|
className="size-7 text-grey-3 hover:text-foreground" |
||||
|
title="مشاهده در پنل چپ" |
||||
|
onClick={() => openPanel('item', item.id)} |
||||
|
> |
||||
|
<Ic name="eye" className="size-3.5" /> |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="icon" |
||||
|
className="size-7 text-grey-3 hover:text-foreground" |
||||
|
title="ویرایش در مودال" |
||||
|
onClick={() => openModal('item', item.id)} |
||||
|
> |
||||
|
<Ic name="pencil" className="size-3.5" /> |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="icon" |
||||
|
className="size-7 text-rose-400/80 hover:bg-rose-500/10 hover:text-rose-400" |
||||
|
title="حذف" |
||||
|
onClick={() => setDeleteTarget(item)} |
||||
|
> |
||||
|
<Ic name="trash" className="size-3.5" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
)) |
||||
|
)} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
|
||||
|
{data && data.totalPages > 1 && ( |
||||
|
<div className="p-3 border-t border-border-soft"> |
||||
|
<TablePagination |
||||
|
currentPage={page} |
||||
|
totalPages={data.totalPages} |
||||
|
onPageChange={setPage} |
||||
|
/> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* مودال ایجاد / ویرایش */} |
||||
|
<SampleItemModal |
||||
|
open={isItemModalOpen} |
||||
|
itemId={activeModal?.id} |
||||
|
onOpenChange={(isOpen) => { |
||||
|
if (!isOpen) closeModal() |
||||
|
}} |
||||
|
onSuccess={() => { |
||||
|
queryClient.invalidateQueries({ queryKey: ['items'] }) |
||||
|
}} |
||||
|
/> |
||||
|
|
||||
|
{/* دیالوگ تأیید حذف */} |
||||
|
<DeleteConfirmDialog |
||||
|
open={Boolean(deleteTarget)} |
||||
|
onOpenChange={(o) => !o && setDeleteTarget(null)} |
||||
|
title={`آیا از حذف "${deleteTarget?.title}" اطمینان دارید؟`} |
||||
|
isLoading={deleteMutation.isPending} |
||||
|
onConfirm={() => { |
||||
|
if (deleteTarget) { |
||||
|
deleteMutation.mutate(deleteTarget.id) |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,235 @@ |
|||||
|
import { useState, useEffect } from 'react' |
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogContent, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
DialogDescription, |
||||
|
DialogFooter, |
||||
|
} from '@/components/ui/dialog' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Textarea } from '@/components/ui/textarea' |
||||
|
import { |
||||
|
Select, |
||||
|
SelectContent, |
||||
|
SelectItem, |
||||
|
SelectTrigger, |
||||
|
SelectValue, |
||||
|
} from '@/components/ui/select' |
||||
|
import { createSampleItem, updateSampleItem, getSampleItem } from '@/services/sample-api' |
||||
|
import type { SampleItem, ItemStatus } from '@/types' |
||||
|
|
||||
|
interface SampleItemModalProps { |
||||
|
open: boolean |
||||
|
itemId?: string | null |
||||
|
onOpenChange: (open: boolean) => void |
||||
|
onSuccess?: (item: SampleItem) => void |
||||
|
} |
||||
|
|
||||
|
export default function SampleItemModal({ |
||||
|
open, |
||||
|
itemId, |
||||
|
onOpenChange, |
||||
|
onSuccess, |
||||
|
}: SampleItemModalProps) { |
||||
|
const isEditing = Boolean(itemId && itemId !== 'new') |
||||
|
|
||||
|
const [title, setTitle] = useState('') |
||||
|
const [category, setCategory] = useState('نرمافزار') |
||||
|
const [status, setStatus] = useState<ItemStatus>('published') |
||||
|
const [price, setPrice] = useState<string>('') |
||||
|
const [description, setDescription] = useState('') |
||||
|
const [tags, setTags] = useState('') |
||||
|
const [loading, setLoading] = useState(false) |
||||
|
const [submitting, setSubmitting] = useState(false) |
||||
|
const [error, setError] = useState<string | null>(null) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (open) { |
||||
|
setError(null) |
||||
|
if (isEditing && itemId) { |
||||
|
setLoading(true) |
||||
|
getSampleItem(itemId) |
||||
|
.then((it) => { |
||||
|
setTitle(it.title) |
||||
|
setCategory(it.category) |
||||
|
setStatus(it.status) |
||||
|
setPrice(it.price ? String(it.price) : '') |
||||
|
setDescription(it.description || '') |
||||
|
setTags(it.tags?.join('، ') || '') |
||||
|
}) |
||||
|
.catch(() => setError('دریافت اطلاعات با خطا مواجه شد.')) |
||||
|
.finally(() => setLoading(false)) |
||||
|
} else { |
||||
|
setTitle('') |
||||
|
setCategory('نرمافزار') |
||||
|
setStatus('published') |
||||
|
setPrice('') |
||||
|
setDescription('') |
||||
|
setTags('') |
||||
|
} |
||||
|
} |
||||
|
}, [open, isEditing, itemId]) |
||||
|
|
||||
|
const handleSubmit = async (e: React.FormEvent) => { |
||||
|
e.preventDefault() |
||||
|
if (!title.trim()) { |
||||
|
setError('عنوان الزامی است.') |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
setSubmitting(true) |
||||
|
setError(null) |
||||
|
|
||||
|
const parsedTags = tags |
||||
|
.split(/[،,]/) |
||||
|
.map((t) => t.trim()) |
||||
|
.filter(Boolean) |
||||
|
|
||||
|
let result: SampleItem |
||||
|
if (isEditing && itemId) { |
||||
|
result = await updateSampleItem(itemId, { |
||||
|
title, |
||||
|
category, |
||||
|
status, |
||||
|
price: price ? Number(price) : undefined, |
||||
|
description, |
||||
|
tags: parsedTags, |
||||
|
}) |
||||
|
} else { |
||||
|
result = await createSampleItem({ |
||||
|
title, |
||||
|
category, |
||||
|
status, |
||||
|
price: price ? Number(price) : undefined, |
||||
|
description, |
||||
|
tags: parsedTags, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
onSuccess?.(result) |
||||
|
onOpenChange(false) |
||||
|
} catch (err: any) { |
||||
|
setError(err?.message || 'عملیات با خطا مواجه شد.') |
||||
|
} finally { |
||||
|
setSubmitting(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent className="sm:max-w-[500px]"> |
||||
|
<DialogHeader className="text-start"> |
||||
|
<DialogTitle className="text-lg font-bold text-foreground"> |
||||
|
{isEditing ? 'ویرایش اطلاعات آیتم' : 'افزودن آیتم جدید'} |
||||
|
</DialogTitle> |
||||
|
<DialogDescription className="text-xs text-grey-3"> |
||||
|
فرم زیر را تکمیل کرده و دکمه ثبت را کلیک کنید. |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
{loading ? ( |
||||
|
<div className="py-8 text-center text-sm text-grey-3">در حال بارگذاری...</div> |
||||
|
) : ( |
||||
|
<form onSubmit={handleSubmit} className="space-y-4 py-2"> |
||||
|
{error && ( |
||||
|
<div className="rounded-lg bg-rose-500/10 border border-rose-500/30 p-3 text-xs text-rose-400"> |
||||
|
{error} |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">عنوان آیتم *</label> |
||||
|
<Input |
||||
|
placeholder="عنوان خدمت یا محصول..." |
||||
|
value={title} |
||||
|
onChange={(e) => setTitle(e.target.value)} |
||||
|
required |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">دستهبندی</label> |
||||
|
<Select value={category} onValueChange={setCategory}> |
||||
|
<SelectTrigger> |
||||
|
<SelectValue placeholder="دستهبندی" /> |
||||
|
</SelectTrigger> |
||||
|
<SelectContent> |
||||
|
<SelectItem value="نرمافزار">نرمافزار</SelectItem> |
||||
|
<SelectItem value="مالی">مالی و پرداخت</SelectItem> |
||||
|
<SelectItem value="ارتباطات">ارتباطات</SelectItem> |
||||
|
<SelectItem value="لجستیک">لجستیک</SelectItem> |
||||
|
<SelectItem value="پشتیبانی">پشتیبانی</SelectItem> |
||||
|
</SelectContent> |
||||
|
</Select> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">وضعیت انتشار</label> |
||||
|
<Select value={status} onValueChange={(v: ItemStatus) => setStatus(v)}> |
||||
|
<SelectTrigger> |
||||
|
<SelectValue placeholder="وضعیت" /> |
||||
|
</SelectTrigger> |
||||
|
<SelectContent> |
||||
|
<SelectItem value="published">منتشر شده</SelectItem> |
||||
|
<SelectItem value="draft">پیشنویس</SelectItem> |
||||
|
<SelectItem value="archived">بایگانی شده</SelectItem> |
||||
|
</SelectContent> |
||||
|
</Select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">قیمت / مبلغ (تومان)</label> |
||||
|
<Input |
||||
|
dir="ltr" |
||||
|
type="number" |
||||
|
placeholder="مثلاً: 1500000" |
||||
|
value={price} |
||||
|
onChange={(e) => setPrice(e.target.value)} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">برچسبها (با ویرگول جدا کنید)</label> |
||||
|
<Input |
||||
|
placeholder="مالی، پرداخت، امنیت" |
||||
|
value={tags} |
||||
|
onChange={(e) => setTags(e.target.value)} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">توضیحات تکمیلی</label> |
||||
|
<Textarea |
||||
|
placeholder="توضیحات کوتاه درباره آیتم..." |
||||
|
value={description} |
||||
|
onChange={(e) => setDescription(e.target.value)} |
||||
|
rows={3} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<DialogFooter className="mt-6 gap-2 sm:justify-start"> |
||||
|
<Button type="submit" disabled={submitting}> |
||||
|
{submitting ? 'در حال ذخیره...' : isEditing ? 'ذخیره تغییرات' : 'افزودن آیتم'} |
||||
|
</Button> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="outline" |
||||
|
disabled={submitting} |
||||
|
onClick={() => onOpenChange(false)} |
||||
|
> |
||||
|
انصراف |
||||
|
</Button> |
||||
|
</DialogFooter> |
||||
|
</form> |
||||
|
)} |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,113 @@ |
|||||
|
import { useQuery } from '@tanstack/react-query' |
||||
|
import { getSampleItem } from '@/services/sample-api' |
||||
|
import PanelHeader from '@/components/shared/PanelHeader' |
||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||
|
import { Badge } from '@/components/ui/badge' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
|
||||
|
interface SampleDetailPanelProps { |
||||
|
id?: string |
||||
|
onClose: () => void |
||||
|
} |
||||
|
|
||||
|
export default function SampleDetailPanel({ id, onClose }: SampleDetailPanelProps) { |
||||
|
const { openModal } = usePanelRouting() |
||||
|
const { data: item, isLoading, error } = useQuery({ |
||||
|
queryKey: ['sample-item', id], |
||||
|
queryFn: () => getSampleItem(id || 'item-1'), |
||||
|
enabled: Boolean(id), |
||||
|
}) |
||||
|
|
||||
|
if (isLoading) { |
||||
|
return ( |
||||
|
<div className="flex h-full flex-col p-5 space-y-4"> |
||||
|
<PanelHeader title="جزئیات آیتم" onClose={onClose} /> |
||||
|
<Skeleton className="h-20 w-full rounded-xl" /> |
||||
|
<Skeleton className="h-28 w-full rounded-xl" /> |
||||
|
<Skeleton className="h-40 w-full rounded-xl" /> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
if (error || !item) { |
||||
|
return ( |
||||
|
<div className="flex h-full flex-col p-5"> |
||||
|
<PanelHeader title="خطا" onClose={onClose} /> |
||||
|
<div className="flex flex-1 items-center justify-center text-sm text-grey-3"> |
||||
|
آیتم مورد نظر یافت نشد. |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const statusBadge = { |
||||
|
published: <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/25">منتشر شده</Badge>, |
||||
|
draft: <Badge className="bg-amber-500/15 text-amber-400 border-amber-500/25">پیشنویس</Badge>, |
||||
|
archived: <Badge className="bg-grey-4/40 text-grey-3 border-grey-4">بایگانی شده</Badge>, |
||||
|
}[item.status] |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex h-full flex-col overflow-y-auto p-5"> |
||||
|
<PanelHeader title="مشاهده جزئیات" onClose={onClose} /> |
||||
|
|
||||
|
<div className="mt-5 rounded-xl border border-border-soft bg-card/60 p-4"> |
||||
|
<div className="flex items-center justify-between gap-2"> |
||||
|
<span className="text-xs text-primary font-medium">{item.category}</span> |
||||
|
{statusBadge} |
||||
|
</div> |
||||
|
<h3 className="mt-2 text-base font-bold text-foreground">{item.title}</h3> |
||||
|
{item.description && ( |
||||
|
<p className="mt-2 text-xs text-grey-2 leading-relaxed">{item.description}</p> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<div className="mt-4 space-y-3 rounded-xl border border-border-soft bg-card/40 p-4 text-xs"> |
||||
|
<div className="flex items-center justify-between py-1 border-b border-border-soft"> |
||||
|
<span className="text-grey-3">مبلغ / ارزش</span> |
||||
|
<span className="font-semibold text-foreground"> |
||||
|
{item.price ? `${item.price.toLocaleString('fa-IR')} تومان` : 'رایگان'} |
||||
|
</span> |
||||
|
</div> |
||||
|
<div className="flex items-center justify-between py-1 border-b border-border-soft"> |
||||
|
<span className="text-grey-3">تاریخ ثبت</span> |
||||
|
<span className="font-medium text-foreground">{item.createdAt}</span> |
||||
|
</div> |
||||
|
<div className="flex items-center justify-between py-1"> |
||||
|
<span className="text-grey-3">آخرین بروزرسانی</span> |
||||
|
<span className="font-medium text-foreground">{item.updatedAt}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{item.tags && item.tags.length > 0 && ( |
||||
|
<div className="mt-4 rounded-xl border border-border-soft bg-card/40 p-4"> |
||||
|
<span className="text-xs font-semibold text-grey-3 block mb-2">برچسبها</span> |
||||
|
<div className="flex flex-wrap gap-1.5"> |
||||
|
{item.tags.map((t) => ( |
||||
|
<span key={t} className="rounded-md bg-white/6 px-2 py-0.5 text-[11px] text-grey-2"> |
||||
|
#{t} |
||||
|
</span> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<div className="mt-auto pt-6 flex items-center gap-2"> |
||||
|
<Button |
||||
|
className="flex-1 gap-2" |
||||
|
onClick={() => { |
||||
|
onClose() |
||||
|
openModal('item', item.id) |
||||
|
}} |
||||
|
> |
||||
|
<Ic name="pencil" className="size-4" /> |
||||
|
ویرایش آیتم |
||||
|
</Button> |
||||
|
<Button variant="outline" onClick={onClose}> |
||||
|
بستن |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,125 @@ |
|||||
|
import { useQuery } from '@tanstack/react-query' |
||||
|
import { getUser } from '@/services/users-api' |
||||
|
import PanelHeader from '@/components/shared/PanelHeader' |
||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||
|
import { Badge } from '@/components/ui/badge' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
|
||||
|
interface UserDetailPanelProps { |
||||
|
id?: string |
||||
|
onClose: () => void |
||||
|
} |
||||
|
|
||||
|
export default function UserDetailPanel({ id, onClose }: UserDetailPanelProps) { |
||||
|
const { openModal } = usePanelRouting() |
||||
|
const { data: user, isLoading, error } = useQuery({ |
||||
|
queryKey: ['user', id], |
||||
|
queryFn: () => getUser(id || '1'), |
||||
|
enabled: Boolean(id), |
||||
|
}) |
||||
|
|
||||
|
if (isLoading) { |
||||
|
return ( |
||||
|
<div className="flex h-full flex-col p-5 space-y-4"> |
||||
|
<PanelHeader title="جزئیات کاربر" onClose={onClose} /> |
||||
|
<div className="flex items-center gap-3 pt-4"> |
||||
|
<Skeleton className="size-16 rounded-full" /> |
||||
|
<div className="space-y-2 flex-1"> |
||||
|
<Skeleton className="h-4 w-3/4" /> |
||||
|
<Skeleton className="h-3 w-1/2" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<Skeleton className="h-28 w-full rounded-xl" /> |
||||
|
<Skeleton className="h-40 w-full rounded-xl" /> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
if (error || !user) { |
||||
|
return ( |
||||
|
<div className="flex h-full flex-col p-5"> |
||||
|
<PanelHeader title="خطا" onClose={onClose} /> |
||||
|
<div className="flex flex-1 items-center justify-center text-sm text-grey-3"> |
||||
|
کاربر مورد نظر یافت نشد. |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const roleLabel = { |
||||
|
admin: 'مدیر کل', |
||||
|
manager: 'مدیر بخش', |
||||
|
editor: 'ویرایشگر', |
||||
|
viewer: 'مشاهدهکننده', |
||||
|
}[user.role] ?? user.role |
||||
|
|
||||
|
const statusBadge = { |
||||
|
active: <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/25">فعال</Badge>, |
||||
|
inactive: <Badge className="bg-rose-500/15 text-rose-400 border-rose-500/25">غیرفعال</Badge>, |
||||
|
pending: <Badge className="bg-amber-500/15 text-amber-400 border-amber-500/25">در انتظار تایید</Badge>, |
||||
|
}[user.status] |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex h-full flex-col overflow-y-auto p-5"> |
||||
|
<PanelHeader title="جزئیات کاربر" onClose={onClose} /> |
||||
|
|
||||
|
{/* آواتار و نام کاربر */} |
||||
|
<div className="mt-5 flex items-center gap-4 rounded-xl border border-border-soft bg-card/60 p-4"> |
||||
|
<div className="flex size-14 items-center justify-center rounded-full bg-primary/20 text-xl font-bold text-primary"> |
||||
|
{user.name.charAt(0)} |
||||
|
</div> |
||||
|
<div className="min-w-0 flex-1"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<h3 className="truncate font-bold text-foreground">{user.name}</h3> |
||||
|
{statusBadge} |
||||
|
</div> |
||||
|
<p dir="ltr" className="truncate text-xs text-grey-3 text-start mt-0.5">{user.email}</p> |
||||
|
<p className="text-[11px] text-grey-4 mt-1">نقش: {roleLabel}</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* اطلاعات تکمیلی */} |
||||
|
<div className="mt-5 space-y-3 rounded-xl border border-border-soft bg-card/40 p-4 text-xs"> |
||||
|
<div className="flex items-center justify-between py-1 border-b border-border-soft"> |
||||
|
<span className="text-grey-3">شماره تماس</span> |
||||
|
<span dir="ltr" className="font-medium text-foreground">{user.phone || 'ثبتنشده'}</span> |
||||
|
</div> |
||||
|
<div className="flex items-center justify-between py-1 border-b border-border-soft"> |
||||
|
<span className="text-grey-3">تاریخ عضویت</span> |
||||
|
<span className="font-medium text-foreground">{user.createdAt}</span> |
||||
|
</div> |
||||
|
<div className="flex items-center justify-between py-1"> |
||||
|
<span className="text-grey-3">آخرین فعالیت</span> |
||||
|
<span className="font-medium text-foreground">{user.lastActive || 'مشخص نیست'}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* بیوگرافی یا توضیحات */} |
||||
|
{user.bio && ( |
||||
|
<div className="mt-4 rounded-xl border border-border-soft bg-card/40 p-4"> |
||||
|
<span className="text-xs font-semibold text-grey-3 block mb-1">درباره کاربر</span> |
||||
|
<p className="text-xs text-grey-2 leading-relaxed">{user.bio}</p> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* اکشنهای سریع */} |
||||
|
<div className="mt-auto pt-6 flex items-center gap-2"> |
||||
|
<Button |
||||
|
className="flex-1 gap-2" |
||||
|
onClick={() => { |
||||
|
onClose() |
||||
|
openModal('user', user.id) |
||||
|
}} |
||||
|
> |
||||
|
<Ic name="pencil" className="size-4" /> |
||||
|
ویرایش اطلاعات |
||||
|
</Button> |
||||
|
<Button variant="outline" onClick={onClose}> |
||||
|
بستن |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,121 @@ |
|||||
|
import { useState } from 'react' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { Switch } from '@/components/ui/switch' |
||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' |
||||
|
import { siteConfig } from '@/config/site' |
||||
|
|
||||
|
export default function SettingsPage() { |
||||
|
const [siteName, setSiteName] = useState(siteConfig.name) |
||||
|
const [emailAlerts, setEmailAlerts] = useState(true) |
||||
|
const [browserAlerts, setBrowserAlerts] = useState(false) |
||||
|
const [saved, setSaved] = useState(false) |
||||
|
|
||||
|
const handleSave = (e: React.FormEvent) => { |
||||
|
e.preventDefault() |
||||
|
setSaved(true) |
||||
|
setTimeout(() => setSaved(false), 2500) |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-6 pt-2 max-w-4xl"> |
||||
|
<div> |
||||
|
<h1 className="text-xl font-bold text-foreground">تنظیمات سیستم</h1> |
||||
|
<p className="mt-0.5 text-xs text-grey-3"> |
||||
|
مدیریت پیکربندیهای کلی داشبورد، اعلانها و امنیت حساب کاربری |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<Tabs defaultValue="general" className="w-full"> |
||||
|
<TabsList className="grid w-full grid-cols-3 max-w-md"> |
||||
|
<TabsTrigger value="general">تنظیمات عمومی</TabsTrigger> |
||||
|
<TabsTrigger value="notifications">اعلانها</TabsTrigger> |
||||
|
<TabsTrigger value="security">امنیت</TabsTrigger> |
||||
|
</TabsList> |
||||
|
|
||||
|
<TabsContent value="general" className="mt-5 space-y-5"> |
||||
|
<form onSubmit={handleSave} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4"> |
||||
|
<h2 className="text-sm font-bold text-foreground pb-2 border-b border-border-soft"> |
||||
|
مشخصات پنل |
||||
|
</h2> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">نام پنل / برند</label> |
||||
|
<Input |
||||
|
value={siteName} |
||||
|
onChange={(e) => setSiteName(e.target.value)} |
||||
|
placeholder="عنوان داشبورد" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">ایمیل پشتیبانی</label> |
||||
|
<Input |
||||
|
dir="ltr" |
||||
|
defaultValue="support@example.com" |
||||
|
placeholder="info@example.com" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="pt-2"> |
||||
|
<Button type="submit"> |
||||
|
{saved ? 'ذخیره شد ✓' : 'ذخیره تنظیمات'} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</form> |
||||
|
</TabsContent> |
||||
|
|
||||
|
<TabsContent value="notifications" className="mt-5 space-y-5"> |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4"> |
||||
|
<h2 className="text-sm font-bold text-foreground pb-2 border-b border-border-soft"> |
||||
|
تنظیمات اطلاعرسانی |
||||
|
</h2> |
||||
|
|
||||
|
<div className="flex items-center justify-between py-2"> |
||||
|
<div> |
||||
|
<div className="text-xs font-semibold text-foreground">ارسال اعلان ایمیلی</div> |
||||
|
<div className="text-[11px] text-grey-3 mt-0.5">دریافت گزارشهای هفتگی و رویدادهای مهم از طریق ایمیل</div> |
||||
|
</div> |
||||
|
<Switch checked={emailAlerts} onCheckedChange={setEmailAlerts} /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center justify-between py-2"> |
||||
|
<div> |
||||
|
<div className="text-xs font-semibold text-foreground">اعلانهای مرورگر (Push)</div> |
||||
|
<div className="text-[11px] text-grey-3 mt-0.5">نمایش نوتیفیکیشن هنگام ثبت رویداد جدید</div> |
||||
|
</div> |
||||
|
<Switch checked={browserAlerts} onCheckedChange={setBrowserAlerts} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</TabsContent> |
||||
|
|
||||
|
<TabsContent value="security" className="mt-5 space-y-5"> |
||||
|
<form onSubmit={handleSave} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4"> |
||||
|
<h2 className="text-sm font-bold text-foreground pb-2 border-b border-border-soft"> |
||||
|
تغییر رمز عبور |
||||
|
</h2> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">رمز عبور فعلی</label> |
||||
|
<Input type="password" placeholder="••••••••" dir="ltr" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">رمز عبور جدید</label> |
||||
|
<Input type="password" placeholder="••••••••" dir="ltr" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">تکرار رمز عبور جدید</label> |
||||
|
<Input type="password" placeholder="••••••••" dir="ltr" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="pt-2"> |
||||
|
<Button type="submit">بروزرسانی رمز عبور</Button> |
||||
|
</div> |
||||
|
</form> |
||||
|
</TabsContent> |
||||
|
</Tabs> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,544 @@ |
|||||
|
import { useState } from 'react' |
||||
|
import { Ic, type IconName } from '@/icons' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Badge } from '@/components/ui/badge' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { Textarea } from '@/components/ui/textarea' |
||||
|
import { Switch } from '@/components/ui/switch' |
||||
|
import { Checkbox } from '@/components/ui/checkbox' |
||||
|
import { Slider } from '@/components/ui/slider' |
||||
|
import { |
||||
|
Select, |
||||
|
SelectContent, |
||||
|
SelectItem, |
||||
|
SelectTrigger, |
||||
|
SelectValue, |
||||
|
} from '@/components/ui/select' |
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogTrigger, |
||||
|
DialogContent, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
DialogDescription, |
||||
|
DialogFooter, |
||||
|
} from '@/components/ui/dialog' |
||||
|
import { |
||||
|
AlertDialog, |
||||
|
AlertDialogTrigger, |
||||
|
AlertDialogContent, |
||||
|
AlertDialogHeader, |
||||
|
AlertDialogTitle, |
||||
|
AlertDialogDescription, |
||||
|
AlertDialogFooter, |
||||
|
AlertDialogAction, |
||||
|
AlertDialogCancel, |
||||
|
} from '@/components/ui/alert-dialog' |
||||
|
import { |
||||
|
Sheet, |
||||
|
SheetTrigger, |
||||
|
SheetContent, |
||||
|
SheetHeader, |
||||
|
SheetTitle, |
||||
|
SheetDescription, |
||||
|
} from '@/components/ui/sheet' |
||||
|
import { |
||||
|
DropdownMenu, |
||||
|
DropdownMenuTrigger, |
||||
|
DropdownMenuContent, |
||||
|
DropdownMenuItem, |
||||
|
DropdownMenuSeparator, |
||||
|
DropdownMenuLabel, |
||||
|
} from '@/components/ui/dropdown-menu' |
||||
|
import { |
||||
|
Tooltip, |
||||
|
TooltipTrigger, |
||||
|
TooltipContent, |
||||
|
} from '@/components/ui/tooltip' |
||||
|
import { |
||||
|
Popover, |
||||
|
PopoverTrigger, |
||||
|
PopoverContent, |
||||
|
} from '@/components/ui/popover' |
||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' |
||||
|
import { |
||||
|
Accordion, |
||||
|
AccordionItem, |
||||
|
AccordionTrigger, |
||||
|
AccordionContent, |
||||
|
} from '@/components/ui/accordion' |
||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||
|
import { Separator } from '@/components/ui/separator' |
||||
|
import { Avatar } from '@/components/ui/avatar' |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
|
||||
|
export default function ComponentsShowcase() { |
||||
|
const { openPanel, openModal } = usePanelRouting() |
||||
|
|
||||
|
// State examples for interactive controls
|
||||
|
const [switchVal, setSwitchVal] = useState(true) |
||||
|
const [checkboxVal, setCheckboxVal] = useState(true) |
||||
|
const [sliderVal, setSliderVal] = useState([45]) |
||||
|
const [selectVal, setSelectVal] = useState('react') |
||||
|
const [copiedToken, setCopiedToken] = useState<string | null>(null) |
||||
|
|
||||
|
const copyToClipboard = (text: string) => { |
||||
|
navigator.clipboard.writeText(text) |
||||
|
setCopiedToken(text) |
||||
|
setTimeout(() => setCopiedToken(null), 2000) |
||||
|
} |
||||
|
|
||||
|
const colorTokens = [ |
||||
|
{ name: '--primary', label: 'رنگ اصلی (Primary)', color: 'bg-primary', text: 'text-primary-foreground', hex: '#6366f1' }, |
||||
|
{ name: '--background', label: 'پسزمینه (Background)', color: 'bg-background', text: 'text-foreground', hex: '#18191d' }, |
||||
|
{ name: '--card', label: 'کارتها (Card)', color: 'bg-card', text: 'text-card-foreground', hex: '#22252c' }, |
||||
|
{ name: '--popover', label: 'پاپاور و منو (Popover)', color: 'bg-popover', text: 'text-popover-foreground', hex: '#1e2128' }, |
||||
|
{ name: '--secondary', label: 'ثانویه (Secondary)', color: 'bg-secondary', text: 'text-secondary-foreground', hex: '#2c303b' }, |
||||
|
{ name: '--destructive', label: 'خطا و حذف (Destructive)', color: 'bg-destructive', text: 'text-white', hex: '#ef4444' }, |
||||
|
{ name: '--muted', label: 'خاموش (Muted)', color: 'bg-muted', text: 'text-muted-foreground', hex: '#22252c' }, |
||||
|
{ name: '--border', label: 'حاشیه (Border)', color: 'border-2 border-white/25 bg-card/40', text: 'text-foreground', hex: 'rgba(255,255,255,0.12)' }, |
||||
|
] |
||||
|
|
||||
|
const availableIcons: IconName[] = [ |
||||
|
'home', 'users', 'grid', 'chart', 'settings', 'shield', 'folder', |
||||
|
'search', 'bell', 'pencil', 'trash', 'plus', 'check', 'x', 'filter', |
||||
|
'globe', 'upload', 'download', 'refresh', 'link', 'alert', 'clock', |
||||
|
'sparkles', 'eye', 'eyeOff', 'logout', 'history', 'file', 'external', |
||||
|
] |
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-8 pt-2 pb-16 max-w-6xl"> |
||||
|
{/* هدر بخش ویترین */} |
||||
|
<div className="flex flex-col gap-2 border-b border-border-soft pb-5 sm:flex-row sm:items-center sm:justify-between"> |
||||
|
<div> |
||||
|
<div className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-0.5 text-xs font-semibold text-primary mb-2"> |
||||
|
<Ic name="sparkles" className="size-3.5" /> دیزاینسیستم و کاتالوگ کامپوننتها |
||||
|
</div> |
||||
|
<h1 className="text-2xl font-black text-foreground">راهنمای استفاده از کامپوننتهای داشبورد</h1> |
||||
|
<p className="mt-1 text-xs text-grey-3 max-w-2xl leading-relaxed"> |
||||
|
این صفحه برای برنامهنویسان و طراحان پروژه آماده شده تا نحوه استفاده از کامپوننتهای شادسن، پالت رنگی، دیالوگها، مودالها و آیکونها را بهصورت زنده مشاهده و کپی کنند. |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
onClick={() => openModal('user', 'new')} |
||||
|
className="gap-1.5 text-xs" |
||||
|
> |
||||
|
<Ic name="plus" className="size-3.5" /> تست مودال فرم |
||||
|
</Button> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
variant="outline" |
||||
|
onClick={() => openPanel('user', '1')} |
||||
|
className="gap-1.5 text-xs" |
||||
|
> |
||||
|
<Ic name="eye" className="size-3.5" /> تست پنل کشویی چپ |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<Tabs defaultValue="tokens" className="w-full"> |
||||
|
<TabsList className="grid w-full grid-cols-2 md:grid-cols-5 max-w-2xl"> |
||||
|
<TabsTrigger value="tokens">رنگها و تم</TabsTrigger> |
||||
|
<TabsTrigger value="buttons">دکمه و نشان</TabsTrigger> |
||||
|
<TabsTrigger value="forms">فرم و ورودیها</TabsTrigger> |
||||
|
<TabsTrigger value="overlays">مودال و منوها</TabsTrigger> |
||||
|
<TabsTrigger value="data">ساختار و آیکونها</TabsTrigger> |
||||
|
</TabsList> |
||||
|
|
||||
|
{/* ─── تب ۱: پالت رنگی و متغیرهای تم ─── */} |
||||
|
<TabsContent value="tokens" className="mt-6 space-y-6"> |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-4"> |
||||
|
<div> |
||||
|
<h2 className="text-base font-bold text-foreground">پالت توکنهای طراحی (Design Tokens)</h2> |
||||
|
<p className="text-xs text-grey-3 mt-0.5"> |
||||
|
تمام استایلهای این پروژه بر اساس متغیرهای استاندارد CSS در فایل <code className="font-mono text-primary text-xs">src/index.css</code> تعریف شدهاند. |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> |
||||
|
{colorTokens.map((token) => ( |
||||
|
<div |
||||
|
key={token.name} |
||||
|
onClick={() => copyToClipboard(`var(${token.name})`)} |
||||
|
className="group relative flex flex-col justify-between rounded-xl border border-border-soft bg-card/60 p-3.5 cursor-pointer transition-all hover:border-primary/50" |
||||
|
> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<div className={`size-8 rounded-lg shadow-inner ${token.color}`} /> |
||||
|
<span className="text-[11px] font-mono text-grey-4 group-hover:text-primary"> |
||||
|
{copiedToken === `var(${token.name})` ? 'کپی شد! ✓' : 'کپی'} |
||||
|
</span> |
||||
|
</div> |
||||
|
<div className="mt-4"> |
||||
|
<div className="text-xs font-bold text-foreground">{token.label}</div> |
||||
|
<div dir="ltr" className="text-[11px] font-mono text-grey-3 text-start mt-0.5"> |
||||
|
{token.name} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-xl border border-primary/25 bg-primary/5 p-4 text-xs space-y-2"> |
||||
|
<span className="font-bold text-primary block">💡 چطور رنگ برند پروژه جدید را عوض کنیم؟</span> |
||||
|
<p className="text-grey-3 leading-relaxed"> |
||||
|
فایل <code className="font-mono text-foreground bg-white/6 px-1.5 py-0.5 rounded">src/index.css</code> را باز کنید و در بلوک <code className="font-mono">:root</code>، مقدار <code className="font-mono">--primary</code> را با کد رنگ دلخواهتان جایگزین کنید (مثلاً آبی، سبز، نارنجی یا بنفش). کل دکمهها، فوکوسها، نشانها و منوها خودکار تغییر رنگ میدهند! |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</TabsContent> |
||||
|
|
||||
|
{/* ─── تب ۲: دکمهها و نشانها ─── */} |
||||
|
<TabsContent value="buttons" className="mt-6 space-y-6"> |
||||
|
{/* انواع دکمهها */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-5"> |
||||
|
<div> |
||||
|
<h2 className="text-base font-bold text-foreground">دکمهها (Button Component)</h2> |
||||
|
<p className="text-xs text-grey-3 mt-0.5">انواع و اندازههای مختلف دکمه در تمپلیت</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex flex-wrap items-center gap-3"> |
||||
|
<Button>پیشفرض (Primary)</Button> |
||||
|
<Button variant="secondary">ثانویه (Secondary)</Button> |
||||
|
<Button variant="outline">حاشیهدار (Outline)</Button> |
||||
|
<Button variant="ghost">شفاف (Ghost)</Button> |
||||
|
<Button variant="destructive">حذف و اخطار (Destructive)</Button> |
||||
|
<Button variant="white">دکمه روشن (White)</Button> |
||||
|
<Button disabled>غیرفعال (Disabled)</Button> |
||||
|
</div> |
||||
|
|
||||
|
<Separator /> |
||||
|
|
||||
|
<div> |
||||
|
<span className="text-xs font-semibold text-grey-3 block mb-3">اندازهها (Sizes) و همراه با آیکون:</span> |
||||
|
<div className="flex flex-wrap items-center gap-3"> |
||||
|
<Button size="sm">کوچک (sm)</Button> |
||||
|
<Button size="default">متوسط (default)</Button> |
||||
|
<Button size="lg">بزرگ (lg)</Button> |
||||
|
<Button className="gap-2"> |
||||
|
<Ic name="plus" className="size-4" /> با آیکون |
||||
|
</Button> |
||||
|
<Button variant="outline" size="icon" title="جستجو"> |
||||
|
<Ic name="search" className="size-4" /> |
||||
|
</Button> |
||||
|
<Button variant="ghost" size="icon" title="تنظیمات"> |
||||
|
<Ic name="settings" className="size-4" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* نشانها (Badges) */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-4"> |
||||
|
<div> |
||||
|
<h2 className="text-base font-bold text-foreground">نشانها (Badge Component)</h2> |
||||
|
<p className="text-xs text-grey-3 mt-0.5">برای نمایش وضعیتها، تگها و شمارندهها</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex flex-wrap items-center gap-3"> |
||||
|
<Badge>پیشفرض</Badge> |
||||
|
<Badge variant="secondary">ثانویه</Badge> |
||||
|
<Badge variant="outline">حاشیهدار</Badge> |
||||
|
<Badge variant="success">موفق / فعال</Badge> |
||||
|
<Badge variant="destructive">رد شده / غیرفعال</Badge> |
||||
|
<Badge variant="count">۱۲</Badge> |
||||
|
</div> |
||||
|
</div> |
||||
|
</TabsContent> |
||||
|
|
||||
|
{/* ─── تب ۳: فرمها و فیلدها ─── */} |
||||
|
<TabsContent value="forms" className="mt-6 space-y-6"> |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-6"> |
||||
|
<div> |
||||
|
<h2 className="text-base font-bold text-foreground">فیلدهای ورودی فرم (Form Controls)</h2> |
||||
|
<p className="text-xs text-grey-3 mt-0.5">ورودیهای تکخطی، چندخطی، چکباکس، سلکت و اسلایدر</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">اینپوت متنی ساده</label> |
||||
|
<Input placeholder="نام و نام خانوادگی را وارد کنید..." /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">اینپوت انگلیسی / LTR (ایمیل یا شماره)</label> |
||||
|
<Input dir="ltr" type="email" placeholder="example@domain.com" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">منوی انتخابی (Select)</label> |
||||
|
<Select value={selectVal} onValueChange={setSelectVal}> |
||||
|
<SelectTrigger> |
||||
|
<SelectValue placeholder="انتخاب گزینه" /> |
||||
|
</SelectTrigger> |
||||
|
<SelectContent> |
||||
|
<SelectItem value="react">ریاکت (React 19)</SelectItem> |
||||
|
<SelectItem value="vue">ویو (Vue.js)</SelectItem> |
||||
|
<SelectItem value="tailwind">تیلویند (Tailwind 4)</SelectItem> |
||||
|
<SelectItem value="node">نود جیاس (Node.js)</SelectItem> |
||||
|
</SelectContent> |
||||
|
</Select> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">اسلایدر (Slider): {sliderVal[0]}٪</label> |
||||
|
<div className="pt-2"> |
||||
|
<Slider value={sliderVal} onValueChange={setSliderVal} max={100} step={1} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">توضیحات متنی (Textarea)</label> |
||||
|
<Textarea placeholder="یادداشت یا توضیحات طولانی خود را بنویسید..." rows={3} /> |
||||
|
</div> |
||||
|
|
||||
|
<Separator /> |
||||
|
|
||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
||||
|
<div className="flex items-center justify-between rounded-xl bg-white/4 p-3.5"> |
||||
|
<div> |
||||
|
<span className="text-xs font-semibold text-foreground block">سوئیچ وضعیت (Switch)</span> |
||||
|
<span className="text-[11px] text-grey-3">روشن / خاموش بودن تنظیمات</span> |
||||
|
</div> |
||||
|
<Switch checked={switchVal} onCheckedChange={setSwitchVal} /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-3 rounded-xl bg-white/4 p-3.5"> |
||||
|
<Checkbox |
||||
|
id="chk-terms" |
||||
|
checked={checkboxVal} |
||||
|
onCheckedChange={(c) => setCheckboxVal(Boolean(c))} |
||||
|
/> |
||||
|
<label htmlFor="chk-terms" className="text-xs text-foreground cursor-pointer select-none"> |
||||
|
پذیرش قوانین و شرایط استفاده (Checkbox) |
||||
|
</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</TabsContent> |
||||
|
|
||||
|
{/* ─── تب ۴: مودالها و منوها ─── */} |
||||
|
<TabsContent value="overlays" className="mt-6 space-y-6"> |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-6"> |
||||
|
<div> |
||||
|
<h2 className="text-base font-bold text-foreground">لایههای شناور و دیالوگها (Overlays & Modals)</h2> |
||||
|
<p className="text-xs text-grey-3 mt-0.5">مودال، دیالوگ تایید، منوی کشویی، دراور و تولتیپ</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
||||
|
{/* ۱. Dialog استاندارد */} |
||||
|
<div className="rounded-xl border border-border-soft bg-card/60 p-4 space-y-3"> |
||||
|
<div className="text-xs font-bold text-foreground">دیالوگ استاندارد (Dialog)</div> |
||||
|
<p className="text-[11px] text-grey-3">مناسب فرمها و جزئیات با کنترل کامل.</p> |
||||
|
<Dialog> |
||||
|
<DialogTrigger asChild> |
||||
|
<Button variant="outline" size="sm" className="w-full"> |
||||
|
باز کردن دیالوگ |
||||
|
</Button> |
||||
|
</DialogTrigger> |
||||
|
<DialogContent className="sm:max-w-[420px]"> |
||||
|
<DialogHeader> |
||||
|
<DialogTitle>نمونه دیالوگ</DialogTitle> |
||||
|
<DialogDescription>این یک دیالوگ ماژولار مبتنی بر Radix است.</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
<div className="py-4 space-y-3"> |
||||
|
<Input placeholder="نام مورد نظر..." /> |
||||
|
<Input placeholder="توضیحات..." /> |
||||
|
</div> |
||||
|
<DialogFooter> |
||||
|
<Button>تایید و ثبت</Button> |
||||
|
</DialogFooter> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
</div> |
||||
|
|
||||
|
{/* ۲. AlertDialog تایید حساس */} |
||||
|
<div className="rounded-xl border border-border-soft bg-card/60 p-4 space-y-3"> |
||||
|
<div className="text-xs font-bold text-foreground">تأییدیه حساس (AlertDialog)</div> |
||||
|
<p className="text-[11px] text-grey-3">برای عملیات حذف یا تغییرات غیرقابل بازگشت.</p> |
||||
|
<AlertDialog> |
||||
|
<AlertDialogTrigger asChild> |
||||
|
<Button variant="destructive" size="sm" className="w-full"> |
||||
|
تست دیالوگ حذف |
||||
|
</Button> |
||||
|
</AlertDialogTrigger> |
||||
|
<AlertDialogContent className="sm:max-w-[400px]"> |
||||
|
<AlertDialogHeader> |
||||
|
<AlertDialogTitle>آیا مطمئن هستید؟</AlertDialogTitle> |
||||
|
<AlertDialogDescription>این عملیات قابل بازگشت نخواهد بود.</AlertDialogDescription> |
||||
|
</AlertDialogHeader> |
||||
|
<AlertDialogFooter className="mt-4 gap-2"> |
||||
|
<AlertDialogAction asChild> |
||||
|
<Button variant="destructive">بله، حذف کن</Button> |
||||
|
</AlertDialogAction> |
||||
|
<AlertDialogCancel asChild> |
||||
|
<Button variant="outline">انصراف</Button> |
||||
|
</AlertDialogCancel> |
||||
|
</AlertDialogFooter> |
||||
|
</AlertDialogContent> |
||||
|
</AlertDialog> |
||||
|
</div> |
||||
|
|
||||
|
{/* ۳. Sheet کشویی */} |
||||
|
<div className="rounded-xl border border-border-soft bg-card/60 p-4 space-y-3"> |
||||
|
<div className="text-xs font-bold text-foreground">دراور کشویی (Sheet)</div> |
||||
|
<p className="text-[11px] text-grey-3">باز شدن کشویی از چپ یا راست صفحه.</p> |
||||
|
<Sheet> |
||||
|
<SheetTrigger asChild> |
||||
|
<Button variant="secondary" size="sm" className="w-full"> |
||||
|
باز کردن Sheet |
||||
|
</Button> |
||||
|
</SheetTrigger> |
||||
|
<SheetContent side="left" className="p-6"> |
||||
|
<SheetHeader> |
||||
|
<SheetTitle>پنل کشویی Sheet</SheetTitle> |
||||
|
<SheetDescription>محتوای جانبی یا تنظیمات سریع در این بخش نمایش مییابد.</SheetDescription> |
||||
|
</SheetHeader> |
||||
|
<div className="py-6 text-xs text-grey-2"> |
||||
|
این کامپوننت با ترنزیشن نرم باز میشود و برای موبایل و دسکتاپ عالی است. |
||||
|
</div> |
||||
|
</SheetContent> |
||||
|
</Sheet> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<Separator /> |
||||
|
|
||||
|
{/* منوهای کشویی، پاپاور و تولتیپ */} |
||||
|
<div className="flex flex-wrap items-center gap-4"> |
||||
|
{/* Dropdown Menu */} |
||||
|
<DropdownMenu> |
||||
|
<DropdownMenuTrigger asChild> |
||||
|
<Button variant="outline" className="gap-2"> |
||||
|
<Ic name="dots" className="size-4" /> منوی آبشاری (Dropdown) |
||||
|
</Button> |
||||
|
</DropdownMenuTrigger> |
||||
|
<DropdownMenuContent align="end" className="w-48"> |
||||
|
<DropdownMenuLabel>عملیاتهای کاربر</DropdownMenuLabel> |
||||
|
<DropdownMenuItem className="gap-2"> |
||||
|
<Ic name="pencil" className="size-3.5" /> ویرایش رکورد |
||||
|
</DropdownMenuItem> |
||||
|
<DropdownMenuItem className="gap-2"> |
||||
|
<Ic name="eye" className="size-3.5" /> مشاهده در پنل |
||||
|
</DropdownMenuItem> |
||||
|
<DropdownMenuSeparator /> |
||||
|
<DropdownMenuItem className="gap-2 text-rose-400"> |
||||
|
<Ic name="trash" className="size-3.5" /> حذف آیتم |
||||
|
</DropdownMenuItem> |
||||
|
</DropdownMenuContent> |
||||
|
</DropdownMenu> |
||||
|
|
||||
|
{/* Popover */} |
||||
|
<Popover> |
||||
|
<PopoverTrigger asChild> |
||||
|
<Button variant="secondary" className="gap-2"> |
||||
|
<Ic name="filter" className="size-4" /> پاپاور (Popover) |
||||
|
</Button> |
||||
|
</PopoverTrigger> |
||||
|
<PopoverContent className="w-64 p-4 text-xs space-y-2"> |
||||
|
<span className="font-bold text-foreground block">اطلاعات سریع</span> |
||||
|
<p className="text-grey-3 leading-relaxed"> |
||||
|
پاپاور میتواند هر کامپوننت یا فرم کوچکی را در خود جای دهد. |
||||
|
</p> |
||||
|
</PopoverContent> |
||||
|
</Popover> |
||||
|
|
||||
|
{/* Tooltip */} |
||||
|
<Tooltip> |
||||
|
<TooltipTrigger asChild> |
||||
|
<Button variant="ghost" size="icon" className="border border-border-soft"> |
||||
|
<Ic name="bell" className="size-4" /> |
||||
|
</Button> |
||||
|
</TooltipTrigger> |
||||
|
<TooltipContent> |
||||
|
<span>اعلانهای جدید سیستم</span> |
||||
|
</TooltipContent> |
||||
|
</Tooltip> |
||||
|
</div> |
||||
|
</div> |
||||
|
</TabsContent> |
||||
|
|
||||
|
{/* ─── تب ۵: ساختار و آیکونها ─── */} |
||||
|
<TabsContent value="data" className="mt-6 space-y-6"> |
||||
|
{/* آکاردئون */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-4"> |
||||
|
<div> |
||||
|
<h2 className="text-base font-bold text-foreground">آکاردئون (Accordion)</h2> |
||||
|
<p className="text-xs text-grey-3 mt-0.5">بخشهای جمعشونده برای سوالات متداول یا تنظیمات فشرده</p> |
||||
|
</div> |
||||
|
|
||||
|
<Accordion type="single" collapsible defaultValue="item-1"> |
||||
|
<AccordionItem value="item-1"> |
||||
|
<AccordionTrigger className="text-xs font-semibold"> |
||||
|
چگونه یک صفحه جدید در داشبورد پیادهسازی کنم؟ |
||||
|
</AccordionTrigger> |
||||
|
<AccordionContent className="text-xs text-grey-3 leading-relaxed"> |
||||
|
کافی است کامپوننت صفحه خود را در پوشه <code className="text-primary font-mono">src/features/</code> ایجاد کنید و مسیر آن را در <code className="text-primary font-mono">src/app/router.tsx</code> رجیستر کنید. |
||||
|
</AccordionContent> |
||||
|
</AccordionItem> |
||||
|
|
||||
|
<AccordionItem value="item-2"> |
||||
|
<AccordionTrigger className="text-xs font-semibold"> |
||||
|
آیا برای مودالها نیاز به استیتهای محلی جداگانه دارم؟ |
||||
|
</AccordionTrigger> |
||||
|
<AccordionContent className="text-xs text-grey-3 leading-relaxed"> |
||||
|
خیر! با استفاده از هوک <code className="text-primary font-mono">usePanelRouting</code> میتوانید تنها با فراخوانی <code className="text-primary font-mono">openModal('name', id)</code> مودالها را از هر کجای برنامه با URL مدیریت کنید. |
||||
|
</AccordionContent> |
||||
|
</AccordionItem> |
||||
|
</Accordion> |
||||
|
</div> |
||||
|
|
||||
|
{/* اسکلتون لودینگ و آواتار */} |
||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-3"> |
||||
|
<h3 className="text-xs font-bold text-foreground">اسکلتون بارگذاری (Skeleton)</h3> |
||||
|
<div className="space-y-2"> |
||||
|
<Skeleton className="h-4 w-3/4" /> |
||||
|
<Skeleton className="h-3 w-1/2" /> |
||||
|
<Skeleton className="h-10 w-full rounded-lg" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-3"> |
||||
|
<h3 className="text-xs font-bold text-foreground">آواتارها (Avatar)</h3> |
||||
|
<div className="flex items-center gap-3"> |
||||
|
<Avatar letter="ع" className="size-10 bg-primary text-sm font-bold" /> |
||||
|
<Avatar letter="س" className="size-10 bg-emerald-600 text-sm font-bold" /> |
||||
|
<Avatar letter="م" className="size-10 bg-amber-600 text-sm font-bold" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* ویترین آیکونهای SVG توکار */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-6 space-y-4"> |
||||
|
<div> |
||||
|
<h2 className="text-base font-bold text-foreground">آیکونهای توکار سیستم (Built-in SVG Icons)</h2> |
||||
|
<p className="text-xs text-grey-3 mt-0.5"> |
||||
|
استفاده سریع با کامپوننت سبک: <code className="font-mono text-primary text-xs"><Ic name="..." /></code> |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-3 gap-2.5 sm:grid-cols-6 md:grid-cols-8"> |
||||
|
{availableIcons.map((ic) => ( |
||||
|
<div |
||||
|
key={ic} |
||||
|
onClick={() => copyToClipboard(`<Ic name="${ic}" className="size-4" />`)} |
||||
|
title="کلیک برای کپی تگ" |
||||
|
className="flex flex-col items-center justify-center rounded-xl border border-border-soft bg-card/60 p-3 cursor-pointer transition-colors hover:border-primary/40 hover:bg-white/6 group" |
||||
|
> |
||||
|
<Ic name={ic} className="size-5 text-grey-2 group-hover:text-primary transition-colors" /> |
||||
|
<span className="mt-2 truncate text-[10.5px] font-mono text-grey-4 group-hover:text-foreground"> |
||||
|
{ic} |
||||
|
</span> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
</TabsContent> |
||||
|
</Tabs> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,224 @@ |
|||||
|
import { useState, useEffect } from 'react' |
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogContent, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
DialogDescription, |
||||
|
DialogFooter, |
||||
|
} from '@/components/ui/dialog' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Textarea } from '@/components/ui/textarea' |
||||
|
import { |
||||
|
Select, |
||||
|
SelectContent, |
||||
|
SelectItem, |
||||
|
SelectTrigger, |
||||
|
SelectValue, |
||||
|
} from '@/components/ui/select' |
||||
|
import { createUser, updateUser, getUser } from '@/services/users-api' |
||||
|
import type { User, UserRole, UserStatus } from '@/types' |
||||
|
|
||||
|
interface UserModalProps { |
||||
|
open: boolean |
||||
|
userId?: string | null |
||||
|
onOpenChange: (open: boolean) => void |
||||
|
onSuccess?: (user: User) => void |
||||
|
} |
||||
|
|
||||
|
export default function UserModal({ |
||||
|
open, |
||||
|
userId, |
||||
|
onOpenChange, |
||||
|
onSuccess, |
||||
|
}: UserModalProps) { |
||||
|
const isEditing = Boolean(userId && userId !== 'new') |
||||
|
|
||||
|
const [name, setName] = useState('') |
||||
|
const [email, setEmail] = useState('') |
||||
|
const [phone, setPhone] = useState('') |
||||
|
const [role, setRole] = useState<UserRole>('editor') |
||||
|
const [status, setStatus] = useState<UserStatus>('active') |
||||
|
const [bio, setBio] = useState('') |
||||
|
const [loading, setLoading] = useState(false) |
||||
|
const [submitting, setSubmitting] = useState(false) |
||||
|
const [error, setError] = useState<string | null>(null) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (open) { |
||||
|
setError(null) |
||||
|
if (isEditing && userId) { |
||||
|
setLoading(true) |
||||
|
getUser(userId) |
||||
|
.then((u) => { |
||||
|
setName(u.name) |
||||
|
setEmail(u.email) |
||||
|
setPhone(u.phone || '') |
||||
|
setRole(u.role) |
||||
|
setStatus(u.status) |
||||
|
setBio(u.bio || '') |
||||
|
}) |
||||
|
.catch(() => setError('دریافت اطلاعات کاربر با خطا مواجه شد.')) |
||||
|
.finally(() => setLoading(false)) |
||||
|
} else { |
||||
|
// Reset form for create
|
||||
|
setName('') |
||||
|
setEmail('') |
||||
|
setPhone('') |
||||
|
setRole('editor') |
||||
|
setStatus('active') |
||||
|
setBio('') |
||||
|
} |
||||
|
} |
||||
|
}, [open, isEditing, userId]) |
||||
|
|
||||
|
const handleSubmit = async (e: React.FormEvent) => { |
||||
|
e.preventDefault() |
||||
|
if (!name.trim()) { |
||||
|
setError('نام و نام خانوادگی الزامی است.') |
||||
|
return |
||||
|
} |
||||
|
if (!email.trim()) { |
||||
|
setError('ایمیل الزامی است.') |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
setSubmitting(true) |
||||
|
setError(null) |
||||
|
|
||||
|
let result: User |
||||
|
if (isEditing && userId) { |
||||
|
result = await updateUser(userId, { name, email, phone, role, status, bio }) |
||||
|
} else { |
||||
|
result = await createUser({ name, email, phone, role, status, bio }) |
||||
|
} |
||||
|
|
||||
|
onSuccess?.(result) |
||||
|
onOpenChange(false) |
||||
|
} catch (err: any) { |
||||
|
setError(err?.message || 'عملیات با خطا مواجه شد.') |
||||
|
} finally { |
||||
|
setSubmitting(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent className="sm:max-w-[500px]"> |
||||
|
<DialogHeader className="text-start"> |
||||
|
<DialogTitle className="text-lg font-bold text-foreground"> |
||||
|
{isEditing ? 'ویرایش اطلاعات کاربر' : 'افزودن کاربر جدید'} |
||||
|
</DialogTitle> |
||||
|
<DialogDescription className="text-xs text-grey-3"> |
||||
|
{isEditing |
||||
|
? 'اطلاعات مورد نظر را تغییر داده و دکمه ذخیره را بزنید.' |
||||
|
: 'مشخصات کاربر جدید را تکمیل نمایید.'} |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
{loading ? ( |
||||
|
<div className="py-8 text-center text-sm text-grey-3">در حال بارگذاری اطلاعات...</div> |
||||
|
) : ( |
||||
|
<form onSubmit={handleSubmit} className="space-y-4 py-2"> |
||||
|
{error && ( |
||||
|
<div className="rounded-lg bg-rose-500/10 border border-rose-500/30 p-3 text-xs text-rose-400"> |
||||
|
{error} |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">نام و نام خانوادگی *</label> |
||||
|
<Input |
||||
|
placeholder="مثلاً: علی رضایی" |
||||
|
value={name} |
||||
|
onChange={(e) => setName(e.target.value)} |
||||
|
required |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">ایمیل *</label> |
||||
|
<Input |
||||
|
dir="ltr" |
||||
|
type="email" |
||||
|
placeholder="name@example.com" |
||||
|
value={email} |
||||
|
onChange={(e) => setEmail(e.target.value)} |
||||
|
required |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">شماره تماس</label> |
||||
|
<Input |
||||
|
dir="ltr" |
||||
|
placeholder="0912..." |
||||
|
value={phone} |
||||
|
onChange={(e) => setPhone(e.target.value)} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">نقش کاربری</label> |
||||
|
<Select value={role} onValueChange={(val: UserRole) => setRole(val)}> |
||||
|
<SelectTrigger> |
||||
|
<SelectValue placeholder="انتخاب نقش" /> |
||||
|
</SelectTrigger> |
||||
|
<SelectContent> |
||||
|
<SelectItem value="admin">مدیر کل</SelectItem> |
||||
|
<SelectItem value="manager">مدیر بخش</SelectItem> |
||||
|
<SelectItem value="editor">ویرایشگر</SelectItem> |
||||
|
<SelectItem value="viewer">مشاهدهکننده</SelectItem> |
||||
|
</SelectContent> |
||||
|
</Select> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">وضعیت حساب</label> |
||||
|
<Select value={status} onValueChange={(val: UserStatus) => setStatus(val)}> |
||||
|
<SelectTrigger> |
||||
|
<SelectValue placeholder="انتخاب وضعیت" /> |
||||
|
</SelectTrigger> |
||||
|
<SelectContent> |
||||
|
<SelectItem value="active">فعال</SelectItem> |
||||
|
<SelectItem value="pending">در انتظار تایید</SelectItem> |
||||
|
<SelectItem value="inactive">غیرفعال</SelectItem> |
||||
|
</SelectContent> |
||||
|
</Select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-semibold text-grey-2">توضیحات یا یادداشت</label> |
||||
|
<Textarea |
||||
|
placeholder="توضیحات اختیاری درباره این کاربر..." |
||||
|
value={bio} |
||||
|
onChange={(e) => setBio(e.target.value)} |
||||
|
rows={3} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<DialogFooter className="mt-6 gap-2 sm:justify-start"> |
||||
|
<Button type="submit" disabled={submitting}> |
||||
|
{submitting ? 'در حال ثبت...' : isEditing ? 'ذخیره تغییرات' : 'ایجاد کاربر'} |
||||
|
</Button> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="outline" |
||||
|
disabled={submitting} |
||||
|
onClick={() => onOpenChange(false)} |
||||
|
> |
||||
|
انصراف |
||||
|
</Button> |
||||
|
</DialogFooter> |
||||
|
</form> |
||||
|
)} |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,230 @@ |
|||||
|
import { useState } from 'react' |
||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' |
||||
|
import { getUsers, deleteUser } from '@/services/users-api' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { Badge } from '@/components/ui/badge' |
||||
|
import { |
||||
|
Table, |
||||
|
TableHeader, |
||||
|
TableBody, |
||||
|
TableHead, |
||||
|
TableRow, |
||||
|
TableCell, |
||||
|
} from '@/components/ui/table' |
||||
|
import TablePagination from '@/components/shared/TablePagination' |
||||
|
import TableRowsSkeleton from '@/components/shared/TableRowsSkeleton' |
||||
|
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog' |
||||
|
import UserModal from './UserModal' |
||||
|
import { usePanelRouting } from '@/hooks/use-panel-routing' |
||||
|
import type { User } from '@/types' |
||||
|
|
||||
|
export default function UsersPage() { |
||||
|
const queryClient = useQueryClient() |
||||
|
const { openPanel, openModal, closeModal, activeModal } = usePanelRouting() |
||||
|
|
||||
|
const [page, setPage] = useState(1) |
||||
|
const [search, setSearch] = useState('') |
||||
|
const [deleteTarget, setDeleteTarget] = useState<User | null>(null) |
||||
|
|
||||
|
const { data, isLoading } = useQuery({ |
||||
|
queryKey: ['users', { page, search }], |
||||
|
queryFn: () => getUsers({ page, pageSize: 10, search }), |
||||
|
}) |
||||
|
|
||||
|
const deleteMutation = useMutation({ |
||||
|
mutationFn: (id: string) => deleteUser(id), |
||||
|
onSuccess: () => { |
||||
|
queryClient.invalidateQueries({ queryKey: ['users'] }) |
||||
|
setDeleteTarget(null) |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const isUserModalOpen = activeModal?.type === 'user' |
||||
|
|
||||
|
const roleLabel = (role: string) => { |
||||
|
switch (role) { |
||||
|
case 'admin': return 'مدیر کل' |
||||
|
case 'manager': return 'مدیر بخش' |
||||
|
case 'editor': return 'ویرایشگر' |
||||
|
case 'viewer': return 'مشاهدهکننده' |
||||
|
default: return role |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const statusBadge = (status: string) => { |
||||
|
switch (status) { |
||||
|
case 'active': |
||||
|
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20">فعال</Badge> |
||||
|
case 'pending': |
||||
|
return <Badge className="bg-amber-500/15 text-amber-400 border-amber-500/20">در انتظار</Badge> |
||||
|
default: |
||||
|
return <Badge className="bg-rose-500/15 text-rose-400 border-rose-500/20">غیرفعال</Badge> |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-5 pt-2"> |
||||
|
{/* هدر صفحه */} |
||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
||||
|
<div> |
||||
|
<h1 className="text-xl font-bold text-foreground">مدیریت کاربران</h1> |
||||
|
<p className="mt-0.5 text-xs text-grey-3"> |
||||
|
مشاهده، جستجو، ایجاد و مدیریت دسترسیهای کاربران سیستم |
||||
|
</p> |
||||
|
</div> |
||||
|
<Button onClick={() => openModal('user', 'new')} className="gap-2"> |
||||
|
<Ic name="plus" className="size-4" /> |
||||
|
افزودن کاربر جدید |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
{/* نوار جستجو و ابزارها */} |
||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-3 sm:flex-row sm:items-center sm:justify-between"> |
||||
|
<div className="relative flex-1 sm:max-w-xs"> |
||||
|
<Ic name="search" className="absolute start-3 top-1/2 size-4 -translate-y-1/2 text-grey-3" /> |
||||
|
<Input |
||||
|
value={search} |
||||
|
onChange={(e) => { |
||||
|
setSearch(e.target.value) |
||||
|
setPage(1) |
||||
|
}} |
||||
|
placeholder="جستجوی نام یا ایمیل..." |
||||
|
className="ps-9 text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="text-xs text-grey-3"> |
||||
|
مجموع: <span className="font-bold text-foreground">{data?.total || 0}</span> کاربر |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* جدول کاربران */} |
||||
|
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40"> |
||||
|
<Table> |
||||
|
<TableHeader> |
||||
|
<TableRow> |
||||
|
<TableHead className="w-12 text-center">#</TableHead> |
||||
|
<TableHead>نام کاربر</TableHead> |
||||
|
<TableHead>ایمیل</TableHead> |
||||
|
<TableHead>شماره تماس</TableHead> |
||||
|
<TableHead>نقش</TableHead> |
||||
|
<TableHead>وضعیت</TableHead> |
||||
|
<TableHead>تاریخ عضویت</TableHead> |
||||
|
<TableHead className="w-24 text-center">عملیات</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{isLoading ? ( |
||||
|
<TableRowsSkeleton cols={8} rows={5} /> |
||||
|
) : !data?.items || data.items.length === 0 ? ( |
||||
|
<TableRow> |
||||
|
<TableCell colSpan={8} className="py-10 text-center text-xs text-grey-3"> |
||||
|
کاربری با این مشخصات یافت نشد. |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
) : ( |
||||
|
data.items.map((user, idx) => ( |
||||
|
<TableRow |
||||
|
key={user.id} |
||||
|
className="cursor-pointer transition-colors hover:bg-white/4" |
||||
|
onClick={() => openPanel('user', user.id)} |
||||
|
> |
||||
|
<TableCell className="text-center text-xs text-grey-4 font-mono"> |
||||
|
{(page - 1) * 10 + idx + 1} |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<div className="flex size-7 items-center justify-center rounded-full bg-primary/20 text-xs font-bold text-primary"> |
||||
|
{user.name.charAt(0)} |
||||
|
</div> |
||||
|
<span className="font-semibold text-foreground text-xs">{user.name}</span> |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
<TableCell dir="ltr" className="text-start text-xs text-grey-3 font-mono"> |
||||
|
{user.email} |
||||
|
</TableCell> |
||||
|
<TableCell dir="ltr" className="text-start text-xs text-grey-3"> |
||||
|
{user.phone || '-'} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-xs text-grey-2"> |
||||
|
{roleLabel(user.role)} |
||||
|
</TableCell> |
||||
|
<TableCell>{statusBadge(user.status)}</TableCell> |
||||
|
<TableCell className="text-xs text-grey-3">{user.createdAt}</TableCell> |
||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}> |
||||
|
<div className="flex items-center justify-center gap-1"> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="icon" |
||||
|
className="size-7 text-grey-3 hover:text-foreground" |
||||
|
title="مشاهده جزئیات در پنل" |
||||
|
onClick={() => openPanel('user', user.id)} |
||||
|
> |
||||
|
<Ic name="eye" className="size-3.5" /> |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="icon" |
||||
|
className="size-7 text-grey-3 hover:text-foreground" |
||||
|
title="ویرایش در مودال" |
||||
|
onClick={() => openModal('user', user.id)} |
||||
|
> |
||||
|
<Ic name="pencil" className="size-3.5" /> |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="icon" |
||||
|
className="size-7 text-rose-400/80 hover:bg-rose-500/10 hover:text-rose-400" |
||||
|
title="حذف" |
||||
|
onClick={() => setDeleteTarget(user)} |
||||
|
> |
||||
|
<Ic name="trash" className="size-3.5" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
)) |
||||
|
)} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
|
||||
|
{/* صفحهبندی */} |
||||
|
{data && data.totalPages > 1 && ( |
||||
|
<div className="p-3 border-t border-border-soft"> |
||||
|
<TablePagination |
||||
|
currentPage={page} |
||||
|
totalPages={data.totalPages} |
||||
|
onPageChange={setPage} |
||||
|
/> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* مودال ایجاد / ویرایش کاربر */} |
||||
|
<UserModal |
||||
|
open={isUserModalOpen} |
||||
|
userId={activeModal?.id} |
||||
|
onOpenChange={(isOpen) => { |
||||
|
if (!isOpen) closeModal() |
||||
|
}} |
||||
|
onSuccess={() => { |
||||
|
queryClient.invalidateQueries({ queryKey: ['users'] }) |
||||
|
}} |
||||
|
/> |
||||
|
|
||||
|
{/* دیالوگ تأیید حذف */} |
||||
|
<DeleteConfirmDialog |
||||
|
open={Boolean(deleteTarget)} |
||||
|
onOpenChange={(o) => !o && setDeleteTarget(null)} |
||||
|
title={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`} |
||||
|
isLoading={deleteMutation.isPending} |
||||
|
onConfirm={() => { |
||||
|
if (deleteTarget) { |
||||
|
deleteMutation.mutate(deleteTarget.id) |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
import { useEffect } from 'react' |
||||
|
import type { CarouselApi } from '@/components/ui/carousel' |
||||
|
|
||||
|
interface UseCarouselInfiniteOptions { |
||||
|
/** آیا صفحهٔ بعدی وجود دارد؟ (از react-query) */ |
||||
|
hasNextPage: boolean |
||||
|
/** آیا هماکنون در حال واکشی صفحهٔ بعدی هستیم؟ */ |
||||
|
isFetchingNextPage: boolean |
||||
|
/** واکشی صفحهٔ بعدی (query.fetchNextPage) */ |
||||
|
fetchNextPage: () => void |
||||
|
/** نسبتِ پیشرفتِ اسکرول که از آن به بعد صفحهٔ بعد prefetch میشود (۰..۱) */ |
||||
|
threshold?: number |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Infinite scroll برای کاروسلِ embla — هنگام نزدیکشدنِ کاروسل به انتها |
||||
|
* (scrollProgress ≥ threshold)، اگر صفحهٔ بعدی موجود بود و در حال واکشی نبودیم، |
||||
|
* fetchNextPage صدا زده میشود. |
||||
|
* |
||||
|
* api را با prop `setApi` از <Carousel> بگیرید. embla بهصورت پیشفرض |
||||
|
* (watchSlides) با افزودهشدن اسلایدهای جدید خودش reInit میکند. |
||||
|
*/ |
||||
|
export function useCarouselInfinite( |
||||
|
api: CarouselApi | undefined, |
||||
|
{ hasNextPage, isFetchingNextPage, fetchNextPage, threshold = 0.6 }: UseCarouselInfiniteOptions, |
||||
|
) { |
||||
|
useEffect(() => { |
||||
|
if (!api) return |
||||
|
|
||||
|
const maybeFetch = () => { |
||||
|
if (hasNextPage && !isFetchingNextPage && api.scrollProgress() >= threshold) { |
||||
|
fetchNextPage() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
api.on('select', maybeFetch) |
||||
|
api.on('settle', maybeFetch) |
||||
|
return () => { |
||||
|
api.off('select', maybeFetch) |
||||
|
api.off('settle', maybeFetch) |
||||
|
} |
||||
|
}, [api, hasNextPage, isFetchingNextPage, fetchNextPage, threshold]) |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
import { useEffect, useState } from 'react' |
||||
|
|
||||
|
/** |
||||
|
* مقدارِ debounceشده — تا `delay` میلیثانیه پس از آخرین تغییر `value` ثابت |
||||
|
* میماند. برای جلوگیری از اجرای Query در هر ضربهٔ کیبورد در Search-as-you-type. |
||||
|
*/ |
||||
|
export function useDebouncedValue<T>(value: T, delay = 200): T { |
||||
|
const [debounced, setDebounced] = useState(value) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const t = setTimeout(() => setDebounced(value), delay) |
||||
|
return () => clearTimeout(t) |
||||
|
}, [value, delay]) |
||||
|
|
||||
|
return debounced |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
import { useEffect, useRef } from 'react' |
||||
|
|
||||
|
interface UseInfiniteScrollOptions { |
||||
|
/** آیا صفحهٔ بعدی وجود دارد؟ (از react-query) */ |
||||
|
hasNextPage: boolean |
||||
|
/** آیا هماکنون در حال واکشی صفحهٔ بعدی هستیم؟ — جلوی فراخوانی تکراری را میگیرد */ |
||||
|
isFetchingNextPage: boolean |
||||
|
/** واکشی صفحهٔ بعدی (query.fetchNextPage) */ |
||||
|
fetchNextPage: () => void |
||||
|
/** فعال/غیرفعالسازی observer (مثلاً وقتی هنوز لودِ اولیه تمام نشده) */ |
||||
|
enabled?: boolean |
||||
|
/** فاصلهٔ پیشبارگذاری تا قبل از رسیدن واقعی به sentinel */ |
||||
|
rootMargin?: string |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Infinite scroll با IntersectionObserver روی یک عنصر sentinel. |
||||
|
* |
||||
|
* ref برگشتی را به یک عنصر نامرئی در انتهای لیست بدهید؛ هر وقت آن عنصر وارد |
||||
|
* دید شد و صفحهٔ بعدی موجود بود (و در حال واکشی نبودیم)، fetchNextPage صدا |
||||
|
* زده میشود. (همالگو با IntersectionObserverِ CollectionPanel.) |
||||
|
*/ |
||||
|
export function useInfiniteScroll({ |
||||
|
hasNextPage, |
||||
|
isFetchingNextPage, |
||||
|
fetchNextPage, |
||||
|
enabled = true, |
||||
|
rootMargin = '320px', |
||||
|
}: UseInfiniteScrollOptions) { |
||||
|
const sentinelRef = useRef<HTMLDivElement>(null) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const sentinel = sentinelRef.current |
||||
|
if (!sentinel || !enabled) return |
||||
|
|
||||
|
const observer = new IntersectionObserver( |
||||
|
(entries) => { |
||||
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { |
||||
|
fetchNextPage() |
||||
|
} |
||||
|
}, |
||||
|
{ rootMargin }, |
||||
|
) |
||||
|
observer.observe(sentinel) |
||||
|
return () => observer.disconnect() |
||||
|
}, [enabled, hasNextPage, isFetchingNextPage, fetchNextPage, rootMargin]) |
||||
|
|
||||
|
return sentinelRef |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
import { useEffect, useState } from 'react' |
||||
|
|
||||
|
const MOBILE_BREAKPOINT = 768 |
||||
|
const PANEL_PUSH_BREAKPOINT = 1280 |
||||
|
|
||||
|
/** هوک عمومی media-query؛ بعد از mount با مقدار واقعی sync میشود. */ |
||||
|
function useMediaQuery(query: string) { |
||||
|
const [matches, setMatches] = useState(false) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const mql = window.matchMedia(query) |
||||
|
const onChange = () => setMatches(mql.matches) |
||||
|
mql.addEventListener('change', onChange) |
||||
|
onChange() |
||||
|
return () => mql.removeEventListener('change', onChange) |
||||
|
}, [query]) |
||||
|
|
||||
|
return matches |
||||
|
} |
||||
|
|
||||
|
/** true زمانی که عرض viewport کمتر از 768px باشد (منوی اصلی روی موبایل). */ |
||||
|
export function useIsMobile() { |
||||
|
return useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* پنل جزئیات چپ زیر 1280px بهجای push کردن محتوا باید overlay (Sheet) شود؛ |
||||
|
* با سایدبار راست (240px) + پنل (384px)، push فقط از xl به بالا جا میگیرد. |
||||
|
*/ |
||||
|
export function useIsPanelOverlay() { |
||||
|
return useMediaQuery(`(max-width: ${PANEL_PUSH_BREAKPOINT - 1}px)`) |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
import { useCallback, useMemo } from 'react' |
||||
|
import { useSearchParams } from 'react-router-dom' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* روتینگ جنریک پنلها و مودالها در URL (قابل اشتراکگذاری و سازگار با Back/Forward): |
||||
|
* |
||||
|
* ?panel=user:123 → باز شدن پنل جزئیات کاربر ۱۲۳ از سمت چپ |
||||
|
* ?edit=user:123 → باز شدن مودال ویرایش کاربر ۱۲۳ |
||||
|
* ?edit=user:new → باز شدن مودال ایجاد کاربر جدید |
||||
|
* ?drawer=filters → باز شدن دراور فیلترها |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
export interface ActivePanelState { |
||||
|
type: string |
||||
|
id?: string |
||||
|
} |
||||
|
|
||||
|
export interface ActiveModalState { |
||||
|
type: string |
||||
|
id?: string |
||||
|
isNew?: boolean |
||||
|
} |
||||
|
|
||||
|
export function usePanelRouting() { |
||||
|
const [searchParams, setSearchParams] = useSearchParams() |
||||
|
|
||||
|
const panelParam = searchParams.get('panel') |
||||
|
const editParam = searchParams.get('edit') |
||||
|
const drawerParam = searchParams.get('drawer') |
||||
|
|
||||
|
const mutate = useCallback((fn: (p: URLSearchParams) => void) => { |
||||
|
const next = new URLSearchParams(searchParams) |
||||
|
fn(next) |
||||
|
setSearchParams(next) |
||||
|
}, [searchParams, setSearchParams]) |
||||
|
|
||||
|
/* پنل اسلاید-اور (Slide-over / Drawer چپ) */ |
||||
|
const activePanel = useMemo<ActivePanelState | null>(() => { |
||||
|
if (!panelParam) return null |
||||
|
const [type, id] = panelParam.split(':') |
||||
|
return { type, id } |
||||
|
}, [panelParam]) |
||||
|
|
||||
|
/* مودال باز (Modal Dialog) */ |
||||
|
const activeModal = useMemo<ActiveModalState | null>(() => { |
||||
|
if (!editParam) return null |
||||
|
const [type, id] = editParam.split(':') |
||||
|
return { type, id, isNew: id === 'new' } |
||||
|
}, [editParam]) |
||||
|
|
||||
|
const isPanelOpen = Boolean(panelParam || drawerParam) |
||||
|
const isModalOpen = Boolean(editParam) |
||||
|
|
||||
|
const openPanel = useCallback((type: string, id?: string) => { |
||||
|
mutate((p) => { |
||||
|
p.delete('drawer') |
||||
|
p.set('panel', id ? `${type}:${id}` : type) |
||||
|
}) |
||||
|
}, [mutate]) |
||||
|
|
||||
|
const closePanel = useCallback(() => { |
||||
|
mutate((p) => { |
||||
|
p.delete('panel') |
||||
|
p.delete('drawer') |
||||
|
}) |
||||
|
}, [mutate]) |
||||
|
|
||||
|
const openModal = useCallback((type: string, id: string = 'new') => { |
||||
|
mutate((p) => { |
||||
|
p.set('edit', `${type}:${id}`) |
||||
|
}) |
||||
|
}, [mutate]) |
||||
|
|
||||
|
const closeModal = useCallback(() => { |
||||
|
mutate((p) => { |
||||
|
p.delete('edit') |
||||
|
}) |
||||
|
}, [mutate]) |
||||
|
|
||||
|
const openDrawer = useCallback((drawerName: string = 'filters') => { |
||||
|
mutate((p) => { |
||||
|
p.delete('panel') |
||||
|
p.set('drawer', drawerName) |
||||
|
}) |
||||
|
}, [mutate]) |
||||
|
|
||||
|
const closeDrawer = useCallback(() => { |
||||
|
mutate((p) => { |
||||
|
p.delete('drawer') |
||||
|
}) |
||||
|
}, [mutate]) |
||||
|
|
||||
|
return { |
||||
|
activePanel, |
||||
|
activeModal, |
||||
|
drawerParam, |
||||
|
isPanelOpen, |
||||
|
isModalOpen, |
||||
|
openPanel, |
||||
|
closePanel, |
||||
|
openModal, |
||||
|
closeModal, |
||||
|
openDrawer, |
||||
|
closeDrawer, |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,110 @@ |
|||||
|
import * as React from 'react' |
||||
|
import { useId } from 'react' |
||||
|
|
||||
|
const PATHS: Record<string, string> = { |
||||
|
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>', |
||||
|
mic2: '<path d="M12 1a4 4 0 0 0-4 4v6a4 4 0 0 0 8 0V5a4 4 0 0 0-4-4Z"/><path d="M19 11a7 7 0 0 1-14 0"/><line x1="12" y1="18" x2="12" y2="22"/>', |
||||
|
radio: '<circle cx="12" cy="12" r="2"/><path d="M16.2 7.8a6 6 0 0 1 0 8.4"/><path d="M7.8 16.2a6 6 0 0 1 0-8.4"/><path d="M19.1 4.9a10 10 0 0 1 0 14.2"/><path d="M4.9 19.1a10 10 0 0 1 0-14.2"/>', |
||||
|
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>', |
||||
|
heart: '<path d="M19 14c1.5-1.5 3-3.4 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3.4 1-4.5 2.5C10.9 4 9.3 3 7.5 3A5.5 5.5 0 0 0 2 8.5c0 2.1 1.5 4 3 5.5l7 7Z"/>', |
||||
|
search: '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/>', |
||||
|
mic: '<rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="17" x2="12" y2="21"/>', |
||||
|
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>', |
||||
|
play: '<path d="M7 4.5 19 12 7 19.5Z"/>', |
||||
|
pause: '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>', |
||||
|
eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>', |
||||
|
eyeOff: '<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 7 10 7a13 13 0 0 1-2.16 3.19"/><path d="M6.6 6.6A13 13 0 0 0 2 11s3.5 7 10 7a9 9 0 0 0 5.4-1.6"/><path d="m2 2 20 20"/><path d="M9.5 9.5a3 3 0 0 0 4.2 4.2"/>', |
||||
|
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><line x1="21" y1="12" x2="9" y2="12"/>', |
||||
|
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', |
||||
|
x: '<line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/>', |
||||
|
next: '<path d="M6 5.5 15 12l-9 6.5Z"/><line x1="18" y1="5" x2="18" y2="19"/>', |
||||
|
prev: '<path d="M18 5.5 9 12l9 6.5Z"/><line x1="6" y1="5" x2="6" y2="19"/>', |
||||
|
shuffle: '<path d="M2 18h4l10-12h6"/><path d="M2 6h4l3 3.6"/><path d="m13 14.4 3 3.6h6"/><path d="m19 3 3 3-3 3"/><path d="m19 15 3 3-3 3"/>', |
||||
|
repeat: '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/>', |
||||
|
volume: '<path d="M11 5 6 9H2v6h4l5 4Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/>', |
||||
|
list: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/>', |
||||
|
grid: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>', |
||||
|
sparkles: '<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2 2M16.4 16.4l2 2M18.4 5.6l-2 2M7.6 16.4l-2 2"/><circle cx="12" cy="12" r="3.5"/>', |
||||
|
share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="10.5" x2="15.4" y2="6.5"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/>', |
||||
|
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.9"/><path d="M16 3.1a4 4 0 0 1 0 7.8"/>', |
||||
|
filter: '<path d="M3 5h18l-7 8v6l-4 2v-8Z"/>', |
||||
|
plus: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>', |
||||
|
check: '<path d="m4 12.5 5 5L20 6.5"/>', |
||||
|
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>', |
||||
|
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>', |
||||
|
video: '<rect x="2" y="6" width="13" height="12" rx="2"/><path d="m15 11 7-4v10l-7-4Z"/>', |
||||
|
chevDown: '<path d="m6 9 6 6 6-6"/>', |
||||
|
chevUp: '<path d="m6 15 6-6 6 6"/>', |
||||
|
pencil: '<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>', |
||||
|
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>', |
||||
|
dots: '<circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/>', |
||||
|
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 9 5-5 5 5"/><line x1="12" y1="4" x2="12" y2="16"/>', |
||||
|
thumb: '<path d="M7 10v12H3V10Z"/><path d="M7 10 12 2a2.5 2.5 0 0 1 2.5 2.5V9H21a2 2 0 0 1 2 2l-2 9a2 2 0 0 1-2 2H7"/>', |
||||
|
youtube: '<path d="M22 12c0-2.5-.2-3.7-.5-4.4a2.6 2.6 0 0 0-1.8-1.8C18.4 5.4 12 5.4 12 5.4s-6.4 0-7.7.4a2.6 2.6 0 0 0-1.8 1.8C2.2 8.3 2 9.5 2 12s.2 3.7.5 4.4a2.6 2.6 0 0 0 1.8 1.8c1.3.4 7.7.4 7.7.4s6.4 0 7.7-.4a2.6 2.6 0 0 0 1.8-1.8c.3-.7.5-1.9.5-4.4Z"/><path d="m10 9 5 3-5 3Z"/>', |
||||
|
tag: '<path d="M12 2H2v10l10 10 10-10Z"/><circle cx="6.5" cy="6.5" r="1.5"/>', |
||||
|
disc: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="2.5"/>', |
||||
|
calendarDot: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><circle cx="12" cy="15" r="1"/>', |
||||
|
/* Lucide-compatible icons for shadcn Pagination */ |
||||
|
chevronLeft: '<path d="M15 18l-6-6 6-6"/>', |
||||
|
chevronRight: '<path d="M9 18l6-6-6-6"/>', |
||||
|
chevronsLeft: '<path d="M11 17l-5-5 5-5"/><path d="M18 17l-5-5 5-5"/>', |
||||
|
chevronsRight: '<path d="M13 17l5-5-5-5"/><path d="M6 17l5-5-5-5"/>', |
||||
|
moreHorizontal: '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>', |
||||
|
/* مرکز ایمپورت محتوا */ |
||||
|
link: '<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.5-1.5"/>', |
||||
|
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 11 5 5 5-5"/><line x1="12" y1="4" x2="12" y2="16"/>', |
||||
|
refresh: '<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 4v5h-5"/>', |
||||
|
alert: '<path d="M10.3 3.3 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.3a2 2 0 0 0-3.4 0Z"/><line x1="12" y1="9" x2="12" y2="13.5"/><circle cx="12" cy="17" r=".6" fill="currentColor"/>', |
||||
|
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5.6 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.4A2 2 0 0 0 16.7 4H7.3a2 2 0 0 0-1.8 1.6Z"/>', |
||||
|
history: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7.5V12l3 2"/>', |
||||
|
stop: '<rect x="5" y="5" width="14" height="14" rx="2.5"/>', |
||||
|
clipboardCheck: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M9 4H6.5A1.5 1.5 0 0 0 5 5.5v15A1.5 1.5 0 0 0 6.5 22h11a1.5 1.5 0 0 0 1.5-1.5v-15A1.5 1.5 0 0 0 17.5 4H15"/><path d="m9 14 2 2 4-4"/>', |
||||
|
file: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5"/>', |
||||
|
external: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>', |
||||
|
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>', |
||||
|
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>', |
||||
|
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>', |
||||
|
folder: '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>', |
||||
|
} |
||||
|
|
||||
|
export type IconName = keyof typeof PATHS |
||||
|
|
||||
|
type IcProps = React.ComponentProps<'svg'> & { name: IconName } |
||||
|
|
||||
|
export function Ic({ name, ...rest }: IcProps) { |
||||
|
return ( |
||||
|
<svg |
||||
|
viewBox="0 0 24 24" |
||||
|
fill="none" |
||||
|
stroke="currentColor" |
||||
|
strokeWidth="1.8" |
||||
|
strokeLinecap="round" |
||||
|
strokeLinejoin="round" |
||||
|
dangerouslySetInnerHTML={{ __html: PATHS[name] }} |
||||
|
{...rest} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/* subtle geometric "girih" pattern overlay */ |
||||
|
export function Pattern() { |
||||
|
const id = useId().replace(/[:]/g, '') |
||||
|
return ( |
||||
|
<svg className="pattern" width="100%" height="100%"> |
||||
|
<defs> |
||||
|
<pattern id={`girih-${id}`} width="64" height="64" patternUnits="userSpaceOnUse"> |
||||
|
<path |
||||
|
d="M32 12 36.7 27.3 52 32 36.7 36.7 32 52 27.3 36.7 12 32 27.3 27.3Z" |
||||
|
fill="none" stroke="#fff" strokeWidth="0.9" |
||||
|
/> |
||||
|
<circle cx="32" cy="32" r="22" fill="none" stroke="#fff" strokeWidth="0.5" opacity="0.6" /> |
||||
|
<path |
||||
|
d="M0 0 8 0 0 8Z M64 0 64 8 56 0Z M0 64 0 56 8 64Z M64 64 56 64 64 56Z" |
||||
|
fill="#fff" opacity="0.35" |
||||
|
/> |
||||
|
</pattern> |
||||
|
</defs> |
||||
|
<rect width="100%" height="100%" fill={`url(#girih-${id})`} /> |
||||
|
</svg> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,205 @@ |
|||||
|
@import url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css'); |
||||
|
@import 'tailwindcss'; |
||||
|
|
||||
|
/* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */ |
||||
|
:root { |
||||
|
--background: #18191d; |
||||
|
--foreground: #ffffff; |
||||
|
--card: #22252c; |
||||
|
--card-foreground: #ffffff; |
||||
|
--popover: #1e2128; |
||||
|
--popover-foreground: #ffffff; |
||||
|
--primary: #6366f1; /* رنگ اصلی تم (قابل تغییر برای هر برند) */ |
||||
|
--primary-foreground: #ffffff; |
||||
|
--secondary: #2c303b; |
||||
|
--secondary-foreground: #ebebeb; |
||||
|
--muted: #22252c; |
||||
|
--muted-foreground: #8b8b8b; |
||||
|
--accent: #373c49; |
||||
|
--accent-foreground: #ffffff; |
||||
|
--destructive: #ef4444; |
||||
|
--border: rgba(255, 255, 255, 0.12); |
||||
|
--input: rgba(255, 255, 255, 0.06); |
||||
|
--ring: rgba(99, 102, 241, 0.55); |
||||
|
--radius: 14px; |
||||
|
|
||||
|
/* پالت سایدبار و پنل کناری */ |
||||
|
--sidebar: #131417; |
||||
|
--sidebar-foreground: #ebebeb; |
||||
|
--sidebar-primary: #6366f1; |
||||
|
--sidebar-primary-foreground: #ffffff; |
||||
|
--sidebar-accent: rgba(255, 255, 255, 0.06); |
||||
|
--sidebar-accent-foreground: #ffffff; |
||||
|
--sidebar-border: rgba(255, 255, 255, 0.07); |
||||
|
--sidebar-ring: rgba(99, 102, 241, 0.55); |
||||
|
--sidebar-width: 240px; |
||||
|
--panel-width: 384px; |
||||
|
|
||||
|
--red: #f87171; |
||||
|
--red-mid: #ef4444; |
||||
|
--red-dark: #dc2626; |
||||
|
--grey-1: #ebebeb; |
||||
|
--grey-2: #bcbcbc; |
||||
|
--grey-3: #8b8b8b; |
||||
|
--grey-4: #4d4d4d; |
||||
|
--border-soft: rgba(255, 255, 255, 0.07); |
||||
|
--g-primary: linear-gradient(135deg, #6366f1, #4338ca); |
||||
|
} |
||||
|
|
||||
|
@theme inline { |
||||
|
--font-sans: 'Vazirmatn', system-ui, sans-serif; |
||||
|
--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-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); |
||||
|
--color-red: var(--red); |
||||
|
--color-red-mid: var(--red-mid); |
||||
|
--color-red-dark: var(--red-dark); |
||||
|
--color-grey-1: var(--grey-1); |
||||
|
--color-grey-2: var(--grey-2); |
||||
|
--color-grey-3: var(--grey-3); |
||||
|
--color-grey-4: var(--grey-4); |
||||
|
--color-border-soft: var(--border-soft); |
||||
|
--radius-sm: 11px; |
||||
|
--radius-md: 13px; |
||||
|
--radius-lg: 15px; |
||||
|
--radius-xl: 19px; |
||||
|
} |
||||
|
|
||||
|
@layer base { |
||||
|
* { |
||||
|
@apply border-border outline-ring/50; |
||||
|
} |
||||
|
html { |
||||
|
direction: rtl; |
||||
|
} |
||||
|
body { |
||||
|
@apply bg-background text-foreground font-sans antialiased; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
::-webkit-scrollbar { |
||||
|
width: 8px; |
||||
|
height: 8px; |
||||
|
} |
||||
|
::-webkit-scrollbar-thumb { |
||||
|
background: rgba(255, 255, 255, 0.14); |
||||
|
border-radius: 8px; |
||||
|
} |
||||
|
::-webkit-scrollbar-track { |
||||
|
background: transparent; |
||||
|
} |
||||
|
|
||||
|
/* override autofill background (مرورگر زرد/سفید میکند) */ |
||||
|
input:-webkit-autofill, |
||||
|
input:-webkit-autofill:focus { |
||||
|
-webkit-box-shadow: 0 0 0 1000px var(--card) inset !important; |
||||
|
-webkit-text-fill-color: var(--foreground) !important; |
||||
|
caret-color: var(--foreground); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@layer utilities { |
||||
|
.bg-g-primary { |
||||
|
background: var(--g-primary); |
||||
|
} |
||||
|
.bg-g-ai { |
||||
|
background: var(--g-ai); |
||||
|
} |
||||
|
.bg-g-progress { |
||||
|
background: var(--g-progress); |
||||
|
} |
||||
|
.bg-g-mic { |
||||
|
background: var(--g-mic); |
||||
|
} |
||||
|
.no-scrollbar::-webkit-scrollbar { |
||||
|
display: none; |
||||
|
} |
||||
|
.no-scrollbar { |
||||
|
scrollbar-width: none; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* avatar gradient fills (person circles) — same palette as mockups */ |
||||
|
.av-1 { background: linear-gradient(135deg, #cd4343, #6a1c1c); } |
||||
|
.av-2 { background: linear-gradient(135deg, #4e5467, #272b37); } |
||||
|
.av-3 { background: linear-gradient(135deg, #9d2121, #3b4050); } |
||||
|
.av-4 { background: linear-gradient(135deg, #a71919, #46141a); } |
||||
|
.av-5 { background: linear-gradient(135deg, #5b6378, #36363c); } |
||||
|
.av-6 { background: linear-gradient(135deg, #862222, #202227); } |
||||
|
|
||||
|
/* cover gradient fills (cards / track covers) */ |
||||
|
.c-1 { background: linear-gradient(150deg, #cd4343, #5a1414); } |
||||
|
.c-2 { background: linear-gradient(150deg, #3b4050, #16181d); } |
||||
|
.c-3 { background: linear-gradient(150deg, #9d2121, #2c1230); } |
||||
|
.c-4 { background: linear-gradient(150deg, #4e5467, #1d2027); } |
||||
|
.c-5 { background: linear-gradient(150deg, #862222, #1f0f0f); } |
||||
|
.c-6 { background: linear-gradient(150deg, #a71919, #3b4050); } |
||||
|
|
||||
|
@keyframes slideIn { |
||||
|
from { transform: translateX(-24px); opacity: 0.4; } |
||||
|
to { transform: translateX(0); opacity: 1; } |
||||
|
} |
||||
|
@keyframes pop { |
||||
|
from { transform: scale(0.96); opacity: 0; } |
||||
|
to { transform: scale(1); opacity: 1; } |
||||
|
} |
||||
|
.anim-slide-in { animation: slideIn 0.22s ease; } |
||||
|
.anim-pop { animation: pop 0.18s ease; } |
||||
|
|
||||
|
/* ─── Sheet (drawer) enter animations — per side، بدون نیاز به پلاگین animate ─── */ |
||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } |
||||
|
@keyframes sheetInRight { from { transform: translateX(100%); } to { transform: translateX(0); } } |
||||
|
@keyframes sheetInLeft { from { transform: translateX(-100%); } to { transform: translateX(0); } } |
||||
|
@keyframes sheetInTop { from { transform: translateY(-100%); } to { transform: translateY(0); } } |
||||
|
@keyframes sheetInBottom { from { transform: translateY(100%); } to { transform: translateY(0); } } |
||||
|
@keyframes playerUp { from { transform: translateY(100%); opacity: 0.4; } to { transform: translateY(0); opacity: 1; } } |
||||
|
.anim-fade { animation: fadeIn 0.2s ease; } |
||||
|
.anim-sheet-right { animation: sheetInRight 0.28s cubic-bezier(0.32, 0.72, 0, 1); } |
||||
|
.anim-sheet-left { animation: sheetInLeft 0.28s cubic-bezier(0.32, 0.72, 0, 1); } |
||||
|
.anim-sheet-top { animation: sheetInTop 0.28s cubic-bezier(0.32, 0.72, 0, 1); } |
||||
|
.anim-sheet-bottom { animation: sheetInBottom 0.28s cubic-bezier(0.32, 0.72, 0, 1); } |
||||
|
|
||||
|
/* ─── R3: NProgress bar — gradient حسینیه، ۳px، بدون spinner ─── */ |
||||
|
#nprogress { pointer-events: none; } |
||||
|
#nprogress .bar { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
right: 0; |
||||
|
left: 0; |
||||
|
z-index: 1000; |
||||
|
height: 3px; |
||||
|
background: var(--g-progress); |
||||
|
} |
||||
|
#nprogress .peg { |
||||
|
display: block; |
||||
|
position: absolute; |
||||
|
left: 0; |
||||
|
width: 100px; |
||||
|
height: 100%; |
||||
|
box-shadow: 0 0 10px #ff4a4a, 0 0 5px #ff4a4a; |
||||
|
opacity: 1; |
||||
|
} |
||||
|
#nprogress .spinner { display: none; } |
||||
|
|
||||
@ -0,0 +1,37 @@ |
|||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* تبدیل تاریخهای میلادیِ بکاند (ISO datetime) به نمایشِ جلالی (هجری شمسی). |
||||
|
* |
||||
|
* بکاند فیلدهای updated_at/created_at را بهصورت ISO (UTC) میفرستد؛ کاربر باید |
||||
|
* تاریخ را به تقویم جلالی ببیند. از `Intl.DateTimeFormat` با تقویم `persian` |
||||
|
* استفاده میکنیم (بدون وابستگیِ خارجی) و ارقام را فارسی نمایش میدهیم. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
const DATE_FMT = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { |
||||
|
year: 'numeric', |
||||
|
month: '2-digit', |
||||
|
day: '2-digit', |
||||
|
}) |
||||
|
|
||||
|
const DATE_TIME_FMT = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { |
||||
|
year: 'numeric', |
||||
|
month: '2-digit', |
||||
|
day: '2-digit', |
||||
|
hour: '2-digit', |
||||
|
minute: '2-digit', |
||||
|
}) |
||||
|
|
||||
|
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵» (جلالی). ورودی خالی/نامعتبر → '—'. */ |
||||
|
export function toJalali(iso: string | null | undefined): string { |
||||
|
if (!iso) return '—' |
||||
|
const d = new Date(iso) |
||||
|
if (Number.isNaN(d.getTime())) return '—' |
||||
|
return DATE_FMT.format(d) |
||||
|
} |
||||
|
|
||||
|
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵، ۱۴:۳۰» (جلالی + ساعت). ورودی نامعتبر → '—'. */ |
||||
|
export function toJalaliDateTime(iso: string | null | undefined): string { |
||||
|
if (!iso) return '—' |
||||
|
const d = new Date(iso) |
||||
|
if (Number.isNaN(d.getTime())) return '—' |
||||
|
return DATE_TIME_FMT.format(d) |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
import { QueryClient } from '@tanstack/react-query' |
||||
|
|
||||
|
/* یک نمونهٔ سراسری QueryClient — staleTime منطقی، retry کم، بدون refetch روی focus. */ |
||||
|
export const queryClient = new QueryClient({ |
||||
|
defaultOptions: { |
||||
|
queries: { |
||||
|
staleTime: 60_000, |
||||
|
retry: 1, |
||||
|
refetchOnWindowFocus: false, |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
@ -0,0 +1,29 @@ |
|||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* خواندن پیکربندیِ محیط — تنها نقطهٔ ورود env برای کلاینت. |
||||
|
* |
||||
|
* چرا این لایه؟ |
||||
|
* Vite متغیرهای VITE_* را در «زمان build» داخل باندل حک میکند؛ یعنی برای |
||||
|
* تغییر آنها باید دوباره build کرد. در پروداکشن این الگو را با «runtime |
||||
|
* injection» جایگزین میکنیم: کانتینر هنگام استارت، مقادیر را از env واحد |
||||
|
* (.env.production → docker-compose) میخواند و در /config.js بهصورت |
||||
|
* window.__ENV__ مینویسد. این فایل قبل از باندل لود میشود. |
||||
|
* |
||||
|
* ترتیب اولویت: |
||||
|
* 1) window.__ENV__[key] ← پروداکشن (runtime، منبع واحد) |
||||
|
* 2) import.meta.env[key] ← توسعهٔ محلی (از .env در زمان dev/build) |
||||
|
* |
||||
|
* مقدار خالی/فاصلهای = «تنظیمنشده» تا مصرفکنندهها به پیشفرض خود برسند. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
type EnvMap = Record<string, string | undefined> |
||||
|
|
||||
|
/** خواندن یک متغیر env؛ undefined اگر تنظیم نشده یا خالی باشد. */ |
||||
|
export function getEnv(key: string): string | undefined { |
||||
|
const runtime = |
||||
|
typeof window !== 'undefined' |
||||
|
? (window as unknown as { __ENV__?: EnvMap }).__ENV__ |
||||
|
: undefined |
||||
|
|
||||
|
const value = runtime?.[key] ?? (import.meta.env as unknown as EnvMap)[key] |
||||
|
return typeof value === 'string' && value.trim() !== '' ? value : undefined |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
import { clsx, type ClassValue } from 'clsx' |
||||
|
import { twMerge } from 'tailwind-merge' |
||||
|
|
||||
|
export function cn(...inputs: ClassValue[]) { |
||||
|
return twMerge(clsx(inputs)) |
||||
|
} |
||||
|
|
||||
|
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹' |
||||
|
export const toFa = (n: number | string) => String(n).replace(/\d/g, (d) => FA_DIGITS[+d]) |
||||
|
|
||||
|
/** |
||||
|
* استخراج URL تصویر از thumbnailِ خامِ بکاند. |
||||
|
* ورودی میتواند رشته باشد یا آبجکتی مثل { sm, md, lg } یا { thumb, medium, large }. |
||||
|
*/ |
||||
|
export function thumbnailUrl(thumbnail: unknown): string | null { |
||||
|
if (!thumbnail) return null |
||||
|
if (typeof thumbnail === 'string') return thumbnail.trim() || null |
||||
|
if (typeof thumbnail === 'object') { |
||||
|
const o = thumbnail as Record<string, unknown> |
||||
|
const v = o.md ?? o.medium ?? o.lg ?? o.large ?? o.sm ?? o.thumb ?? Object.values(o)[0] |
||||
|
return v != null ? String(v).trim() || null : null |
||||
|
} |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
/** ثانیه → «م:ثث» با ارقام فارسی (مثلاً ۳:۰۵). مقادیر نامعتبر → ۰:۰۰ */ |
||||
|
export function formatTime(seconds: number): string { |
||||
|
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0 |
||||
|
const m = Math.floor(seconds / 60) |
||||
|
const s = Math.floor(seconds % 60) |
||||
|
return toFa(`${m}:${String(s).padStart(2, '0')}`) |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
import { StrictMode } from 'react' |
||||
|
import { createRoot } from 'react-dom/client' |
||||
|
import Providers from '@/app/providers' |
||||
|
import App from '@/app/App' |
||||
|
import './index.css' |
||||
|
|
||||
|
createRoot(document.getElementById('root')!).render( |
||||
|
<StrictMode> |
||||
|
<Providers> |
||||
|
<App /> |
||||
|
</Providers> |
||||
|
</StrictMode>, |
||||
|
) |
||||
@ -0,0 +1,49 @@ |
|||||
|
import { apiFetch } from '@/services/http' |
||||
|
import type { AuthUser, LoginInput } from '@/types' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* سرویس احراز هویت (Auth Service) |
||||
|
* |
||||
|
* • دارای پشتیبانی آماده از لاگین دمو برای تست سریع تمپلیت بدون نیاز به بکاند: |
||||
|
* ایمیل: admin@example.com | رمز عبور: admin |
||||
|
* • در حالت پروداکشن، درخواست به endpoint لاگین سرور ارسال میشود. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
export async function login(input: LoginInput): Promise<{ token: string; user: AuthUser }> { |
||||
|
// لاگین آزمایشی پیشفرض برای اجرای مستقیم تمپلیت
|
||||
|
if (input.email === 'admin@example.com' && input.password === 'admin') { |
||||
|
return { |
||||
|
token: 'demo-mock-jwt-token', |
||||
|
user: { |
||||
|
id: 1, |
||||
|
email: 'admin@example.com', |
||||
|
name: 'مدیر کل سیستم', |
||||
|
role: 'admin', |
||||
|
isStaff: true, |
||||
|
isSuperuser: true, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const res = await apiFetch<any>('auth/login', { |
||||
|
method: 'POST', |
||||
|
body: { email: input.email, password: input.password }, |
||||
|
skipAuth: true, |
||||
|
}) |
||||
|
|
||||
|
return { |
||||
|
token: res.token ?? res.access_token ?? res.accessToken ?? res.data?.token, |
||||
|
user: { |
||||
|
id: res.user?.id ?? res.user_id ?? res.data?.user?.id ?? 1, |
||||
|
email: res.user?.email ?? res.email ?? res.data?.user?.email ?? input.email, |
||||
|
name: res.user?.name ?? res.name ?? res.data?.user?.name ?? 'کاربر', |
||||
|
role: res.user?.role ?? res.role ?? 'admin', |
||||
|
isStaff: res.user?.isStaff ?? res.is_staff ?? true, |
||||
|
isSuperuser: res.user?.isSuperuser ?? res.is_superuser ?? false, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function getProfile(): Promise<AuthUser> { |
||||
|
return apiFetch<AuthUser>('auth/me') |
||||
|
} |
||||
@ -0,0 +1,126 @@ |
|||||
|
import { useAuthStore } from '@/stores/auth' |
||||
|
import { getEnv } from '@/lib/runtime-config' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* کلاینت HTTP واحد — تنها نقطهٔ اتصال کلاینت به بکاند. |
||||
|
* |
||||
|
* • base از VITE_API_BASE_URL ?? '/api' (در dev، '/api' را پراکسی Vite به |
||||
|
* بکاند هدایت میکند؛ نگاه کنید به vite.config.ts). |
||||
|
* • توکن DRF را بهصورت «Authorization: Token <key>» تزریق میکند. |
||||
|
* • خطاها را به ApiError استانداردِ قابلنمایش تبدیل میکند (با پیام فارسیِ |
||||
|
* قابلفهم استخراجشده از شکل خطای DRF). |
||||
|
* • روی 401 بهصورت خودکار استور احراز هویت را پاک میکند (خروج). |
||||
|
* |
||||
|
* React Query در همهجا queryFn/mutationFn خود را روی همین تابع میسازد. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
const BASE_URL = getEnv('VITE_API_BASE_URL') ?? '/api' |
||||
|
|
||||
|
/** خطای استاندارد API — status + پیام قابلنمایش + بدنهٔ خام برای دیباگ. */ |
||||
|
export class ApiError extends Error { |
||||
|
status: number |
||||
|
data: unknown |
||||
|
constructor(status: number, message: string, data?: unknown) { |
||||
|
super(message) |
||||
|
this.name = 'ApiError' |
||||
|
this.status = status |
||||
|
this.data = data |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** اتصال base و path با یک اسلش (بدون دوتاشدن یا حذف اسلش). */ |
||||
|
function buildUrl(path: string): string { |
||||
|
if (/^https?:\/\//i.test(path)) return path |
||||
|
return `${BASE_URL.replace(/\/$/, '')}/${path.replace(/^\//, '')}` |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* استخراج یک پیام انسانیِ فارسی/خام از شکل خطای DRF. |
||||
|
* |
||||
|
* DRF خطاها را به چند شکل برمیگرداند: |
||||
|
* { detail: "..." } → خطای سطحبالا |
||||
|
* { non_field_errors: ["..."] } → خطای اعتبارسنجی سراسری (مثل لاگین) |
||||
|
* { email: ["..."], password: ["..."] } → خطای فیلدی |
||||
|
* این تابع اولین پیام معنادار را برمیگرداند. |
||||
|
*/ |
||||
|
function extractMessage(data: unknown, fallback: string): string { |
||||
|
if (typeof data === 'string' && data.trim()) return data |
||||
|
if (data && typeof data === 'object') { |
||||
|
const obj = data as Record<string, unknown> |
||||
|
if (typeof obj.detail === 'string') return obj.detail |
||||
|
if (Array.isArray(obj.non_field_errors) && obj.non_field_errors.length) { |
||||
|
return String(obj.non_field_errors[0]) |
||||
|
} |
||||
|
/* اولین خطای فیلدی (آرایه یا رشته) */ |
||||
|
for (const value of Object.values(obj)) { |
||||
|
if (Array.isArray(value) && value.length) return String(value[0]) |
||||
|
if (typeof value === 'string' && value.trim()) return value |
||||
|
} |
||||
|
} |
||||
|
return fallback |
||||
|
} |
||||
|
|
||||
|
interface ApiFetchOptions extends Omit<RequestInit, 'body'> { |
||||
|
/** بدنهٔ درخواست؛ اگر شیء باشد به JSON تبدیل میشود. */ |
||||
|
body?: unknown |
||||
|
/** اگر true، هدر Authorization تزریق نمیشود (مثل خود لاگین). */ |
||||
|
skipAuth?: boolean |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* fetch مرکزی. خروجی JSON را بهصورت T برمیگرداند (یا undefined برای 204). |
||||
|
* در صورت خطا ApiError پرتاب میکند. |
||||
|
*/ |
||||
|
export async function apiFetch<T = unknown>(path: string, options: ApiFetchOptions = {}): Promise<T> { |
||||
|
const { body, skipAuth, headers, ...rest } = options |
||||
|
|
||||
|
const finalHeaders = new Headers(headers) |
||||
|
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData |
||||
|
if (body !== undefined && !isFormData && !finalHeaders.has('Content-Type')) { |
||||
|
finalHeaders.set('Content-Type', 'application/json') |
||||
|
} |
||||
|
|
||||
|
if (!skipAuth) { |
||||
|
const token = useAuthStore.getState().token |
||||
|
const authScheme = getEnv('VITE_AUTH_SCHEME') ?? 'Bearer' |
||||
|
if (token) finalHeaders.set('Authorization', `${authScheme} ${token}`) |
||||
|
} |
||||
|
|
||||
|
let res: Response |
||||
|
try { |
||||
|
res = await fetch(buildUrl(path), { |
||||
|
...rest, |
||||
|
headers: finalHeaders, |
||||
|
body: body === undefined ? undefined : isFormData ? (body as FormData) : JSON.stringify(body), |
||||
|
}) |
||||
|
} catch (err) { |
||||
|
/* لغو درخواست (AbortController) باید همان AbortError بماند تا React Query |
||||
|
آن را بهعنوان «کنسلشده» نادیده بگیرد، نه خطای شبکه. */ |
||||
|
if (err instanceof DOMException && err.name === 'AbortError') throw err |
||||
|
/* خطای شبکه (آفلاین/CORS/قطع اتصال) — status=0 */ |
||||
|
throw new ApiError(0, 'ارتباط با سرور برقرار نشد. اتصال اینترنت را بررسی کنید.') |
||||
|
} |
||||
|
|
||||
|
/* 401 → فقط اگر توکن داشتیم (واردشده بودیم) خروج خودکار میکنیم. |
||||
|
اگر اصلاً توکنی نبود، درخواست anonymous بوده و نیازی به clearAuth نیست. */ |
||||
|
if (res.status === 401 && useAuthStore.getState().token) { |
||||
|
useAuthStore.getState().clearAuth() |
||||
|
} |
||||
|
|
||||
|
const text = await res.text() |
||||
|
const data = text ? safeJson(text) : undefined |
||||
|
|
||||
|
if (!res.ok) { |
||||
|
throw new ApiError(res.status, extractMessage(data, `خطای سرور (${res.status})`), data) |
||||
|
} |
||||
|
|
||||
|
return data as T |
||||
|
} |
||||
|
|
||||
|
function safeJson(text: string): unknown { |
||||
|
try { |
||||
|
return JSON.parse(text) |
||||
|
} catch { |
||||
|
return text |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,154 @@ |
|||||
|
import { apiFetch } from '@/services/http' |
||||
|
import type { SampleItem, PaginatedResponse, PaginationParams } from '@/types' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* سرویس نمونه CRUD برای استفاده به عنوان الگو در سایر بخشهای پروژه |
||||
|
* |
||||
|
* در صورتی که بکاند واقعی در دسترس نباشد، این سرویس با دادههای شبیهسازیشده (Mock) |
||||
|
* کار میکند تا ظاهر، مودالها، فیلترها و جداول در محیط توسعه بدون قطعی کار کنند. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
// دادههای تستی محلی برای تست اولیه تمپلیت
|
||||
|
let mockItems: SampleItem[] = [ |
||||
|
{ |
||||
|
id: 'item-1', |
||||
|
title: 'سیستم گزارشگیری هوشمند', |
||||
|
category: 'نرمافزار', |
||||
|
status: 'published', |
||||
|
price: 1500000, |
||||
|
createdAt: '1403/06/15', |
||||
|
updatedAt: '1403/06/20', |
||||
|
description: 'ماژول استخراج خودکار گزارشهای تحلیلی و اکسل برای مدیران ارشد', |
||||
|
tags: ['گزارش', 'داشبورد', 'تحلیل'], |
||||
|
}, |
||||
|
{ |
||||
|
id: 'item-2', |
||||
|
title: 'درگاه پرداخت سپهر', |
||||
|
category: 'مالی', |
||||
|
status: 'published', |
||||
|
price: 3200000, |
||||
|
createdAt: '1403/06/10', |
||||
|
updatedAt: '1403/06/18', |
||||
|
description: 'اتصال خودکار به سوئیچ شاپرک با قابلیت تسویه آنی', |
||||
|
tags: ['مالی', 'پرداخت', 'بانک'], |
||||
|
}, |
||||
|
{ |
||||
|
id: 'item-3', |
||||
|
title: 'سرویس اعلانهای پیامکی', |
||||
|
category: 'ارتباطات', |
||||
|
status: 'draft', |
||||
|
price: 850000, |
||||
|
createdAt: '1403/06/05', |
||||
|
updatedAt: '1403/06/12', |
||||
|
description: 'ارسال پیامکهای ورود دو مرحلهای و رویدادهای مهم سیستم', |
||||
|
tags: ['پیامک', 'اعلان', 'امنیت'], |
||||
|
}, |
||||
|
{ |
||||
|
id: 'item-4', |
||||
|
title: 'اتصال به وبسرویس گمرک', |
||||
|
category: 'لجستیک', |
||||
|
status: 'archived', |
||||
|
price: 4900000, |
||||
|
createdAt: '1403/05/22', |
||||
|
updatedAt: '1403/06/01', |
||||
|
description: 'استعلام خودکار ترخیص کالا و کد رهگیری پستی', |
||||
|
tags: ['گمرک', 'حملونقل'], |
||||
|
}, |
||||
|
{ |
||||
|
id: 'item-5', |
||||
|
title: 'پنل پشتیبانی تیکتینگ', |
||||
|
category: 'پشتیبانی', |
||||
|
status: 'published', |
||||
|
price: 2100000, |
||||
|
createdAt: '1403/06/18', |
||||
|
updatedAt: '1403/06/21', |
||||
|
description: 'سیستم ثبت و پیگیری درخواستهای کاربران با امکان ضمیمه فایل', |
||||
|
tags: ['پشتیبانی', 'تیکت', 'CRM'], |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
export async function getSampleItems(params?: PaginationParams): Promise<PaginatedResponse<SampleItem>> { |
||||
|
try { |
||||
|
const query = new URLSearchParams() |
||||
|
if (params?.page) query.set('page', String(params.page)) |
||||
|
if (params?.pageSize) query.set('page_size', String(params.pageSize)) |
||||
|
if (params?.search) query.set('search', params.search) |
||||
|
|
||||
|
return await apiFetch<PaginatedResponse<SampleItem>>(`items/?${query.toString()}`) |
||||
|
} catch { |
||||
|
// Fallback to mock data
|
||||
|
let filtered = [...mockItems] |
||||
|
if (params?.search) { |
||||
|
const q = params.search.trim().toLowerCase() |
||||
|
filtered = filtered.filter( |
||||
|
(it) => it.title.toLowerCase().includes(q) || it.category.toLowerCase().includes(q), |
||||
|
) |
||||
|
} |
||||
|
const page = params?.page ?? 1 |
||||
|
const pageSize = params?.pageSize ?? 10 |
||||
|
const start = (page - 1) * pageSize |
||||
|
const paged = filtered.slice(start, start + pageSize) |
||||
|
|
||||
|
return { |
||||
|
items: paged, |
||||
|
total: filtered.length, |
||||
|
page, |
||||
|
pageSize, |
||||
|
totalPages: Math.ceil(filtered.length / pageSize), |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function getSampleItem(id: string): Promise<SampleItem> { |
||||
|
try { |
||||
|
return await apiFetch<SampleItem>(`items/${id}/`) |
||||
|
} catch { |
||||
|
const found = mockItems.find((it) => it.id === id) |
||||
|
if (!found) throw new Error('آیتم یافت نشد') |
||||
|
return found |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function createSampleItem(data: Omit<SampleItem, 'id' | 'createdAt' | 'updatedAt'>): Promise<SampleItem> { |
||||
|
try { |
||||
|
return await apiFetch<SampleItem>('items/', { |
||||
|
method: 'POST', |
||||
|
body: data, |
||||
|
}) |
||||
|
} catch { |
||||
|
const newItem: SampleItem = { |
||||
|
...data, |
||||
|
id: `item-${Date.now()}`, |
||||
|
createdAt: new Date().toLocaleDateString('fa-IR'), |
||||
|
updatedAt: new Date().toLocaleDateString('fa-IR'), |
||||
|
} |
||||
|
mockItems = [newItem, ...mockItems] |
||||
|
return newItem |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function updateSampleItem(id: string, data: Partial<SampleItem>): Promise<SampleItem> { |
||||
|
try { |
||||
|
return await apiFetch<SampleItem>(`items/${id}/`, { |
||||
|
method: 'PUT', |
||||
|
body: data, |
||||
|
}) |
||||
|
} catch { |
||||
|
const idx = mockItems.findIndex((it) => it.id === id) |
||||
|
if (idx === -1) throw new Error('آیتم یافت نشد') |
||||
|
mockItems[idx] = { |
||||
|
...mockItems[idx], |
||||
|
...data, |
||||
|
updatedAt: new Date().toLocaleDateString('fa-IR'), |
||||
|
} |
||||
|
return mockItems[idx] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function deleteSampleItem(id: string): Promise<void> { |
||||
|
try { |
||||
|
await apiFetch(`items/${id}/`, { method: 'DELETE' }) |
||||
|
} catch { |
||||
|
mockItems = mockItems.filter((it) => it.id !== id) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
import { apiFetch } from '@/services/http' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* سرویس آپلود مشترک فرمهای داشبورد. |
||||
|
* |
||||
|
* فایل را به endpoint داشبورد میفرستد و یک URL برمیگرداند. این URL را بهصورت |
||||
|
* رشته در بدنهٔ ساخت/ویرایش (مثل avatar_url ناشر یا thumbnail_url آلبوم) میفرستیم؛ |
||||
|
* بکاند رشته را به filer.Image دائمی تبدیل میکند. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
export interface UploadResult { |
||||
|
success: boolean |
||||
|
url: string |
||||
|
name: string |
||||
|
size: string |
||||
|
} |
||||
|
|
||||
|
/** آپلود یک فایل به داشبورد و دریافت URLِ قابلارسال در فرمها. */ |
||||
|
export async function uploadMedia(file: File): Promise<UploadResult> { |
||||
|
const form = new FormData() |
||||
|
form.append('file', file) |
||||
|
// body=FormData → apiFetch هدر Content-Type نمیگذارد و توکن را تزریق میکند.
|
||||
|
return apiFetch<UploadResult>('/hussainya/dashboard/upload-media/', { |
||||
|
method: 'POST', |
||||
|
body: form, |
||||
|
}) |
||||
|
} |
||||
@ -0,0 +1,120 @@ |
|||||
|
import { apiFetch } from '@/services/http' |
||||
|
import type { User, PaginatedResponse, PaginationParams } from '@/types' |
||||
|
|
||||
|
let mockUsers: User[] = [ |
||||
|
{ |
||||
|
id: '1', |
||||
|
name: 'علیرضا محمدی', |
||||
|
email: 'alireza@example.com', |
||||
|
phone: '09121234567', |
||||
|
role: 'admin', |
||||
|
status: 'active', |
||||
|
createdAt: '1403/01/10', |
||||
|
lastActive: 'هماکنون آنلاین', |
||||
|
bio: 'مدیر ارشد فنی و توسعهدهنده سیستم', |
||||
|
}, |
||||
|
{ |
||||
|
id: '2', |
||||
|
name: 'سارا رضایی', |
||||
|
email: 'sara@example.com', |
||||
|
phone: '09129876543', |
||||
|
role: 'manager', |
||||
|
status: 'active', |
||||
|
createdAt: '1403/02/15', |
||||
|
lastActive: '۲ ساعت پیش', |
||||
|
bio: 'مدیر محصول و سرپرست تیم طراحی', |
||||
|
}, |
||||
|
{ |
||||
|
id: '3', |
||||
|
name: 'محمد حسینی', |
||||
|
email: 'hosseini@example.com', |
||||
|
phone: '09135554433', |
||||
|
role: 'editor', |
||||
|
status: 'pending', |
||||
|
createdAt: '1403/05/20', |
||||
|
lastActive: 'دیروز', |
||||
|
bio: 'کارشناس ثبت محتوا و بررسی مستندات', |
||||
|
}, |
||||
|
{ |
||||
|
id: '4', |
||||
|
name: 'مریم کمالی', |
||||
|
email: 'kamali@example.com', |
||||
|
phone: '09351112233', |
||||
|
role: 'viewer', |
||||
|
status: 'inactive', |
||||
|
createdAt: '1403/04/01', |
||||
|
lastActive: '۱ هفته پیش', |
||||
|
bio: 'حسابدار و ناظر مالی پروژه', |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
export async function getUsers(params?: PaginationParams): Promise<PaginatedResponse<User>> { |
||||
|
try { |
||||
|
const query = new URLSearchParams() |
||||
|
if (params?.page) query.set('page', String(params.page)) |
||||
|
if (params?.pageSize) query.set('page_size', String(params.pageSize)) |
||||
|
if (params?.search) query.set('search', params.search) |
||||
|
return await apiFetch<PaginatedResponse<User>>(`users/?${query.toString()}`) |
||||
|
} catch { |
||||
|
let filtered = [...mockUsers] |
||||
|
if (params?.search) { |
||||
|
const q = params.search.trim().toLowerCase() |
||||
|
filtered = filtered.filter( |
||||
|
(u) => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q), |
||||
|
) |
||||
|
} |
||||
|
const page = params?.page ?? 1 |
||||
|
const pageSize = params?.pageSize ?? 10 |
||||
|
const start = (page - 1) * pageSize |
||||
|
return { |
||||
|
items: filtered.slice(start, start + pageSize), |
||||
|
total: filtered.length, |
||||
|
page, |
||||
|
pageSize, |
||||
|
totalPages: Math.ceil(filtered.length / pageSize), |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function getUser(id: string): Promise<User> { |
||||
|
try { |
||||
|
return await apiFetch<User>(`users/${id}/`) |
||||
|
} catch { |
||||
|
const found = mockUsers.find((u) => u.id === id) |
||||
|
if (!found) throw new Error('کاربر یافت نشد') |
||||
|
return found |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function createUser(data: Omit<User, 'id' | 'createdAt'>): Promise<User> { |
||||
|
try { |
||||
|
return await apiFetch<User>('users/', { method: 'POST', body: data }) |
||||
|
} catch { |
||||
|
const newUser: User = { |
||||
|
...data, |
||||
|
id: String(Date.now()), |
||||
|
createdAt: new Date().toLocaleDateString('fa-IR'), |
||||
|
} |
||||
|
mockUsers = [newUser, ...mockUsers] |
||||
|
return newUser |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function updateUser(id: string, data: Partial<User>): Promise<User> { |
||||
|
try { |
||||
|
return await apiFetch<User>(`users/${id}/`, { method: 'PUT', body: data }) |
||||
|
} catch { |
||||
|
const idx = mockUsers.findIndex((u) => u.id === id) |
||||
|
if (idx === -1) throw new Error('کاربر یافت نشد') |
||||
|
mockUsers[idx] = { ...mockUsers[idx], ...data } |
||||
|
return mockUsers[idx] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function deleteUser(id: string): Promise<void> { |
||||
|
try { |
||||
|
await apiFetch(`users/${id}/`, { method: 'DELETE' }) |
||||
|
} catch { |
||||
|
mockUsers = mockUsers.filter((u) => u.id !== id) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,40 @@ |
|||||
|
import { create } from 'zustand' |
||||
|
import { persist } from 'zustand/middleware' |
||||
|
import type { AuthUser } from '@/types' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* استور احراز هویت داشبورد. |
||||
|
* |
||||
|
* با persist روی localStorage نگهداری میشود تا رفرشِ صفحه کاربر را خارج نکند. |
||||
|
* چون localStorage همگام (sync) است، هیدریت در همان لحظهٔ ساخت استور انجام |
||||
|
* میشود و هیچ «فلش»ی بین «واردنشده» و «واردشده» رخ نمیدهد. |
||||
|
* |
||||
|
* این استور تنها منبعِ حقیقتِ توکن است: http.ts برای تزریق هدر Authorization و |
||||
|
* برای logout خودکار روی 401 مستقیماً از getState/setState آن استفاده میکند. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
interface AuthState { |
||||
|
token: string | null |
||||
|
user: AuthUser | null |
||||
|
isAuthenticated: boolean |
||||
|
/** ذخیرهٔ نتیجهٔ لاگین موفق (توکن + کاربر) */ |
||||
|
setAuth: (token: string, user: AuthUser) => void |
||||
|
/** پاککردن کامل احراز هویت (خروج یا 401) */ |
||||
|
clearAuth: () => void |
||||
|
} |
||||
|
|
||||
|
export const useAuthStore = create<AuthState>()( |
||||
|
persist( |
||||
|
(set) => ({ |
||||
|
token: null, |
||||
|
user: null, |
||||
|
isAuthenticated: false, |
||||
|
setAuth: (token, user) => set({ token, user, isAuthenticated: true }), |
||||
|
clearAuth: () => set({ token: null, user: null, isAuthenticated: false }), |
||||
|
}), |
||||
|
{ |
||||
|
name: 'dashboard-auth', |
||||
|
partialize: (s) => ({ token: s.token, user: s.user, isAuthenticated: s.isAuthenticated }), |
||||
|
}, |
||||
|
), |
||||
|
) |
||||
@ -0,0 +1,23 @@ |
|||||
|
import { create } from 'zustand' |
||||
|
|
||||
|
interface CommonFiltersState { |
||||
|
status: string[] |
||||
|
category: string | null |
||||
|
sortBy: string |
||||
|
sortOrder: 'asc' | 'desc' |
||||
|
setStatus: (status: string[]) => void |
||||
|
setCategory: (category: string | null) => void |
||||
|
setSort: (by: string, order: 'asc' | 'desc') => void |
||||
|
reset: () => void |
||||
|
} |
||||
|
|
||||
|
export const useFiltersStore = create<CommonFiltersState>((set) => ({ |
||||
|
status: [], |
||||
|
category: null, |
||||
|
sortBy: 'createdAt', |
||||
|
sortOrder: 'desc', |
||||
|
setStatus: (status) => set({ status }), |
||||
|
setCategory: (category) => set({ category }), |
||||
|
setSort: (sortBy, sortOrder) => set({ sortBy, sortOrder }), |
||||
|
reset: () => set({ status: [], category: null, sortBy: 'createdAt', sortOrder: 'desc' }), |
||||
|
})) |
||||
@ -0,0 +1,27 @@ |
|||||
|
import { create } from 'zustand' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* وضعیت Dialog جستجوی سراسری — مستقل از Topbar تا از App.tsx هم |
||||
|
* (برای Ctrl+K / Cmd+K) قابل کنترل باشد. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
interface GlobalSearchState { |
||||
|
open: boolean |
||||
|
/** درخواست شروع خودکار ضبط صدا هنگام باز شدن (از میکروفونِ تاپبار). */ |
||||
|
startVoice: boolean |
||||
|
setOpen: (open: boolean) => void |
||||
|
toggle: () => void |
||||
|
/** باز کردن Dialog همراه با شروع فوری جستجوی صوتی. */ |
||||
|
openWithVoice: () => void |
||||
|
/** مصرفشدن نیتِ شروع صوتی (پس از استارت در Dialog). */ |
||||
|
clearStartVoice: () => void |
||||
|
} |
||||
|
|
||||
|
export const useGlobalSearchStore = create<GlobalSearchState>((set) => ({ |
||||
|
open: false, |
||||
|
startVoice: false, |
||||
|
setOpen: (open) => set(open ? { open } : { open: false, startVoice: false }), |
||||
|
toggle: () => set((s) => (s.open ? { open: false, startVoice: false } : { open: true })), |
||||
|
openWithVoice: () => set({ open: true, startVoice: true }), |
||||
|
clearStartVoice: () => set({ startVoice: false }), |
||||
|
})) |
||||
@ -0,0 +1,30 @@ |
|||||
|
import { create } from 'zustand' |
||||
|
|
||||
|
/** حالت نمایش هر جدول مدیریتی: list (جدول) یا grid (کارت) */ |
||||
|
export type ViewMode = 'list' | 'grid' |
||||
|
|
||||
|
/** کلید صفحهای که حالت نمایش مستقل دارد */ |
||||
|
export type ViewKey = 'songs' | 'albums' | 'providers' | 'genres' | 'tags' | 'publishers' | 'please' | 'collections' |
||||
|
|
||||
|
interface ViewModeState { |
||||
|
/** حالت نمایش به ازای هر صفحه — پیشفرض همه list */ |
||||
|
views: Record<ViewKey, ViewMode> |
||||
|
setView: (key: ViewKey, mode: ViewMode) => void |
||||
|
} |
||||
|
|
||||
|
export const useViewMode = create<ViewModeState>((set) => ({ |
||||
|
views: { songs: 'list', albums: 'list', providers: 'list', genres: 'list', tags: 'list', publishers: 'list', please: 'list', collections: 'list' }, |
||||
|
setView: (key, mode) => |
||||
|
set((s) => ({ views: { ...s.views, [key]: mode } })), |
||||
|
})) |
||||
|
|
||||
|
/** |
||||
|
* هوک کمکی برای استفادهٔ راحت در صفحهها. |
||||
|
* مقدار حالت نمایش و setter مخصوص همان صفحه را برمیگرداند: |
||||
|
* const [view, setView] = useView('albums') |
||||
|
*/ |
||||
|
export function useView(key: ViewKey): readonly [ViewMode, (mode: ViewMode) => void] { |
||||
|
const view = useViewMode((s) => s.views[key]) |
||||
|
const setView = useViewMode((s) => s.setView) |
||||
|
return [view, (mode: ViewMode) => setView(key, mode)] as const |
||||
|
} |
||||
@ -0,0 +1,84 @@ |
|||||
|
/* ── احراز هویت (Auth) ── */ |
||||
|
|
||||
|
export interface AuthUser { |
||||
|
id: number | string |
||||
|
email: string |
||||
|
name: string |
||||
|
role?: string |
||||
|
isStaff?: boolean |
||||
|
isSuperuser?: boolean |
||||
|
} |
||||
|
|
||||
|
export interface LoginInput { |
||||
|
email: string |
||||
|
password: string |
||||
|
} |
||||
|
|
||||
|
export interface LoginResponse { |
||||
|
token: string |
||||
|
user: AuthUser |
||||
|
} |
||||
|
|
||||
|
/* ── پاسخهای استاندارد سرور و صفحهبندی (Pagination & API) ── */ |
||||
|
|
||||
|
export interface PaginationParams { |
||||
|
page?: number |
||||
|
pageSize?: number |
||||
|
search?: string |
||||
|
sortBy?: string |
||||
|
sortOrder?: 'asc' | 'desc' |
||||
|
} |
||||
|
|
||||
|
export interface PaginatedResponse<T> { |
||||
|
items: T[] |
||||
|
total: number |
||||
|
page: number |
||||
|
pageSize: number |
||||
|
totalPages: number |
||||
|
} |
||||
|
|
||||
|
export interface ApiResponse<T> { |
||||
|
data: T |
||||
|
message?: string |
||||
|
success: boolean |
||||
|
} |
||||
|
|
||||
|
export interface ApiError { |
||||
|
message: string |
||||
|
statusCode: number |
||||
|
errors?: Record<string, string[]> |
||||
|
} |
||||
|
|
||||
|
/* ── مدل کاربر نمونه (User Entity) ── */ |
||||
|
|
||||
|
export type UserStatus = 'active' | 'inactive' | 'pending' |
||||
|
export type UserRole = 'admin' | 'manager' | 'editor' | 'viewer' |
||||
|
|
||||
|
export interface User { |
||||
|
id: string |
||||
|
name: string |
||||
|
email: string |
||||
|
phone?: string |
||||
|
role: UserRole |
||||
|
status: UserStatus |
||||
|
avatar?: string | null |
||||
|
createdAt: string |
||||
|
lastActive?: string |
||||
|
bio?: string |
||||
|
} |
||||
|
|
||||
|
/* ── مدل آیتم نمونه (Sample CRUD Entity) ── */ |
||||
|
|
||||
|
export type ItemStatus = 'published' | 'draft' | 'archived' |
||||
|
|
||||
|
export interface SampleItem { |
||||
|
id: string |
||||
|
title: string |
||||
|
description?: string |
||||
|
category: string |
||||
|
status: ItemStatus |
||||
|
price?: number |
||||
|
createdAt: string |
||||
|
updatedAt: string |
||||
|
tags?: string[] |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Write
Preview
Loading…
Cancel
Save
Reference in new issue