feat: initialize project with Symfony, React, and Tailwind CSS setup
- Add package.json with development and production dependencies - Create postcss.config.js for Tailwind CSS integration - Implement AdminController for handling admin routes - Add admin index template with React root element - Create base template for consistent layout - Configure TypeScript with tsconfig.json - Set up Webpack configuration for asset management
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import AdminLayout from './components/layout/AdminLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AdminLayout />
|
||||
</PrivateRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import Topbar from './Topbar';
|
||||
|
||||
export default function AdminLayout() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[#f1f5f9]">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
UserGroupIcon,
|
||||
HeartIcon,
|
||||
BuildingOffice2Icon,
|
||||
CalendarDaysIcon,
|
||||
CreditCardIcon,
|
||||
BanknotesIcon,
|
||||
UsersIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
StarIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
TagIcon,
|
||||
DocumentTextIcon,
|
||||
KeyIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronLeftIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ArrowLeftOnRectangleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useUiStore } from '../../stores/uiStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
const sections = [
|
||||
{
|
||||
label: 'عمومی',
|
||||
items: [
|
||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
||||
{ to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' },
|
||||
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' },
|
||||
{ to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیکها' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'مدیریت',
|
||||
items: [
|
||||
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبتها' },
|
||||
{ to: '/admin/payments', icon: CreditCardIcon, label: 'پرداختها' },
|
||||
{ to: '/admin/settlements', icon: BanknotesIcon, label: 'تسویهحساب' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'محتوا',
|
||||
items: [
|
||||
{ to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' },
|
||||
{ to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' },
|
||||
{ to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' },
|
||||
{ to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'سیستم',
|
||||
items: [
|
||||
{ to: '/admin/categories', icon: TagIcon, label: 'دستهبندیها' },
|
||||
{ to: '/admin/representations', icon: UsersIcon, label: 'نمایندگان' },
|
||||
{ to: '/admin/secretaries', icon: KeyIcon, label: 'منشیها' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const open = useUiStore((s) => s.sidebarOpen);
|
||||
const toggle = useUiStore((s) => s.toggleSidebar);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{ transition: 'width 300ms cubic-bezier(0.4,0,0.2,1)' }}
|
||||
className={`${open ? 'w-[260px]' : 'w-[72px]'} shrink-0 bg-[#0f172a] flex flex-col h-screen overflow-hidden`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between px-4 py-5 border-b border-slate-700/50">
|
||||
{open && (
|
||||
<span className="text-white font-bold text-lg tracking-tight">ClinicPro</span>
|
||||
)}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-md hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
{open ? <ChevronRightIcon className="w-5 h-5" /> : <ChevronLeftIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
{open && (
|
||||
<div className="px-3 py-3 border-b border-slate-700/50">
|
||||
<div className="flex items-center gap-2 bg-slate-800 rounded-lg px-3 py-2">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجوی سریع..."
|
||||
className="bg-transparent text-sm text-slate-300 placeholder-slate-500 outline-none w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 space-y-1 scrollbar-thin">
|
||||
{sections.map((section) => (
|
||||
<div key={section.label} className="mb-2">
|
||||
{open && (
|
||||
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider px-4 py-1">
|
||||
{section.label}
|
||||
</p>
|
||||
)}
|
||||
{section.items.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 mx-2 px-3 py-2.5 rounded-lg text-sm transition-colors group ${
|
||||
isActive
|
||||
? 'bg-primary-500/15 text-primary-400 font-semibold border-r-4 border-primary-500'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
|
||||
}`
|
||||
}
|
||||
title={!open ? label : undefined}
|
||||
>
|
||||
<Icon className="w-5 h-5 shrink-0" />
|
||||
{open && <span>{label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User card */}
|
||||
<div className="border-t border-slate-700/50 p-3">
|
||||
{open ? (
|
||||
<div className="flex items-center gap-3 bg-slate-800 rounded-xl p-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-bold shrink-0">
|
||||
A
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">Admin</p>
|
||||
<p className="text-slate-400 text-xs truncate">مدیر سیستم</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-slate-400 hover:text-red-400 transition-colors"
|
||||
title="خروج"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex justify-center py-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||
title="خروج"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Bars3Icon, BellIcon } from '@heroicons/react/24/outline';
|
||||
import { useUiStore } from '../../stores/uiStore';
|
||||
|
||||
interface TopbarProps {
|
||||
pageTitle?: string;
|
||||
breadcrumbs?: { label: string; to?: string }[];
|
||||
}
|
||||
|
||||
export default function Topbar({ pageTitle, breadcrumbs }: TopbarProps) {
|
||||
const toggle = useUiStore((s) => s.toggleSidebar);
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="text-gray-500 hover:text-gray-700 p-1 rounded-md hover:bg-gray-100 transition-colors md:hidden"
|
||||
>
|
||||
<Bars3Icon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
{pageTitle && <h2 className="text-base font-semibold text-gray-800">{pageTitle}</h2>}
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<nav className="flex items-center gap-1 text-xs text-gray-500">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span>/</span>}
|
||||
<span className={i === breadcrumbs.length - 1 ? 'text-gray-700 font-medium' : ''}>
|
||||
{crumb.label}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="relative text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<BellIcon className="w-5 h-5" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-bold">
|
||||
A
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 hidden sm:block">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, staleTime: 30_000 },
|
||||
},
|
||||
});
|
||||
|
||||
const root = document.getElementById('admin-root')!;
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="top-left" richColors />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
UserGroupIcon,
|
||||
HeartIcon,
|
||||
BuildingOffice2Icon,
|
||||
CalendarDaysIcon,
|
||||
CreditCardIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
BanknotesIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
trend?: string;
|
||||
trendUp?: boolean;
|
||||
icon: React.ElementType;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, trend, trendUp, icon: Icon, iconBg, iconColor }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-[0_1px_3px_rgba(0,0,0,.08)] p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{value}</p>
|
||||
{trend && (
|
||||
<div className={`flex items-center gap-1 mt-2 text-xs font-medium ${trendUp ? 'text-emerald-600' : 'text-red-500'}`}>
|
||||
<ArrowTrendingUpIcon className={`w-3.5 h-3.5 ${!trendUp && 'rotate-180'}`} />
|
||||
<span>{trend}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||
<Icon className={`w-6 h-6 ${iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats: StatCardProps[] = [
|
||||
{
|
||||
label: 'کل کاربران',
|
||||
value: '۱۲,۴۸۴',
|
||||
trend: '۸٪ نسبت به ماه قبل',
|
||||
trendUp: true,
|
||||
icon: UserGroupIcon,
|
||||
iconBg: 'bg-violet-100',
|
||||
iconColor: 'text-violet-600',
|
||||
},
|
||||
{
|
||||
label: 'پزشکان فعال',
|
||||
value: '۱,۲۸۴',
|
||||
trend: '۱۲٪ نسبت به ماه قبل',
|
||||
trendUp: true,
|
||||
icon: HeartIcon,
|
||||
iconBg: 'bg-emerald-100',
|
||||
iconColor: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
label: 'کلینیکها',
|
||||
value: '۳۲۱',
|
||||
trend: '۴٪ نسبت به ماه قبل',
|
||||
trendUp: false,
|
||||
icon: BuildingOffice2Icon,
|
||||
iconBg: 'bg-blue-100',
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: 'نوبتهای امروز',
|
||||
value: '۱۴۸',
|
||||
trend: '۲۳٪ نسبت به دیروز',
|
||||
trendUp: true,
|
||||
icon: CalendarDaysIcon,
|
||||
iconBg: 'bg-orange-100',
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: 'پرداختهای امروز',
|
||||
value: '۴۸,۲۰۰,۰۰۰',
|
||||
trend: '۱۵٪ نسبت به دیروز',
|
||||
trendUp: true,
|
||||
icon: CreditCardIcon,
|
||||
iconBg: 'bg-pink-100',
|
||||
iconColor: 'text-pink-600',
|
||||
},
|
||||
{
|
||||
label: 'نظرات در انتظار',
|
||||
value: '۱۲',
|
||||
icon: ChatBubbleLeftEllipsisIcon,
|
||||
iconBg: 'bg-yellow-100',
|
||||
iconColor: 'text-yellow-600',
|
||||
},
|
||||
{
|
||||
label: 'درخواست تسویه',
|
||||
value: '۵',
|
||||
icon: BanknotesIcon,
|
||||
iconBg: 'bg-teal-100',
|
||||
iconColor: 'text-teal-600',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">داشبورد</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">خلاصه وضعیت سیستم</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{stats.map((stat) => (
|
||||
<StatCard key={stat.label} {...stat} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white rounded-2xl shadow-[0_1px_3px_rgba(0,0,0,.08)] p-6">
|
||||
<h3 className="text-base font-semibold text-gray-800 mb-4">فعالیتهای اخیر</h3>
|
||||
<div className="text-center py-10 text-gray-400 text-sm">
|
||||
در حال توسعه...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const schema = z.object({
|
||||
mobile: z.string().min(10, 'شماره موبایل معتبر نیست'),
|
||||
password: z.string().min(6, 'رمز عبور باید حداقل ۶ کاراکتر باشد'),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/user/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mobile_number: data.mobile, password: data.password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
toast.error(err.message || 'خطا در ورود');
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
login(json.access_token, json.refresh_token ?? '');
|
||||
toast.success('ورود موفق');
|
||||
} catch {
|
||||
toast.error('خطا در اتصال به سرور');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f1f5f9]">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">ClinicPro</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">پنل مدیریت</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
شماره موبایل
|
||||
</label>
|
||||
<input
|
||||
{...register('mobile')}
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
placeholder="09xxxxxxxxx"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-right focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
{errors.mobile && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.mobile.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
رمز عبور
|
||||
</label>
|
||||
<input
|
||||
{...register('password')}
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full h-11 bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white font-medium rounded-[10px] transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'در حال ورود...' : 'ورود'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (token: string, refreshToken: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
login: (token, refreshToken) =>
|
||||
set({ token, refreshToken, isAuthenticated: true }),
|
||||
logout: () =>
|
||||
set({ token: null, refreshToken: null, isAuthenticated: false }),
|
||||
}),
|
||||
{ name: 'clinicpro-auth' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface UiState {
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
sidebarOpen: true,
|
||||
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
}));
|
||||
@@ -0,0 +1,30 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #f5f3ff;
|
||||
--color-primary-100: #ede9fe;
|
||||
--color-primary-200: #ddd6fe;
|
||||
--color-primary-300: #c4b5fd;
|
||||
--color-primary-400: #a78bfa;
|
||||
--color-primary-500: #8b5cf6;
|
||||
--color-primary-600: #7c3aed;
|
||||
--color-primary-700: #6d28d9;
|
||||
--color-primary-800: #5b21b6;
|
||||
--color-primary-900: #4c1d95;
|
||||
|
||||
--color-bg-body: #f1f5f9;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-bg-sidebar: #0f172a;
|
||||
--color-bg-sidebar-active: rgba(139, 92, 246, 0.15);
|
||||
|
||||
--font-sans: "Vazirmatn", "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-body);
|
||||
direction: rtl;
|
||||
}
|
||||
Reference in New Issue
Block a user