Browse Source

feat: initialize panel project structure with UI components, routing, and services

master
commit
8c51785703
  1. 26
      .dockerignore
  2. 6
      .env.example
  3. 11
      .env.production.example
  4. 31
      .gitignore
  5. 39
      Dockerfile
  6. 163
      README.md
  7. 39
      docker-compose.yml
  8. 27
      docker/40-config-js.sh
  9. 7
      docker/nginx/00-upstream-map.conf
  10. 76
      docker/nginx/default.conf.template
  11. 23
      eslint.config.js
  12. 17
      index.html
  13. 4873
      package-lock.json
  14. 61
      package.json
  15. 7
      public/config.js
  16. 7
      public/favicon.svg
  17. 7
      public/hosseinieh-icon.svg
  18. 24
      public/icons.svg
  19. 2
      runner.sh
  20. 57
      src/app/App.tsx
  21. 30
      src/app/nprogress.tsx
  22. 20
      src/app/providers.tsx
  23. 38
      src/app/router.tsx
  24. 147
      src/components/layout/AppSidebar.tsx
  25. 129
      src/components/layout/PanelSlot.tsx
  26. 39
      src/components/layout/Topbar.tsx
  27. 163
      src/components/shared/AppImage.tsx
  28. 44
      src/components/shared/CardsSkeleton.tsx
  29. 64
      src/components/shared/DeleteConfirmDialog.tsx
  30. 21
      src/components/shared/IconButton.tsx
  31. 34
      src/components/shared/PanelHeader.tsx
  32. 21
      src/components/shared/SectionHeader.tsx
  33. 106
      src/components/shared/TablePagination.tsx
  34. 93
      src/components/shared/TableRowsSkeleton.tsx
  35. 50
      src/components/shared/ViewToggle.tsx
  36. 41
      src/components/ui/accordion.tsx
  37. 53
      src/components/ui/alert-dialog.tsx
  38. 35
      src/components/ui/avatar.tsx
  39. 26
      src/components/ui/badge.tsx
  40. 36
      src/components/ui/button.tsx
  41. 243
      src/components/ui/carousel.tsx
  42. 23
      src/components/ui/checkbox.tsx
  43. 154
      src/components/ui/command.tsx
  44. 67
      src/components/ui/dialog.tsx
  45. 67
      src/components/ui/dropdown-menu.tsx
  46. 16
      src/components/ui/input.tsx
  47. 190
      src/components/ui/multi-select.tsx
  48. 111
      src/components/ui/pagination.tsx
  49. 25
      src/components/ui/popover.tsx
  50. 63
      src/components/ui/select.tsx
  51. 8
      src/components/ui/separator.tsx
  52. 89
      src/components/ui/sheet.tsx
  53. 476
      src/components/ui/sidebar.tsx
  54. 155
      src/components/ui/single-select.tsx
  55. 13
      src/components/ui/skeleton.tsx
  56. 19
      src/components/ui/slider.tsx
  57. 19
      src/components/ui/switch.tsx
  58. 33
      src/components/ui/table.tsx
  59. 38
      src/components/ui/tabs.tsx
  60. 16
      src/components/ui/textarea.tsx
  61. 34
      src/components/ui/tooltip.tsx
  62. 33
      src/config/navigation.ts
  63. 17
      src/config/site.ts
  64. 154
      src/features/auth/LoginPage.tsx
  65. 18
      src/features/auth/RequireAuth.tsx
  66. 40
      src/features/auth/use-auth.ts
  67. 208
      src/features/dashboard/DashboardHome.tsx
  68. 86
      src/features/filters/FilterDrawer.tsx
  69. 103
      src/features/global-search/GlobalSearchDialog.tsx
  70. 44
      src/features/global-search/GlobalSearchTrigger.tsx
  71. 217
      src/features/items/ItemsPage.tsx
  72. 235
      src/features/items/SampleItemModal.tsx
  73. 113
      src/features/panels/SampleDetailPanel.tsx
  74. 125
      src/features/panels/UserDetailPanel.tsx
  75. 121
      src/features/settings/SettingsPage.tsx
  76. 544
      src/features/showcase/ComponentsShowcase.tsx
  77. 224
      src/features/users/UserModal.tsx
  78. 230
      src/features/users/UsersPage.tsx
  79. 43
      src/hooks/use-carousel-infinite.ts
  80. 16
      src/hooks/use-debounced-value.ts
  81. 49
      src/hooks/use-infinite-scroll.ts
  82. 32
      src/hooks/use-mobile.ts
  83. 106
      src/hooks/use-panel-routing.ts
  84. 110
      src/icons.tsx
  85. 205
      src/index.css
  86. 37
      src/lib/jalali.ts
  87. 12
      src/lib/query-client.ts
  88. 29
      src/lib/runtime-config.ts
  89. 32
      src/lib/utils.ts
  90. 13
      src/main.tsx
  91. 49
      src/services/auth.ts
  92. 126
      src/services/http.ts
  93. 154
      src/services/sample-api.ts
  94. 27
      src/services/upload.ts
  95. 120
      src/services/users-api.ts
  96. 40
      src/stores/auth.ts
  97. 23
      src/stores/filters.ts
  98. 27
      src/stores/global-search.ts
  99. 30
      src/stores/view-mode.ts
  100. 84
      src/types.ts

26
.dockerignore

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

6
.env.example

@ -0,0 +1,6 @@
# ─── تنظیمات اتصال به API و بک‌اند (توسعه محلی) ───
# آدرس پایه API که کلاینت صدا می‌زند (پیش‌فرض: /api)
VITE_API_BASE_URL=/api
# مقصد پراکسی توسعه Vite برای هدایت درخواست‌های /api به سرور بک‌اند
VITE_API_TARGET=http://localhost:8000

11
.env.production.example

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

31
.gitignore

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

39
Dockerfile

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

163
README.md

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

39
docker-compose.yml

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

27
docker/40-config-js.sh

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

7
docker/nginx/00-upstream-map.conf

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

76
docker/nginx/default.conf.template

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

23
eslint.config.js

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

17
index.html

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

61
package.json

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

7
public/config.js

@ -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__ = {}

7
public/favicon.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>

7
public/hosseinieh-icon.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>

24
public/icons.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>

2
runner.sh

@ -0,0 +1,2 @@
git pull origin master && docker compose up -d --build

57
src/app/App.tsx

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

30
src/app/nprogress.tsx

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

20
src/app/providers.tsx

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

38
src/app/router.tsx

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

147
src/components/layout/AppSidebar.tsx

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

129
src/components/layout/PanelSlot.tsx

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

39
src/components/layout/Topbar.tsx

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

163
src/components/shared/AppImage.tsx

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

44
src/components/shared/CardsSkeleton.tsx

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

64
src/components/shared/DeleteConfirmDialog.tsx

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

21
src/components/shared/IconButton.tsx

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

34
src/components/shared/PanelHeader.tsx

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

21
src/components/shared/SectionHeader.tsx

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

106
src/components/shared/TablePagination.tsx

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

93
src/components/shared/TableRowsSkeleton.tsx

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

50
src/components/shared/ViewToggle.tsx

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

41
src/components/ui/accordion.tsx

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

53
src/components/ui/alert-dialog.tsx

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

35
src/components/ui/avatar.tsx

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

26
src/components/ui/badge.tsx

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

36
src/components/ui/button.tsx

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

243
src/components/ui/carousel.tsx

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

23
src/components/ui/checkbox.tsx

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

154
src/components/ui/command.tsx

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

67
src/components/ui/dialog.tsx

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

67
src/components/ui/dropdown-menu.tsx

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

16
src/components/ui/input.tsx

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

190
src/components/ui/multi-select.tsx

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

111
src/components/ui/pagination.tsx

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

25
src/components/ui/popover.tsx

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

63
src/components/ui/select.tsx

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

8
src/components/ui/separator.tsx

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

89
src/components/ui/sheet.tsx

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

476
src/components/ui/sidebar.tsx

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

155
src/components/ui/single-select.tsx

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

13
src/components/ui/skeleton.tsx

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

19
src/components/ui/slider.tsx

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

19
src/components/ui/switch.tsx

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

33
src/components/ui/table.tsx

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

38
src/components/ui/tabs.tsx

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

16
src/components/ui/textarea.tsx

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

34
src/components/ui/tooltip.tsx

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

33
src/config/navigation.ts

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

17
src/config/site.ts

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

154
src/features/auth/LoginPage.tsx

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

18
src/features/auth/RequireAuth.tsx

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

40
src/features/auth/use-auth.ts

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

208
src/features/dashboard/DashboardHome.tsx

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

86
src/features/filters/FilterDrawer.tsx

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

103
src/features/global-search/GlobalSearchDialog.tsx

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

44
src/features/global-search/GlobalSearchTrigger.tsx

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

217
src/features/items/ItemsPage.tsx

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

235
src/features/items/SampleItemModal.tsx

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

113
src/features/panels/SampleDetailPanel.tsx

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

125
src/features/panels/UserDetailPanel.tsx

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

121
src/features/settings/SettingsPage.tsx

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

544
src/features/showcase/ComponentsShowcase.tsx

@ -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">&lt;Ic name="..." /&gt;</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>
)
}

224
src/features/users/UserModal.tsx

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

230
src/features/users/UsersPage.tsx

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

43
src/hooks/use-carousel-infinite.ts

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

16
src/hooks/use-debounced-value.ts

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

49
src/hooks/use-infinite-scroll.ts

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

32
src/hooks/use-mobile.ts

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

106
src/hooks/use-panel-routing.ts

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

110
src/icons.tsx

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

205
src/index.css

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

37
src/lib/jalali.ts

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

12
src/lib/query-client.ts

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

29
src/lib/runtime-config.ts

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

32
src/lib/utils.ts

@ -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')}`)
}

13
src/main.tsx

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

49
src/services/auth.ts

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

126
src/services/http.ts

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

154
src/services/sample-api.ts

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

27
src/services/upload.ts

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

120
src/services/users-api.ts

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

40
src/stores/auth.ts

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

23
src/stores/filters.ts

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

27
src/stores/global-search.ts

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

30
src/stores/view-mode.ts

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

84
src/types.ts

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

Loading…
Cancel
Save