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:
hamed
2026-06-09 22:27:16 +03:30
parent de1a78a235
commit c98d7b7968
33 changed files with 10399 additions and 116 deletions
+7
View File
@@ -46,3 +46,10 @@
# Composer
/composer.phar
###> symfony/webpack-encore-bundle ###
/node_modules/
/public/build/
npm-debug.log
yarn-error.log
###< symfony/webpack-encore-bundle ###
+43
View File
@@ -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>
);
}
+167
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
+129
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}
+25
View File
@@ -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' }
)
);
+13
View File
@@ -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 }),
}));
+30
View File
@@ -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;
}
+13
View File
@@ -0,0 +1,13 @@
import { registerReactControllerComponents } from '@symfony/ux-react';
import './stimulus_bootstrap.js';
/*
* Welcome to your app's main JavaScript file!
*
* We recommend including the built version of this JavaScript file
* (and its CSS file) in your base layout (base.html.twig).
*/
// any CSS you import will output into a single css file (app.css in this case)
import './styles/app.css';
registerReactControllerComponents(require.context('./react/controllers', true, /\.(j|t)sx?$/));
+11
View File
@@ -0,0 +1,11 @@
{
"controllers": {
"@symfony/ux-react": {
"react": {
"enabled": true,
"fetch": "eager"
}
}
},
"entrypoints": []
}
@@ -0,0 +1,81 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';
+16
View File
@@ -0,0 +1,16 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}
+5
View File
@@ -0,0 +1,5 @@
import React from 'react';
export default function (props) {
return <div>Hello {props.fullName}</div>;
}
+10
View File
@@ -0,0 +1,10 @@
import { startStimulusApp } from '@symfony/stimulus-bridge';
// Registers Stimulus controllers from controllers.json and in the controllers/ directory
export const app = startStimulusApp(require.context(
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
true,
/\.[jt]sx?$/
));
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
+3
View File
@@ -0,0 +1,3 @@
body {
background-color: lightgray;
}
+3
View File
@@ -29,8 +29,11 @@
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*",
"symfony/ux-react": "^2.36",
"symfony/validator": "7.4.*",
"symfony/webpack-encore-bundle": "^2.4",
"symfony/yaml": "7.4.*",
"twig/twig": "*",
"zircote/swagger-php": "*"
Generated
+435 -116
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4fb67bd5aaa2425722fa73ea10b6a1b2",
"content-hash": "245ea537c0b4605205b72071da16ee4b",
"packages": [
{
"name": "doctrine/collections",
@@ -5738,6 +5738,79 @@
],
"time": "2026-03-28T09:44:51+00:00"
},
{
"name": "symfony/stimulus-bundle",
"version": "v2.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/stimulus-bundle.git",
"reference": "377a3d1ec5834631a7db53bd275276ff3c5b49df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/377a3d1ec5834631a7db53bd275276ff3c5b49df",
"reference": "377a3d1ec5834631a7db53bd275276ff3c5b49df",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/config": "^5.4|^6.0|^7.0|^8.0",
"symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.0|^3.0",
"symfony/finder": "^5.4|^6.0|^7.0|^8.0",
"symfony/http-kernel": "^5.4|^6.0|^7.0|^8.0",
"twig/twig": "^2.15.3|^3.8"
},
"require-dev": {
"symfony/asset-mapper": "^6.3|^7.0|^8.0",
"symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0",
"zenstruck/browser": "^1.4"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\UX\\StimulusBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Integration with your Symfony app & Stimulus!",
"keywords": [
"symfony-ux"
],
"support": {
"source": "https://github.com/symfony/stimulus-bundle/tree/v2.36.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-06T04:31:36+00:00"
},
{
"name": "symfony/stopwatch",
"version": "v7.4.8",
@@ -5977,6 +6050,211 @@
],
"time": "2026-01-05T13:30:16+00:00"
},
{
"name": "symfony/twig-bridge",
"version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/twig-bridge.git",
"reference": "81663873d946531129c76c65e80b681ce99c0e89"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/twig-bridge/zipball/81663873d946531129c76c65e80b681ce99c0e89",
"reference": "81663873d946531129c76c65e80b681ce99c0e89",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/translation-contracts": "^2.5|^3",
"twig/twig": "^3.21"
},
"conflict": {
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1",
"symfony/console": "<6.4",
"symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4",
"symfony/http-foundation": "<6.4",
"symfony/http-kernel": "<6.4",
"symfony/mime": "<6.4.37|>7,<7.4.9|>8.0,<8.0.9",
"symfony/serializer": "<6.4",
"symfony/translation": "<6.4",
"symfony/workflow": "<6.4"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/asset": "^6.4|^7.0|^8.0",
"symfony/asset-mapper": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/emoji": "^7.1|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0",
"symfony/form": "^6.4.32|~7.3.10|^7.4.4|^8.0.4",
"symfony/html-sanitizer": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^7.3|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/intl": "^6.4|^7.0|^8.0",
"symfony/mime": "^6.4.37|^7.4.9|^8.0.9",
"symfony/polyfill-intl-icu": "~1.0",
"symfony/property-info": "^6.4|^7.0|^8.0",
"symfony/routing": "^6.4|^7.0|^8.0",
"symfony/security-acl": "^2.8|^3.0",
"symfony/security-core": "^6.4|^7.0|^8.0",
"symfony/security-csrf": "^6.4|^7.0|^8.0",
"symfony/security-http": "^6.4|^7.0|^8.0",
"symfony/serializer": "^6.4.3|^7.0.3|^8.0",
"symfony/stopwatch": "^6.4|^7.0|^8.0",
"symfony/translation": "^6.4|^7.0|^8.0",
"symfony/validator": "^6.4|^7.0|^8.0",
"symfony/web-link": "^6.4|^7.0|^8.0",
"symfony/workflow": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0",
"twig/cssinliner-extra": "^3",
"twig/inky-extra": "^3",
"twig/markdown-extra": "^3"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Twig\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides integration for Twig with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/twig-bridge/tree/v7.4.12"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-29T17:13:54+00:00"
},
{
"name": "symfony/twig-bundle",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/twig-bundle.git",
"reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/twig-bundle/zipball/ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95",
"reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95",
"shasum": ""
},
"require": {
"composer-runtime-api": ">=2.1",
"php": ">=8.2",
"symfony/config": "^7.4|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4.13|^7.1.6|^8.0",
"symfony/twig-bridge": "^7.3|^8.0",
"twig/twig": "^3.12"
},
"conflict": {
"symfony/framework-bundle": "<6.4",
"symfony/translation": "<6.4"
},
"require-dev": {
"symfony/asset": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0",
"symfony/form": "^6.4|^7.0|^8.0",
"symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0",
"symfony/routing": "^6.4|^7.0|^8.0",
"symfony/runtime": "^6.4.13|^7.1.6",
"symfony/stopwatch": "^6.4|^7.0|^8.0",
"symfony/translation": "^6.4|^7.0|^8.0",
"symfony/web-link": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\Bundle\\TwigBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a tight integration of Twig into the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/twig-bundle/tree/v7.4.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/type-info",
"version": "v7.4.9",
@@ -6138,6 +6416,86 @@
],
"time": "2026-04-30T15:19:22+00:00"
},
{
"name": "symfony/ux-react",
"version": "v2.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-react.git",
"reference": "d7436063f39be1af8b0f9f1a2b746aedd9fffdda"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-react/zipball/d7436063f39be1af8b0f9f1a2b746aedd9fffdda",
"reference": "d7436063f39be1af8b0f9f1a2b746aedd9fffdda",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/stimulus-bundle": "^2.9.1|^3.0"
},
"require-dev": {
"symfony/asset-mapper": "^6.3|^7.0|^8.0",
"symfony/finder": "^5.4|^6.0|^7.0|^8.0",
"symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ux",
"name": "symfony/ux"
}
},
"autoload": {
"psr-4": {
"Symfony\\UX\\React\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Titouan Galopin",
"email": "galopintitouan@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Integration of React in Symfony",
"homepage": "https://symfony.com",
"keywords": [
"symfony-ux"
],
"support": {
"source": "https://github.com/symfony/ux-react/tree/v2.36.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-06T04:31:36+00:00"
},
{
"name": "symfony/validator",
"version": "v7.4.10",
@@ -6410,6 +6768,82 @@
],
"time": "2026-04-18T13:18:21+00:00"
},
{
"name": "symfony/webpack-encore-bundle",
"version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/webpack-encore-bundle.git",
"reference": "5b932e0feddd81aaf0ecd7d5fcd2e450e5a7817e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/5b932e0feddd81aaf0ecd7d5fcd2e450e5a7817e",
"reference": "5b932e0feddd81aaf0ecd7d5fcd2e450e5a7817e",
"shasum": ""
},
"require": {
"php": ">=8.1.0",
"symfony/asset": "^5.4 || ^6.2 || ^7.0 || ^8.0",
"symfony/config": "^5.4 || ^6.2 || ^7.0 || ^8.0",
"symfony/dependency-injection": "^5.4 || ^6.2 || ^7.0 || ^8.0",
"symfony/http-kernel": "^5.4 || ^6.2 || ^7.0 || ^8.0",
"symfony/service-contracts": "^1.1.9 || ^2.1.3 || ^3.0"
},
"require-dev": {
"symfony/framework-bundle": "^5.4 || ^6.2 || ^7.0 || ^8.0",
"symfony/http-client": "^5.4 || ^6.2 || ^7.0 || ^8.0",
"symfony/phpunit-bridge": "^5.4 || ^6.2 || ^7.0 || ^8.0",
"symfony/twig-bundle": "^5.4 || ^6.2 || ^7.0 || ^8.0",
"symfony/web-link": "^5.4 || ^6.2 || ^7.0 || ^8.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/webpack-encore",
"name": "symfony/webpack-encore"
}
},
"autoload": {
"psr-4": {
"Symfony\\WebpackEncoreBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Integration of your Symfony app with Webpack Encore",
"support": {
"issues": "https://github.com/symfony/webpack-encore-bundle/issues",
"source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.4.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-11-27T13:41:46+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.4.13",
@@ -9033,121 +9467,6 @@
],
"time": "2026-05-23T16:05:06+00:00"
},
{
"name": "symfony/twig-bridge",
"version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/twig-bridge.git",
"reference": "81663873d946531129c76c65e80b681ce99c0e89"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/twig-bridge/zipball/81663873d946531129c76c65e80b681ce99c0e89",
"reference": "81663873d946531129c76c65e80b681ce99c0e89",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/translation-contracts": "^2.5|^3",
"twig/twig": "^3.21"
},
"conflict": {
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1",
"symfony/console": "<6.4",
"symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4",
"symfony/http-foundation": "<6.4",
"symfony/http-kernel": "<6.4",
"symfony/mime": "<6.4.37|>7,<7.4.9|>8.0,<8.0.9",
"symfony/serializer": "<6.4",
"symfony/translation": "<6.4",
"symfony/workflow": "<6.4"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/asset": "^6.4|^7.0|^8.0",
"symfony/asset-mapper": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/emoji": "^7.1|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0",
"symfony/form": "^6.4.32|~7.3.10|^7.4.4|^8.0.4",
"symfony/html-sanitizer": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^7.3|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/intl": "^6.4|^7.0|^8.0",
"symfony/mime": "^6.4.37|^7.4.9|^8.0.9",
"symfony/polyfill-intl-icu": "~1.0",
"symfony/property-info": "^6.4|^7.0|^8.0",
"symfony/routing": "^6.4|^7.0|^8.0",
"symfony/security-acl": "^2.8|^3.0",
"symfony/security-core": "^6.4|^7.0|^8.0",
"symfony/security-csrf": "^6.4|^7.0|^8.0",
"symfony/security-http": "^6.4|^7.0|^8.0",
"symfony/serializer": "^6.4.3|^7.0.3|^8.0",
"symfony/stopwatch": "^6.4|^7.0|^8.0",
"symfony/translation": "^6.4|^7.0|^8.0",
"symfony/validator": "^6.4|^7.0|^8.0",
"symfony/web-link": "^6.4|^7.0|^8.0",
"symfony/workflow": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0",
"twig/cssinliner-extra": "^3",
"twig/inky-extra": "^3",
"twig/markdown-extra": "^3"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Twig\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides integration for Twig with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/twig-bridge/tree/v7.4.12"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-29T17:13:54+00:00"
},
{
"name": "theseer/tokenizer",
"version": "2.0.1",
+4
View File
@@ -10,4 +10,8 @@ return [
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Symfony\UX\React\ReactBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
];
+6
View File
@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true
+45
View File
@@ -0,0 +1,45 @@
webpack_encore:
# The path where Encore is building the assets - i.e. Encore.setOutputPath()
output_path: '%kernel.project_dir%/public/build'
# If multiple builds are defined (as shown below), you can disable the default build:
# output_path: false
# Set attributes that will be rendered on all script and link tags
script_attributes:
defer: true
# Uncomment (also under link_attributes) if using Turbo Drive
# https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change
# 'data-turbo-track': reload
# link_attributes:
# Uncomment if using Turbo Drive
# 'data-turbo-track': reload
# If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials')
# crossorigin: 'anonymous'
# Preload all rendered script and link tags automatically via the HTTP/2 Link header
# preload: true
# Throw an exception if the entrypoints.json file is missing or an entry is missing from the data
# strict_mode: false
# If you have multiple builds:
# builds:
# frontend: '%kernel.project_dir%/public/frontend/build'
# pass the build name as the 3rd argument to the Twig functions
# {{ encore_entry_script_tags('entry1', null, 'frontend') }}
framework:
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
#when@prod:
# webpack_encore:
# # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
# # Available in version 1.2
# cache: true
#when@test:
# webpack_encore:
# strict_mode: false
+67
View File
@@ -1436,6 +1436,57 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* },
* }
* @psalm-type StimulusConfig = array{
* controller_paths?: list<scalar|Param|null>,
* controllers_json?: scalar|Param|null, // Default: "%kernel.project_dir%/assets/controllers.json"
* }
* @psalm-type ReactConfig = array{
* controllers_path?: scalar|Param|null, // The path to the directory where React controller components are stored - relevant only when using symfony/asset-mapper. // Default: "%kernel.project_dir%/assets/react/controllers"
* name_glob?: list<scalar|Param|null>,
* }
* @psalm-type WebpackEncoreConfig = array{
* output_path?: scalar|Param|null, // The path where Encore is building the assets - i.e. Encore.setOutputPath()
* crossorigin?: false|"anonymous"|"use-credentials"|Param, // crossorigin value when Encore.enableIntegrityHashes() is used, can be false (default), anonymous or use-credentials // Default: false
* preload?: bool|Param, // preload all rendered script and link tags automatically via the http2 Link header. // Default: false
* cache?: bool|Param, // Enable caching of the entry point file(s) // Default: false
* strict_mode?: bool|Param, // Throw an exception if the entrypoints.json file is missing or an entry is missing from the data // Default: true
* builds?: array<string, scalar|Param|null>,
* script_attributes?: array<string, scalar|Param|null>,
* link_attributes?: array<string, scalar|Param|null>,
* }
* @psalm-type TwigConfig = array{
* form_themes?: list<scalar|Param|null>,
* globals?: array<string, array{ // Default: []
* id?: scalar|Param|null,
* type?: scalar|Param|null,
* value?: mixed,
* }>,
* autoescape_service?: scalar|Param|null, // Default: null
* autoescape_service_method?: scalar|Param|null, // Default: null
* base_template_class?: scalar|Param|null, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated.
* cache?: scalar|Param|null, // Default: true
* charset?: scalar|Param|null, // Default: "%kernel.charset%"
* debug?: bool|Param, // Default: "%kernel.debug%"
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
* auto_reload?: scalar|Param|null,
* optimizations?: int|Param,
* default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
* file_name_pattern?: string|list<scalar|Param|null>,
* paths?: array<string, mixed>,
* date?: array{ // The default format options used by the date filter.
* format?: scalar|Param|null, // Default: "F j, Y H:i"
* interval_format?: scalar|Param|null, // Default: "%d days"
* timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
* },
* number_format?: array{ // The default format options for the number_format filter.
* decimals?: int|Param, // Default: 0
* decimal_point?: scalar|Param|null, // Default: "."
* thousands_separator?: scalar|Param|null, // Default: ","
* },
* mailer?: array{
* html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
* },
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1447,6 +1498,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* nelmio_cors?: NelmioCorsConfig,
* nelmio_api_doc?: NelmioApiDocConfig,
* stimulus?: StimulusConfig,
* react?: ReactConfig,
* webpack_encore?: WebpackEncoreConfig,
* twig?: TwigConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1460,6 +1515,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* debug?: DebugConfig,
* maker?: MakerConfig,
* nelmio_api_doc?: NelmioApiDocConfig,
* stimulus?: StimulusConfig,
* react?: ReactConfig,
* webpack_encore?: WebpackEncoreConfig,
* twig?: TwigConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1472,6 +1531,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* nelmio_cors?: NelmioCorsConfig,
* nelmio_api_doc?: NelmioApiDocConfig,
* stimulus?: StimulusConfig,
* react?: ReactConfig,
* webpack_encore?: WebpackEncoreConfig,
* twig?: TwigConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1484,6 +1547,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* nelmio_cors?: NelmioCorsConfig,
* nelmio_api_doc?: NelmioApiDocConfig,
* stimulus?: StimulusConfig,
* react?: ReactConfig,
* webpack_encore?: WebpackEncoreConfig,
* twig?: TwigConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,
+8880
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"devDependencies": {
"@babel/core": "^7.17.0",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.0.0",
"@hotwired/stimulus": "^3.0.0",
"@symfony/stimulus-bridge": "^3.2.0 || ^4.0.0",
"@symfony/ux-react": "file:vendor/symfony/ux-react/assets",
"@symfony/webpack-encore": "^6.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"core-js": "^3.38.0",
"postcss": "^8.0.0",
"postcss-loader": "^8.0.0",
"regenerator-runtime": "^0.13.9",
"tailwindcss": "^4.0.0",
"ts-loader": "^9.6.0",
"typescript": "^5.0.0",
"webpack": "^5.72",
"webpack-cli": "^6.0.0"
},
"dependencies": {
"@heroicons/react": "^2.0.0",
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-table": "^8.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.0.0",
"react-hot-toast": "^2.0.0",
"react-router-dom": "^7.0.0",
"react-select": "^5.0.0",
"sonner": "^1.0.0",
"zod": "^3.0.0",
"zustand": "^5.0.0"
},
"license": "UNLICENSED",
"private": true,
"scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress"
}
}
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Admin\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AdminController extends AbstractController
{
#[Route('/admin/{reactRouting}', name: 'admin_app', requirements: ['reactRouting' => '.*'], defaults: ['reactRouting' => ''])]
public function index(): Response
{
return $this->render('admin/index.html.twig');
}
}
+56
View File
@@ -196,6 +196,34 @@
"config/routes/security.yaml"
]
},
"symfony/stimulus-bundle": {
"version": "2.36",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.24",
"ref": "d21494ed2ddbde38942e8278299a23ce5cf4a9a1"
},
"files": [
"assets/controllers.json",
"assets/controllers/csrf_protection_controller.js",
"assets/controllers/hello_controller.js",
"assets/stimulus_bootstrap.js"
]
},
"symfony/twig-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "7.4",
"recipe": {
@@ -205,6 +233,18 @@
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/ux-react": {
"version": "2.36",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.9",
"ref": "adec905b8d643c7e15a3a8d070c58ff3021288c6"
},
"files": [
"assets/react/controllers/Hello.jsx"
]
},
"symfony/validator": {
"version": "7.4",
"recipe": {
@@ -216,5 +256,21 @@
"files": [
"config/packages/validator.yaml"
]
},
"symfony/webpack-encore-bundle": {
"version": "2.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.1",
"ref": "b346dae458e64a1921ded2125993d94bd719a8dd"
},
"files": [
"assets/app.js",
"assets/styles/app.css",
"config/packages/webpack_encore.yaml",
"package.json",
"webpack.config.js"
]
}
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ClinicPro Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap" rel="stylesheet">
{{ encore_entry_link_tags('admin') }}
</head>
<body>
<div id="admin-root"></div>
{{ encore_entry_script_tags('admin') }}
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% endblock %}
{% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %}
{% if frankenphpHotReload %}
<meta name="frankenphp-hot-reload:url" content="{{ frankenphpHotReload }}">
<script src="https://cdn.jsdelivr.net/npm/idiomorph"></script>
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
{% endif %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["assets/admin/*"]
}
},
"include": ["assets/admin/**/*"],
"exclude": ["node_modules", "public"]
}
+33
View File
@@ -0,0 +1,33 @@
const Encore = require('@symfony/webpack-encore');
if (!Encore.isRuntimeEnvironmentConfigured()) {
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}
Encore
.setOutputPath('public/build/')
.setPublicPath('/build')
.addEntry('app', './assets/app.js')
.addEntry('admin', './assets/admin/index.tsx')
.splitEntryChunks()
.enableSingleRuntimeChunk()
.cleanupOutputBeforeBuild()
.enableSourceMaps(!Encore.isProduction())
.enableVersioning(Encore.isProduction())
.enableReactPreset()
.enableTypeScriptLoader()
.enablePostCssLoader()
.enableStimulusBridge('./assets/controllers.json')
.configureBabelPresetEnv((config) => {
config.useBuiltIns = 'usage';
config.corejs = '3.38';
})
;
module.exports = Encore.getWebpackConfig();