├── .env ├── .gitignore ├── README.md ├── assets ├── css │ ├── app.scss │ └── blog-theme │ │ ├── .editor.scss │ │ ├── base.scss │ │ ├── color-palette.scss │ │ ├── forms.scss │ │ ├── theme.scss │ │ └── variables.scss └── js │ ├── app.js │ ├── article.js │ ├── comment.js │ ├── comment_html_helper.js │ └── functions │ ├── api.js │ └── dom.js ├── bin └── console ├── composer.json ├── composer.lock ├── config ├── bundles.php ├── packages │ ├── assets.yaml │ ├── cache.yaml │ ├── dev │ │ ├── debug.yaml │ │ ├── monolog.yaml │ │ └── web_profiler.yaml │ ├── doctrine.yaml │ ├── doctrine_migrations.yaml │ ├── framework.yaml │ ├── mailer.yaml │ ├── nelmio_cors.yaml │ ├── notifier.yaml │ ├── paginator.yaml │ ├── prod │ │ ├── deprecations.yaml │ │ ├── doctrine.yaml │ │ ├── monolog.yaml │ │ └── webpack_encore.yaml │ ├── routing.yaml │ ├── security.yaml │ ├── test │ │ ├── doctrine.yaml │ │ ├── monolog.yaml │ │ ├── validator.yaml │ │ ├── web_profiler.yaml │ │ └── webpack_encore.yaml │ ├── translation.yaml │ ├── twig.yaml │ ├── validator.yaml │ └── webpack_encore.yaml ├── preload.php ├── routes.yaml ├── routes │ ├── annotations.yaml │ ├── api_platform.yaml │ ├── dev │ │ └── web_profiler.yaml │ └── framework.yaml └── services.yaml ├── migrations ├── Version20230215213529.php └── Version20230420131909.php ├── package-lock.json ├── package.json ├── public └── index.php ├── src ├── ApiResource │ └── .gitignore ├── Controller │ ├── Admin │ │ ├── ArticleCrudController.php │ │ ├── CategoryCrudController.php │ │ ├── CommentCrudController.php │ │ ├── DashboardController.php │ │ ├── MediaCrudController.php │ │ ├── MenuCrudController.php │ │ ├── OptionCrudController.php │ │ ├── PageCrudController.php │ │ └── UserCrudController.php │ ├── ArticleController.php │ ├── CategoryController.php │ ├── CommentController.php │ ├── ErrorController.php │ ├── HomeController.php │ ├── PageController.php │ └── UserController.php ├── DataFixtures │ └── OptionFixtures.php ├── Entity │ ├── Article.php │ ├── Category.php │ ├── Comment.php │ ├── Media.php │ ├── Menu.php │ ├── Option.php │ ├── Page.php │ └── User.php ├── EventListener │ └── ExceptionListener.php ├── EventSubscriber │ ├── ControllerSubscriber.php │ └── EasyAdminSubscriber.php ├── Form │ └── Type │ │ ├── Admin │ │ ├── CommentType.php │ │ └── MenuType.php │ │ ├── CommentType.php │ │ ├── RegistrationFormType.php │ │ └── WelcomeType.php ├── Kernel.php ├── Model │ ├── TimestampedInterface.php │ └── WelcomeModel.php ├── Repository │ ├── ArticleRepository.php │ ├── CategoryRepository.php │ ├── CommentRepository.php │ ├── MediaRepository.php │ ├── MenuRepository.php │ ├── OptionRepository.php │ ├── PageRepository.php │ └── UserRepository.php ├── Service │ ├── ArticleService.php │ ├── CommentService.php │ ├── DatabaseService.php │ ├── MenuService.php │ └── OptionService.php └── Twig │ └── AppExtension.php ├── symfony.lock ├── templates ├── article │ ├── index.html.twig │ ├── item.html.twig │ └── list.html.twig ├── base.html.twig ├── bundles │ └── TwigBundle │ │ └── Exception │ │ ├── error403.html.twig │ │ ├── error404.html.twig │ │ ├── error500.html.twig │ │ └── index.html.twig ├── category │ └── index.html.twig ├── comment │ ├── _card_footer.twig │ ├── _card_text.html.twig │ ├── answer.html.twig │ ├── index.html.twig │ └── list.html.twig ├── home │ ├── index.html.twig │ └── welcome.html.twig ├── page │ └── index.html.twig ├── user │ ├── index.html.twig │ ├── login.html.twig │ └── register.html.twig └── widget │ ├── about.html.twig │ └── categories.html.twig └── webpack.config.js /.env: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | APP_ENV=dev 3 | APP_SECRET=31cdbe196ba4b896a8ad8ac3fafe71cf 4 | ###< symfony/framework-bundle ### 5 | 6 | ###> symfony/webapp-meta ### 7 | MESSENGER_TRANSPORT_DSN= 8 | ###< symfony/webapp-meta ### 9 | 10 | ###> doctrine/doctrine-bundle ### 11 | # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" 12 | DATABASE_URL= 13 | #DATABASE_URL="postgresql://symfony:ChangeMe@127.0.0.1:5432/app?serverVersion=13&charset=utf8" 14 | ###< doctrine/doctrine-bundle ### 15 | 16 | ###> symfony/mailer ### 17 | # MAILER_DSN=smtp://localhost 18 | ###< symfony/mailer ### 19 | 20 | ###> nelmio/cors-bundle ### 21 | CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' 22 | ###< nelmio/cors-bundle ### 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ###> symfony/framework-bundle ### 3 | /.env.local 4 | /.env.local.php 5 | /.env.*.local 6 | /config/secrets/prod/prod.decrypt.private.php 7 | /public/bundles/ 8 | /public/uploads/ 9 | /var/ 10 | /vendor/ 11 | ###< symfony/framework-bundle ### 12 | 13 | ###> symfony/webpack-encore-bundle ### 14 | /node_modules/ 15 | /public/build/ 16 | npm-debug.log 17 | yarn-error.log 18 | ###< symfony/webpack-encore-bundle ### 19 | 20 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Création d'un blog (CMS) avec Symfony 6, Bootstrap 5 et EasyAdmin 4 2 | 3 | ### YouTube 4 | 5 | [![Vidéo](https://i3.ytimg.com/vi/1BbmGc6J7qA/maxresdefault.jpg)](https://www.youtube.com/watch?v=1BbmGc6J7qA) 6 | 7 | ⚙️ Installation 8 | -------------- 9 | Install the PHP dependencies and JS dependencies. 10 | ```sh 11 | composer install 12 | ``` 13 | ```sh 14 | npm install 15 | ``` 16 | Installing assets 17 | ```sh 18 | npm run dev 19 | ``` 20 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | @import "./blog-theme/variables.scss"; 2 | 3 | @import "~bootstrap/scss/bootstrap"; 4 | 5 | @import "~@fortawesome/fontawesome-free/css/all.css"; 6 | @import "~@fortawesome/fontawesome-free/css/v4-shims.css"; 7 | 8 | @import "./blog-theme/theme.scss"; -------------------------------------------------------------------------------- /assets/css/blog-theme/.editor.scss: -------------------------------------------------------------------------------- 1 | .codex-editor { 2 | @extend .border; 3 | @extend .border-primary; 4 | @extend .p-2; 5 | @extend .rounded-2; 6 | @extend .w-100; 7 | 8 | background-color: aliceblue; 9 | } 10 | 11 | .ce-block__content { 12 | max-width: 100%; 13 | } -------------------------------------------------------------------------------- /assets/css/blog-theme/base.scss: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100vh; 3 | margin: 0; 4 | } 5 | 6 | body { 7 | background-color: var(--body-bg); 8 | color: var(--text-color); 9 | } 10 | 11 | a { 12 | color: var(--link-color); 13 | } 14 | 15 | .comment-answers { 16 | border-left: 1px solid; 17 | margin-left: 10px; 18 | padding-left: 30px; 19 | } 20 | 21 | .article-title > a.text-decoration-none { 22 | color: var(--text-color); 23 | } 24 | 25 | .btn-primary, .btn-primary:not(:disabled):not(.disabled):active, 26 | .btn-primary:not(:disabled):not(.disabled):active:focus, 27 | .btn-primary:not(:disabled):not(.disabled):focus, 28 | .btn-primary:not(:disabled):not(.disabled):hover { 29 | background-color: var(--button-primary-bg); 30 | color: var(--button-primary-color); 31 | } 32 | 33 | .comment { 34 | @extend .mb-4; 35 | } 36 | 37 | .comment-answers { 38 | @extend .mt-4 39 | } 40 | 41 | .comment-author { 42 | color: var(--comment-author-color); 43 | } 44 | 45 | .comment-content { 46 | @extend .card-body; 47 | background: var(--form-control-bg); 48 | } 49 | 50 | .dropdown-appearance { 51 | .dropdown-appearance-button { 52 | color: var(--dropdown-appearance-icon-color); 53 | font-size: 16px; 54 | padding: 0 0 0 15px; 55 | } 56 | 57 | .dropdown-appearance-label { 58 | color: var(--text-muted); 59 | display: block; 60 | font-size: var(--font-size-sm); 61 | margin: 4px 4px 6px 4px; 62 | } 63 | 64 | .dropdown-item.active { 65 | background: var(--dropdown-appearance-active-item-bg); 66 | color: var(--dropdown-appearance-active-item-color); 67 | 68 | i { 69 | color: var(--dropdown-appearance-active-item-color); 70 | } 71 | } 72 | } 73 | 74 | .dropdown-menu { 75 | background-color: var(--dropdown-bg); 76 | border-color: var(--dropdown-border-color); 77 | box-shadow: var(--shadow-lg); 78 | color: var(--dropdown-color); 79 | padding: 5px; 80 | 81 | li { 82 | border-radius: var(--border-radius); 83 | } 84 | 85 | a, a:hover, a:active { 86 | border-radius: var(--border-radius); 87 | color: var(--dropdown-link-color); 88 | } 89 | 90 | a:hover { 91 | background: var(--dropdown-link-hover-bg); 92 | } 93 | 94 | i { 95 | color: var(--dropdown-icon-color); 96 | font-size: var(--font-size-lg); 97 | margin-right: 10px; 98 | margin-top: 3px; 99 | vertical-align: middle; 100 | height: 16px; 101 | width: 20px; 102 | } 103 | 104 | .dropdown-item, .dropdown-header { 105 | align-items: flex-start; 106 | display: flex; 107 | padding: 4px 5px; 108 | } 109 | 110 | .dropdown-divider { 111 | background: transparent; 112 | border-top-color: var(--dropdown-border-color); 113 | opacity: 1; 114 | } 115 | 116 | .dropdown-item-color-scheme { 117 | color: var(--dropdown-color); 118 | 119 | &:hover { 120 | background: transparent; 121 | } 122 | 123 | label { 124 | align-items: center; 125 | display: flex; 126 | } 127 | 128 | i { 129 | margin-top: 0; 130 | } 131 | 132 | select { 133 | background: var(--dropdown-bg); 134 | border: 1px solid var(--dropdown-border-color); 135 | border-radius: var(--border-radius); 136 | color: var(--dropdown-color); 137 | margin-left: 10px; 138 | outline: none; 139 | padding: 0 4px; 140 | } 141 | } 142 | } 143 | 144 | .dropdown > a.nav-link { 145 | color: var(--text-color); 146 | } 147 | 148 | .header-title { 149 | color: var(--text-color); 150 | } 151 | 152 | input.form-control, 153 | textarea.form-control { 154 | background: var(--form-control-bg); 155 | border: 1px solid var(--form-input-border-color); 156 | box-shadow: var(--form-input-shadow); 157 | color: var(--form-input-text-color); 158 | padding: 3px 7px 4px; 159 | white-space: nowrap; 160 | word-break: keep-all; 161 | transition: box-shadow .08s ease-in, color .08s ease-in; 162 | } 163 | 164 | .form-control:focus { 165 | color: var(--text-color); 166 | background-color: var(--form-control-bg); 167 | } 168 | 169 | .nav-link.active > .menu-label { 170 | font-weight: 500; 171 | } 172 | 173 | .nav-item > a.nav-link:not(.nav-link-admin) { 174 | color: var(--text-color); 175 | } 176 | 177 | .nav-link.active { 178 | color: var(--menu-active-item-color) !important; 179 | background-color: var(--menu-active-item-bg) !important; 180 | } 181 | 182 | .page-item.active, 183 | .page-item.active .page-link:hover, 184 | .page-link { 185 | background: var(--pagination-active-bg); 186 | border-color: var(--pagination-active-bg); 187 | color: var(--pagination-active-color); 188 | } 189 | 190 | .page-item.disabled .page-link { 191 | background: transparent; 192 | border: var(--border-width) var(--border-style) transparent; 193 | color: var(--pagination-disabled-color); 194 | } 195 | 196 | .page-item .page-link, 197 | .page-item .page-link:focus, 198 | .page-item .page-link:hover { 199 | background: transparent; 200 | border: var(--border-width) var(--border-style) transparent; 201 | border-radius: var(--border-radius); 202 | color: inherit; 203 | margin: 0 1px; 204 | } 205 | 206 | .page-item .page-link:focus, 207 | .page-item .page-link:hover { 208 | border-color: var(--pagination-hover-border-color); 209 | } 210 | 211 | pre { 212 | border-radius: var(--border-radius); 213 | color: crimson; 214 | background-color: #f1f1f1; 215 | padding: 4px; 216 | font-size: 105%; 217 | } -------------------------------------------------------------------------------- /assets/css/blog-theme/color-palette.scss: -------------------------------------------------------------------------------- 1 | // Color palette copied from Tailwind CSS (MIT License) 2 | // see https://tailwindcss.com/docs/customizing-colors 3 | // and https://github.com/tailwindlabs/tailwindcss/blob/master/colors.js 4 | 5 | :root { 6 | --black: #000; 7 | --white: #fff; 8 | 9 | --rose-50: #fff1f2; 10 | --rose-100: #ffe4e6; 11 | --rose-200: #fecdd3; 12 | --rose-300: #fda4af; 13 | --rose-400: #fb7185; 14 | --rose-500: #f43f5e; 15 | --rose-600: #e11d48; 16 | --rose-700: #be123c; 17 | --rose-800: #9f1239; 18 | --rose-900: #881337; 19 | 20 | --pink-50: #fdf2f8; 21 | --pink-100: #fce7f3; 22 | --pink-200: #fbcfe8; 23 | --pink-300: #f9a8d4; 24 | --pink-400: #f472b6; 25 | --pink-500: #ec4899; 26 | --pink-600: #db2777; 27 | --pink-700: #be185d; 28 | --pink-800: #9d174d; 29 | --pink-900: #831843; 30 | 31 | --fuchsia-50: #fdf4ff; 32 | --fuchsia-100: #fae8ff; 33 | --fuchsia-200: #f5d0fe; 34 | --fuchsia-300: #f0abfc; 35 | --fuchsia-400: #e879f9; 36 | --fuchsia-500: #d946ef; 37 | --fuchsia-600: #c026d3; 38 | --fuchsia-700: #a21caf; 39 | --fuchsia-800: #86198f; 40 | --fuchsia-900: #701a75; 41 | 42 | --purple-50: #faf5ff; 43 | --purple-100: #f3e8ff; 44 | --purple-200: #e9d5ff; 45 | --purple-300: #d8b4fe; 46 | --purple-400: #c084fc; 47 | --purple-500: #a855f7; 48 | --purple-600: #9333ea; 49 | --purple-700: #7e22ce; 50 | --purple-800: #6b21a8; 51 | --purple-900: #581c87; 52 | 53 | --violet-50: #f5f3ff; 54 | --violet-100: #ede9fe; 55 | --violet-200: #ddd6fe; 56 | --violet-300: #c4b5fd; 57 | --violet-400: #a78bfa; 58 | --violet-500: #8b5cf6; 59 | --violet-600: #7c3aed; 60 | --violet-700: #6d28d9; 61 | --violet-800: #5b21b6; 62 | --violet-900: #4c1d95; 63 | 64 | --indigo-50: #eef2ff; 65 | --indigo-100: #e0e7ff; 66 | --indigo-200: #c7d2fe; 67 | --indigo-300: #a5b4fc; 68 | --indigo-400: #818cf8; 69 | --indigo-500: #6366f1; 70 | --indigo-600: #4f46e5; 71 | --indigo-700: #4338ca; 72 | --indigo-800: #3730a3; 73 | --indigo-900: #312e81; 74 | 75 | --blue-50: #eff6ff; 76 | --blue-100: #dbeafe; 77 | --blue-200: #bfdbfe; 78 | --blue-300: #93c5fd; 79 | --blue-400: #60a5fa; 80 | --blue-500: #3b82f6; 81 | --blue-600: #2563eb; 82 | --blue-700: #1d4ed8; 83 | --blue-800: #1e40af; 84 | --blue-900: #1e3a8a; 85 | 86 | --sky-50: #f0f9ff; 87 | --sky-100: #e0f2fe; 88 | --sky-200: #bae6fd; 89 | --sky-300: #7dd3fc; 90 | --sky-400: #38bdf8; 91 | --sky-500: #0ea5e9; 92 | --sky-600: #0284c7; 93 | --sky-700: #0369a1; 94 | --sky-800: #075985; 95 | --sky-900: #0c4a6e; 96 | 97 | --cyan-50: #ecfeff; 98 | --cyan-100: #cffafe; 99 | --cyan-200: #a5f3fc; 100 | --cyan-300: #67e8f9; 101 | --cyan-400: #22d3ee; 102 | --cyan-500: #06b6d4; 103 | --cyan-600: #0891b2; 104 | --cyan-700: #0e7490; 105 | --cyan-800: #155e75; 106 | --cyan-900: #164e63; 107 | 108 | --teal-50: #f0fdfa; 109 | --teal-100: #ccfbf1; 110 | --teal-200: #99f6e4; 111 | --teal-300: #5eead4; 112 | --teal-400: #2dd4bf; 113 | --teal-500: #14b8a6; 114 | --teal-600: #0d9488; 115 | --teal-700: #0f766e; 116 | --teal-800: #115e59; 117 | --teal-900: #134e4a; 118 | 119 | --emerald-50: #ecfdf5; 120 | --emerald-100: #d1fae5; 121 | --emerald-200: #a7f3d0; 122 | --emerald-300: #6ee7b7; 123 | --emerald-400: #34d399; 124 | --emerald-500: #10b981; 125 | --emerald-600: #059669; 126 | --emerald-700: #047857; 127 | --emerald-800: #065f46; 128 | --emerald-900: #064e3b; 129 | 130 | --green-50: #f0fdf4; 131 | --green-100: #dcfce7; 132 | --green-200: #bbf7d0; 133 | --green-300: #86efac; 134 | --green-400: #4ade80; 135 | --green-500: #22c55e; 136 | --green-600: #16a34a; 137 | --green-700: #15803d; 138 | --green-800: #166534; 139 | --green-900: #14532d; 140 | 141 | --lime-50: #f7fee7; 142 | --lime-100: #ecfccb; 143 | --lime-200: #d9f99d; 144 | --lime-300: #bef264; 145 | --lime-400: #a3e635; 146 | --lime-500: #84cc16; 147 | --lime-600: #65a30d; 148 | --lime-700: #4d7c0f; 149 | --lime-800: #3f6212; 150 | --lime-900: #365314; 151 | 152 | --yellow-50: #fefce8; 153 | --yellow-100: #fef9c3; 154 | --yellow-200: #fef08a; 155 | --yellow-300: #fde047; 156 | --yellow-400: #facc15; 157 | --yellow-500: #eab308; 158 | --yellow-600: #ca8a04; 159 | --yellow-700: #a16207; 160 | --yellow-800: #854d0e; 161 | --yellow-900: #713f12; 162 | 163 | --amber-50: #fffbeb; 164 | --amber-100: #fef3c7; 165 | --amber-200: #fde68a; 166 | --amber-300: #fcd34d; 167 | --amber-400: #fbbf24; 168 | --amber-500: #f59e0b; 169 | --amber-600: #d97706; 170 | --amber-700: #b45309; 171 | --amber-800: #92400e; 172 | --amber-900: #78350f; 173 | 174 | --orange-50: #fff7ed; 175 | --orange-100: #ffedd5; 176 | --orange-200: #fed7aa; 177 | --orange-300: #fdba74; 178 | --orange-400: #fb923c; 179 | --orange-500: #f97316; 180 | --orange-600: #ea580c; 181 | --orange-700: #c2410c; 182 | --orange-800: #9a3412; 183 | --orange-900: #7c2d12; 184 | 185 | --red-50: #fef2f2; 186 | --red-100: #fee2e2; 187 | --red-200: #fecaca; 188 | --red-300: #fca5a5; 189 | --red-400: #f87171; 190 | --red-500: #ef4444; 191 | --red-600: #dc2626; 192 | --red-700: #b91c1c; 193 | --red-800: #991b1b; 194 | --red-900: #7f1d1d; 195 | 196 | --warm-gray-50: #fafaf9; 197 | --warm-gray-100: #f5f5f4; 198 | --warm-gray-200: #e7e5e4; 199 | --warm-gray-300: #d6d3d1; 200 | --warm-gray-400: #a8a29e; 201 | --warm-gray-500: #78716c; 202 | --warm-gray-600: #57534e; 203 | --warm-gray-700: #44403c; 204 | --warm-gray-800: #292524; 205 | --warm-gray-900: #1c1917; 206 | 207 | --true-gray-50: #fafafa; 208 | --true-gray-100: #f5f5f5; 209 | --true-gray-200: #e5e5e5; 210 | --true-gray-300: #d4d4d4; 211 | --true-gray-400: #a3a3a3; 212 | --true-gray-500: #737373; 213 | --true-gray-600: #525252; 214 | --true-gray-700: #404040; 215 | --true-gray-800: #262626; 216 | --true-gray-900: #171717; 217 | 218 | // Tailwind CSS calls this color "gray", but we renamed it to "neutral gray" 219 | // because our design is based on blue tones, so the best "gray" for our design 220 | // is "blue gray" and we alias "blue gray" color as "gray" to simplify things 221 | --neutral-gray-50: #fafafa; 222 | --neutral-gray-100: #f4f4f5; 223 | --neutral-gray-200: #e4e4e7; 224 | --neutral-gray-300: #d4d4d8; 225 | --neutral-gray-400: #a1a1aa; 226 | --neutral-gray-500: #71717a; 227 | --neutral-gray-600: #52525b; 228 | --neutral-gray-700: #3f3f46; 229 | --neutral-gray-800: #27272a; 230 | --neutral-gray-900: #18181b; 231 | 232 | --cool-gray-50: #f9fafb; 233 | --cool-gray-100: #f3f4f6; 234 | --cool-gray-200: #e5e7eb; 235 | --cool-gray-300: #d1d5db; 236 | --cool-gray-400: #9ca3af; 237 | --cool-gray-500: #6b7280; 238 | --cool-gray-600: #4b5563; 239 | --cool-gray-700: #374151; 240 | --cool-gray-800: #1f2937; 241 | --cool-gray-900: #111827; 242 | 243 | --blue-gray-50: #f8fafc; 244 | --blue-gray-100: #f1f5f9; 245 | --blue-gray-200: #e2e8f0; 246 | --blue-gray-300: #cbd5e1; 247 | --blue-gray-400: #94a3b8; 248 | --blue-gray-500: #64748b; 249 | --blue-gray-600: #475569; 250 | --blue-gray-700: #334155; 251 | --blue-gray-800: #1e293b; 252 | --blue-gray-900: #0f172a; 253 | 254 | // this is the color alias to consider the "blue gray" color as "the normal gray" 255 | --gray-50: var(--blue-gray-50); 256 | --gray-100: var(--blue-gray-100); 257 | --gray-200: var(--blue-gray-200); 258 | --gray-300: var(--blue-gray-300); 259 | --gray-400: var(--blue-gray-400); 260 | --gray-500: var(--blue-gray-500); 261 | --gray-600: var(--blue-gray-600); 262 | --gray-700: var(--blue-gray-700); 263 | --gray-800: var(--blue-gray-800); 264 | --gray-900: var(--blue-gray-900); 265 | } -------------------------------------------------------------------------------- /assets/css/blog-theme/forms.scss: -------------------------------------------------------------------------------- 1 | input[disabled] { 2 | cursor: not-allowed; 3 | } 4 | 5 | .form-inline { 6 | align-items: center; 7 | display: flex; 8 | flex-flow: row wrap; 9 | } 10 | 11 | // Base form groups 12 | .form-group { 13 | padding: 12px 0; 14 | } 15 | 16 | .form-group label, 17 | .form-group legend.col-form-label { 18 | color: var(--form-label-color); 19 | font-size: var(--font-size-base); 20 | font-weight: 500; 21 | margin: 0; 22 | padding: 0 0 8px 0; 23 | } 24 | 25 | .form-check .form-check-input { 26 | background: unset; 27 | border-color: var(--form-type-check-input-border-color); 28 | height: 15px; 29 | width: 15px; 30 | } 31 | // Used in checkbox and radio buttons 32 | label.form-check-label { 33 | cursor: pointer; 34 | font-weight: normal; 35 | } 36 | .form-group label.form-check-label.required:after { 37 | display: none; 38 | } 39 | .form-check + .form-check { 40 | margin-top: 5px; 41 | } 42 | 43 | .form-group label.required:after, 44 | .form-group .col-form-label.required:after { 45 | background: var(--color-danger); 46 | border-radius: 50%; 47 | content: ''; 48 | display: inline-block; 49 | filter: opacity(75%); 50 | position: relative; 51 | right: -2px; 52 | top: -8px; 53 | z-index: var(--zindex-700); 54 | height: 4px; 55 | width: 4px; 56 | } 57 | 58 | .form-help { 59 | color: var(--form-help-color); 60 | display: block; 61 | font-size: var(--font-size-sm); 62 | margin-top: 5px; 63 | transition: color 0.5s ease; 64 | } 65 | .form-control:focus-within .form-help { 66 | color: var(--form-help-active-color); 67 | } 68 | 69 | input.form-control, 70 | input.form-control[readonly], 71 | textarea.form-control, 72 | .form-select { 73 | background: var(--form-control-bg); 74 | border: 1px solid var(--form-input-border-color); 75 | box-shadow: var(--form-input-shadow); 76 | color: var(--form-input-text-color); 77 | padding: 3px 7px 4px; 78 | white-space: nowrap; 79 | word-break: keep-all; 80 | transition: box-shadow .08s ease-in, color .08s ease-in; 81 | } 82 | input.form-check-input { 83 | border: 1px solid var(--form-type-check-input-border-color); 84 | box-shadow: var(--form-type-check-input-box-shadow); 85 | } 86 | input.form-control:focus, 87 | textarea.form-control:focus, 88 | .form-select:focus, 89 | .custom-file-input:focus ~ .custom-file-label, 90 | input.form-check-input:focus { 91 | border-color: var(--form-input-hover-border-color); 92 | box-shadow: var(--form-input-hover-shadow); 93 | outline: 0; 94 | } 95 | .form-check-input:checked { 96 | background-color: var(--form-type-check-input-checked-bg); 97 | } 98 | .form-check-input:focus { 99 | box-shadow: var(--form-input-hover-shadow); 100 | } 101 | 102 | .form-control + .input-group-append { 103 | color: var(--gray-600); 104 | height: 30px; 105 | } 106 | .form-control + .input-group-append i { 107 | color: var(--gray-600); 108 | } 109 | 110 | textarea.form-control { 111 | height: auto; 112 | line-height: 1.6; 113 | white-space: pre-wrap; 114 | } 115 | 116 | .form-select { 117 | background-position: right 5px center; 118 | padding: 3px 28px 4px 7px; 119 | } 120 | .ts-dropdown.form-select { 121 | height: auto; 122 | } 123 | 124 | // Checkbox widgets 125 | .form-check { 126 | margin: 0; 127 | padding: 0; 128 | } 129 | label.form-check-label { 130 | margin: 0; 131 | padding: 0 0 0 5px; 132 | } 133 | .form-check .form-check-input { 134 | float: none; 135 | margin-left: 0; 136 | margin-top: 2px; 137 | } 138 | .form-check-inline + .form-check-inline { 139 | margin-left: 15px; 140 | } 141 | 142 | .datetime-widget select, 143 | .datetime-widget .input-group > .form-select { 144 | min-width: max-content; 145 | -webkit-appearance: none; // needed for Safari 146 | } 147 | .datetime-widget + .datetime-widget { 148 | margin-left: 10px; 149 | } 150 | 151 | .datetime-widget select + select { 152 | margin-left: 4px; 153 | } 154 | 155 | .datetime-widget-time select { 156 | margin: 0 0 0 2px; 157 | } 158 | .datetime-widget-time select:first-child { 159 | margin-left: 0; 160 | } 161 | .datetime-widget-time select:last-child { 162 | margin-right: 0; 163 | } 164 | 165 | .nullable-control label, 166 | fieldset .form-group .nullable-control label { 167 | cursor: pointer; 168 | margin-top: 5px; 169 | } 170 | 171 | // Utilities to create common form fields (long, short, etc.) 172 | .short { 173 | flex: 0 0 20% !important; 174 | } 175 | 176 | .long .form-control, .large .form-control { 177 | max-width: unset !important; 178 | } 179 | 180 | .large .input.form-control { 181 | font-size: 18px !important; 182 | } 183 | 184 | .large textarea.form-control { 185 | height: 500px; 186 | max-width: unset !important; 187 | } 188 | 189 | .code input.form-control, .code textarea.form-control { 190 | font-family: monospace !important; 191 | } 192 | 193 | .field-group .long .form-control, .field-group .large .form-control { 194 | flex: 0 0 100% !important; 195 | max-width: unset !important; 196 | } 197 | 198 | .field-group .large textarea.form-control { 199 | flex: 0 0 100% !important; 200 | height: 500px; 201 | max-width: unset !important; 202 | } 203 | 204 | // Form tabs 205 | .form-tabs .nav-tabs { 206 | background: transparent; 207 | border: 0; 208 | box-shadow: 0 1px 0 var(--form-tabs-border-color); 209 | margin: 0px 0px 20px; 210 | padding-left: 0px; 211 | } 212 | 213 | .form-tabs .nav-tabs a, .form-tabs .nav-tabs a:hover { 214 | border: 0; 215 | color: var(--text-color); 216 | font-size: var(--font-size-base); 217 | font-weight: 500; 218 | margin: 0 28px 0 0; 219 | padding: 4px 0px 8px; 220 | } 221 | 222 | .form-tabs .nav-tabs .fa { 223 | color: var(--text-muted); 224 | font-size: var(--font-size-lg); 225 | margin-right: 4px; 226 | } 227 | 228 | .form-tabs .nav-tabs .nav-link.active { 229 | background: transparent; 230 | box-shadow: inset 0px -2px 0 0px var(--link-color); 231 | color: var(--link-color); 232 | transition: box-shadow .3s ease-in-out; 233 | } 234 | 235 | .form-tabs .nav-tabs .nav-item .badge { 236 | margin-left: 4px; 237 | padding: 3px 6px; 238 | } 239 | 240 | .form-tabs .tab-help { 241 | margin-top: -10px; 242 | margin-bottom: 15px; 243 | } 244 | 245 | // Form fieldsets (used for the "from groups" feature) 246 | fieldset { 247 | background: var(--fieldset-bg); 248 | border: var(--border-width) var(--border-style) var(--border-color); 249 | border-radius: var(--border-radius); 250 | margin: 10px 0; 251 | padding: 10px 20px 15px; 252 | } 253 | 254 | fieldset > legend { 255 | border: 0; 256 | font-size: var(--font-size-sm); 257 | font-weight: 500; 258 | text-transform: uppercase; 259 | margin: 0 0 5px -5px; 260 | padding: 0 5px; 261 | width: auto; 262 | } 263 | fieldset > legend .fa { 264 | color: var(--text-muted); 265 | font-size: var(--font-size-lg); 266 | margin-right: 4px; 267 | } 268 | 269 | fieldset .form-section { 270 | padding-left: 0; 271 | padding-right: 0; 272 | } 273 | 274 | fieldset .form-group { 275 | padding: 10px 0; 276 | } 277 | 278 | fieldset .form-group label, 279 | fieldset .form-group legend.col-form-label { 280 | flex: 100% 0 0; 281 | margin: 0 0 4px 0; 282 | text-align: left; 283 | } 284 | 285 | fieldset .form-group , 286 | fieldset .field-checkbox { 287 | flex: 0 0 100%; 288 | padding-left: 0; 289 | padding-right: 0; 290 | } 291 | 292 | fieldset .field-checkbox , 293 | fieldset .form-group.field-collection-action { 294 | margin-left: 0; 295 | } 296 | 297 | fieldset .form-group.field-collection-action { 298 | padding-top: 0; 299 | } 300 | 301 | fieldset .field-collection-action .btn { 302 | margin-left: 0; 303 | } 304 | 305 | fieldset .legend-help { 306 | color: var(--text-muted); 307 | font-size: var(--font-size-sm); 308 | margin-bottom: 15px; 309 | margin-top: -5px; 310 | } 311 | 312 | // Form errors 313 | .has-error .form-help, .has-error .control-label, .has-error .radio, 314 | .has-error .checkbox, .has-error .radio-inline, .has-error .checkbox-inline, 315 | .has-error.radio label, .has-error.checkbox label, .has-error.radio-inline label, 316 | .has-error.checkbox-inline label { 317 | color: var(--gray-800); 318 | } 319 | 320 | .has-error input.form-control, 321 | .has-error textarea.form-control, 322 | .has-error .form-select, 323 | .has-error .input-group, 324 | .has-error .btn.input-file-container, 325 | .has-error .CodeMirror, 326 | .has-error { 327 | box-shadow: 0 0 0 1px rgba(43, 45, 80, 0), 0 0 0 1px rgba(183, 6, 32, .2), 0 0 0 2px rgba(183, 6, 32, .25), 0 1px 1px rgba(0, 0, 0, .08); 328 | } 329 | 330 | .has-error .input-group { 331 | border-radius: var(--border-radius); 332 | } 333 | 334 | .global-invalid-feedback { 335 | background: var(--red-100); 336 | border-radius: var(--border-radius); 337 | color: var(--color-danger); 338 | font-size: 14px; 339 | margin: 5px 0; 340 | padding: 6px 12px; 341 | } 342 | form .invalid-feedback { 343 | color: var(--color-danger); 344 | font-weight: 500; 345 | padding-top: 6px; 346 | } 347 | form .invalid-feedback .badge-danger { 348 | font-size: 0.6875rem; 349 | margin-right: 2px; 350 | padding: 3px 4px; 351 | } 352 | form .invalid-feedback > .d-block + .d-block { 353 | margin-top: 5px; 354 | } 355 | 356 | // Form group inputs 357 | .input-group-text { 358 | background-color: var(--form-input-group-text-bg); 359 | border: 1px solid var(--form-input-group-text-border-color); 360 | box-shadow: var(--form-input-box-shadow); 361 | color: var(--form-input-text-color); 362 | } 363 | 364 | .input-group button, 365 | .input-group button:hover, 366 | .input-group button:active, 367 | .input-group button:focus { 368 | height: 28px; 369 | margin-top: 1px; 370 | } 371 | 372 | .input-group-append { 373 | margin-left: 0; 374 | } 375 | .input-group-prepend { 376 | margin-right: 0; 377 | } 378 | 379 | // This CSS trick allows to customize the field entirely 380 | // as explained in https://gist.github.com/barneycarroll/5244258 381 | .input-file-container { 382 | overflow: hidden; 383 | position: relative; 384 | } 385 | 386 | .input-file-container [type=file] { 387 | cursor: inherit; 388 | display: block; 389 | font-size: 999px; 390 | filter: opacity(0); 391 | min-height: 100%; 392 | min-width: 100%; 393 | opacity: 0; 394 | position: absolute; 395 | right: 0; 396 | text-align: right; 397 | top: 0; 398 | } -------------------------------------------------------------------------------- /assets/css/blog-theme/theme.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.scss"; 2 | @import ".editor.scss"; 3 | @import "./forms.scss"; 4 | @import "./base.scss"; -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import '../css/app.scss'; 2 | import {Dropdown} from 'bootstrap'; 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | new App(); 6 | }); 7 | 8 | class App { 9 | colorSchemeLocalStorageKey; 10 | 11 | constructor() { 12 | this.colorSchemeLocalStorageKey = 'blog/colorScheme'; 13 | 14 | this.createColorSchemeSelector(); 15 | this.enableDropdowns(); 16 | } 17 | 18 | createColorSchemeSelector() { 19 | if (null === document.querySelector('.dropdown-appearance')) { 20 | return; 21 | } 22 | 23 | const currentScheme = localStorage.getItem(this.colorSchemeLocalStorageKey) || 'auto'; 24 | const colorSchemeSelectors = document.querySelectorAll('.dropdown-appearance a[data-color-scheme]'); 25 | const activeColorSchemeSelector = document.querySelector(`.dropdown-appearance a[data-color-scheme="${currentScheme}"]`); 26 | 27 | colorSchemeSelectors.forEach((selector) => { selector.classList.remove('active') }); 28 | activeColorSchemeSelector.classList.add('active'); 29 | 30 | colorSchemeSelectors.forEach((selector) => { 31 | selector.addEventListener('click', () => { 32 | const selectedColorScheme = selector.getAttribute('data-color-scheme'); 33 | const resolvedColorScheme = 'auto' === selectedColorScheme 34 | ? matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 35 | : selectedColorScheme; 36 | 37 | document.body.classList.remove('light-scheme', 'dark-scheme'); 38 | document.body.classList.add('light' === resolvedColorScheme ? 'light-scheme' : 'dark-scheme'); 39 | document.body.style.colorScheme = resolvedColorScheme; 40 | localStorage.setItem(this.colorSchemeLocalStorageKey, selectedColorScheme); 41 | 42 | colorSchemeSelectors.forEach((otherSelector) => { otherSelector.classList.remove('active') }); 43 | selector.classList.add('active'); 44 | }); 45 | }); 46 | } 47 | 48 | enableDropdowns() { 49 | const dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle')); 50 | dropdownElementList.map(function (dropdownToggleEl) { 51 | return new Dropdown(dropdownToggleEl); 52 | }); 53 | } 54 | } -------------------------------------------------------------------------------- /assets/js/article.js: -------------------------------------------------------------------------------- 1 | import {$} from "./functions/dom"; 2 | import Checklist from '@editorjs/checklist'; 3 | import EditorJS from '@editorjs/editorjs'; 4 | import Embed from '@editorjs/embed'; 5 | import Header from '@editorjs/header'; 6 | import List from '@editorjs/list'; 7 | 8 | document.addEventListener('DOMContentLoaded', async () => { 9 | const article = new Article(); 10 | 11 | if ($('.article-data').dataset['isAuthor']) { 12 | await article.initializeEditor(); 13 | } else { 14 | article.initializeContent(); 15 | } 16 | }) 17 | 18 | export class Article { 19 | id; 20 | 21 | /** @var {EditorJS} */ 22 | editor; 23 | 24 | /** @var {boolean} */ 25 | isPatching = false; 26 | 27 | constructor() { 28 | this.id = $('.article-data').dataset.id; 29 | } 30 | 31 | initializeContent() { 32 | const data = this.getData(); 33 | const articleContent = $('#article-content'); 34 | 35 | console.log(data.blocks); 36 | 37 | data.blocks.forEach(block => { 38 | switch (block.type) { 39 | case 'checklist': 40 | articleContent.append(this.handleChecklistBlock(block.data)); 41 | break; 42 | case 'embed': 43 | articleContent.append(this.handleEmbedBlock(block.data)); 44 | break; 45 | case 'header': 46 | articleContent.append(this.handleHeaderBlock(block.data)) 47 | break; 48 | case 'list': 49 | articleContent.append(this.handleListBlock(block.data)); 50 | break; 51 | case 'paragraph': 52 | articleContent.append(this.handleParagraphBlock(block.data)); 53 | break; 54 | } 55 | }); 56 | } 57 | 58 | async initializeEditor() { 59 | this.editor = await new EditorJS({ 60 | data: this.getData(), 61 | holder: 'article-content', 62 | onChange: async () => { 63 | if (!this.isPatching) { 64 | await this.patchArticle(); 65 | } 66 | }, 67 | tools: { 68 | checklist: Checklist, 69 | embed: { 70 | class: Embed, 71 | config: { 72 | services: { 73 | youtube: true 74 | } 75 | }, 76 | inlineToolbar: true 77 | }, 78 | header: Header, 79 | list: List 80 | } 81 | }); 82 | 83 | await this.editor.isReady; 84 | 85 | $('.codex-editor__redactor').style.removeProperty('padding-bottom'); 86 | } 87 | 88 | /** 89 | * @returns {Promise} 90 | */ 91 | async fetchComments() { 92 | const response = await fetch(`/ajax/articles/${this.id}/comments`, { 93 | headers: { 94 | 'X-Requested-With': 'XMLHttpRequest' 95 | }, 96 | method: 'GET' 97 | }); 98 | 99 | return await response.json(); 100 | } 101 | 102 | getData() { 103 | return JSON.parse($('.article-data').dataset['content']); 104 | } 105 | 106 | handleChecklistBlock(data) { 107 | const ul = document.createElement('ul'); 108 | ul.classList.add('list-group', 'list-group-flush') 109 | 110 | data.items.forEach(item => { 111 | const li = document.createElement('li'); 112 | li.classList.add('list-group-item'); 113 | 114 | const div = document.createElement('div'); 115 | div.classList.add('form-check'); 116 | 117 | const input = document.createElement('input'); 118 | input.classList.add('form-check-input'); 119 | input.checked = item.checked; 120 | input.type = 'checkbox'; 121 | 122 | const label = document.createElement('label'); 123 | label.classList.add('form-check-label'); 124 | label.innerText = item.text; 125 | 126 | div.append(input); 127 | div.append(label); 128 | li.appendChild(div); 129 | 130 | ul.append(li); 131 | }) 132 | 133 | return ul; 134 | } 135 | 136 | handleEmbedBlock(data) { 137 | const iframe = document.createElement('iframe'); 138 | iframe.height = data.height; 139 | iframe.src = data.embed 140 | iframe.width = data.width; 141 | 142 | return iframe; 143 | } 144 | 145 | handleHeaderBlock(data) { 146 | const header = document.createElement(`h${data.level}`); 147 | header.innerText = data.text; 148 | 149 | return header; 150 | } 151 | 152 | handleListBlock(data) { 153 | const tagName = 'ordered' === data.type ? 'ol' : 'ul'; 154 | const list = document.createElement(tagName); 155 | 156 | data.items.forEach(item => { 157 | const li = document.createElement('li'); 158 | li.innerText = item; 159 | 160 | list.append(li); 161 | }); 162 | 163 | return list; 164 | } 165 | 166 | handleParagraphBlock(data) { 167 | const paragraph = document.createElement('p'); 168 | paragraph.innerHTML = data.text; 169 | 170 | return paragraph; 171 | } 172 | 173 | async patchArticle() { 174 | this.isPatching = true; 175 | 176 | await fetch(`/api/articles/${this.id}`, { 177 | method: 'PATCH', 178 | headers: { 179 | 'Content-Type': 'application/merge-patch+json' 180 | }, 181 | body: JSON.stringify({ content: await this.editor.save() }) 182 | }); 183 | 184 | this.isPatching = false; 185 | } 186 | } -------------------------------------------------------------------------------- /assets/js/comment.js: -------------------------------------------------------------------------------- 1 | import {$, $$} from "./functions/dom"; 2 | import {Article} from "./article"; 3 | import {getCommentElement, getReplyDialogElement} from "./comment_html_helper"; 4 | import {jsonFetch} from "./functions/api"; 5 | 6 | document.addEventListener('DOMContentLoaded', async () => { 7 | const article = new Article(); 8 | const comments = await article.fetchComments(); 9 | const comment = new Comment(); 10 | comment.comments = comments; 11 | comment.handleCommentsList(comments); 12 | comment.listenCommentAnswerButton(); 13 | }); 14 | 15 | class Comment { 16 | /** 17 | * @type {Array} 18 | */ 19 | comments; 20 | 21 | /** 22 | * @type {HTMLElement} 23 | */ 24 | commentList; 25 | 26 | /** 27 | * @type {HTMLElement} 28 | */ 29 | commentCount; 30 | 31 | /** 32 | * @type {HTMLFormElement} 33 | */ 34 | commentForm; 35 | 36 | /** 37 | * @type {HTMLElement} 38 | */ 39 | currentReplyDialog; 40 | 41 | /** 42 | * @type {Number} 43 | */ 44 | userId; 45 | 46 | selectedCommentText; 47 | 48 | constructor() { 49 | this.commentList = $('.comment-list'); 50 | this.commentCount = $('#comment-count'); 51 | this.commentForm = $('form.comment-form'); 52 | this.userId = Number($('.article-data').dataset.userId); 53 | 54 | this.commentForm.addEventListener('submit', async (e) => { 55 | e.preventDefault(); 56 | await this.addComment(e.target); 57 | }); 58 | } 59 | 60 | /** 61 | * 62 | * @param {HTMLFormElement} target 63 | * @returns {Promise} 64 | */ 65 | async addAnswer(target) { 66 | const content = this.getAnswerContentFromActionArea(target); 67 | 68 | if (!content.length) { 69 | return; 70 | } 71 | 72 | const data = await jsonFetch('/ajax/comments/answer', { 73 | body: new FormData(target), 74 | method: 'POST' 75 | }) 76 | 77 | if (data.code !== 'ANSWER_ADDED_SUCCESSFULLY') { 78 | return; 79 | } 80 | 81 | const answer = data.detail.answer; 82 | const commentAnswers = target.nextElementSibling; 83 | 84 | commentAnswers.insertAdjacentHTML('beforeend', data.message); 85 | 86 | this.addCardFooterEventListeners(answer.id); 87 | this.comments.push(answer); 88 | 89 | this.currentReplyDialog.remove(); 90 | } 91 | 92 | /** 93 | * 94 | * @param {HTMLFormElement} target 95 | * @returns {Promise} 96 | */ 97 | async addComment(target) { 98 | const data = await jsonFetch('/ajax/comments', { 99 | body: new FormData(target), 100 | method: 'POST' 101 | }); 102 | 103 | if (data.code !== 'COMMENT_ADDED_SUCCESSFULLY') { 104 | return; 105 | } 106 | 107 | this.commentList.insertAdjacentHTML('beforeend', data.message); 108 | 109 | const comment = data.detail.comment; 110 | const commentElt = this.commentList.lastElementChild; 111 | commentElt.scrollIntoView(); 112 | 113 | this.addCardFooterEventListeners(comment.id); 114 | this.comments.push(comment); 115 | this.commentCount.innerText = data.detail.numberOfComments; 116 | 117 | $('#comment_content').value = ''; 118 | } 119 | 120 | /** 121 | * 122 | * @param {HTMLButtonElement} target 123 | * @returns {Promise} 124 | */ 125 | async editComment(target) { 126 | const commentId = target.dataset.id; 127 | const content = this.getCardTextElement(commentId).querySelector('#comment_content').value; 128 | 129 | const data = await jsonFetch(`/ajax/comments/${commentId}`, { 130 | body: JSON.stringify({content: content}), 131 | method: 'PATCH' 132 | }); 133 | 134 | if (data.code !== 'COMMENT_SUCCESSFULLY_EDITED') { 135 | return; 136 | } 137 | 138 | const modifiedComment = data.detail.comment; 139 | 140 | const commentIndex = this.comments.findIndex(comment => comment.id === modifiedComment.id); 141 | 142 | if (commentIndex !== -1) { 143 | this.comments[commentIndex] = modifiedComment; 144 | } 145 | } 146 | 147 | /** 148 | * 149 | * @param {Array} comments 150 | */ 151 | handleCommentsList(comments) { 152 | const commentList = document.createElement('div'); 153 | commentList.classList.add('comment-list'); 154 | 155 | comments.forEach(comment => { 156 | if (null === comment.parentId) { 157 | const item = document.createElement('div'); 158 | item.classList.add('comment'); 159 | item.id = `c${comment.id}`; 160 | item.innerHTML = getCommentElement(comment, this.userId); 161 | commentList.append(item); 162 | 163 | const replyList = document.createElement('div'); 164 | replyList.classList.add('comment-answers'); 165 | item.append(replyList); 166 | 167 | this.handleCommentReplies(comment.id, comments, replyList); 168 | } 169 | }); 170 | 171 | this.commentList.replaceWith(commentList); 172 | this.commentList = $('.comment-list'); 173 | } 174 | 175 | handleCommentReplies(commentId, comments, list) { 176 | for (let i = 0; (i < comments.length); i++) { 177 | if (commentId === comments[i].parentId) { 178 | const item = document.createElement('div'); 179 | item.classList.add('comment'); 180 | item.id = `c${comments[i].id}`; 181 | item.innerHTML = getCommentElement(comments[i], this.userId); 182 | 183 | list.append(item); 184 | 185 | const replyList = document.createElement('div'); 186 | replyList.classList.add('comment-answers'); 187 | item.append(replyList); 188 | 189 | this.handleCommentReplies(comments[i].id, comments, replyList); 190 | } 191 | } 192 | } 193 | 194 | /** 195 | * 196 | * @param {HTMLButtonElement} target 197 | * @returns {Promise} 198 | */ 199 | async deleteComment(target) { 200 | const data = await jsonFetch(`/ajax/comments/${target.dataset.id}`, { 201 | method: 'DELETE' 202 | }); 203 | 204 | if (data.code !== 'COMMENT_SUCCESSFULLY_DELETED') { 205 | return; 206 | } 207 | 208 | const commentElement = target.closest('.comment'); 209 | commentElement.remove(); 210 | 211 | this.commentCount.innerText = data.detail.numberOfComments; 212 | } 213 | 214 | async toggleEditArea(target) { 215 | const commentId = Number(target.dataset.id); 216 | const cardText = this.getCardTextElement(commentId); 217 | 218 | switch (target.dataset.action) { 219 | case 'showEditArea': 220 | const data = await jsonFetch(`/ajax/comments/${commentId}`, { 221 | method: 'GET' 222 | }); 223 | 224 | const cardFooter = this.getCardFooterElement(commentId); 225 | 226 | cardText.innerHTML = data.cardText 227 | cardFooter.innerHTML = data.cardFooter; 228 | 229 | cardFooter.querySelector('#save-edit-comment-button').addEventListener('click', async (e) => { 230 | await this.editComment(e.target); 231 | await this.toggleEditArea(e.target); 232 | }); 233 | 234 | cardFooter.querySelector('#cancel-edit-comment-button').addEventListener('click', async (e) => { 235 | await this.toggleEditArea(e.target); 236 | }); 237 | 238 | this.selectedCommentText = cardText.querySelector('#comment_content').value; 239 | break; 240 | case 'hideEditArea': 241 | $(`#c${commentId} > .card`).innerHTML = getCommentElement(this.comments.find(c => c.id === commentId), this.userId, true); 242 | this.addCardFooterEventListeners(commentId); 243 | break; 244 | default: 245 | break; 246 | } 247 | } 248 | 249 | listenCommentAnswerButton() { 250 | const answerCommentButtons = $$('#show-reply-dialog-button'); 251 | const deleteButtons = $$('#delete-comment-button'); 252 | const editButtons = $$('#edit-comment-button'); 253 | 254 | answerCommentButtons.forEach(btn => { 255 | btn.addEventListener('click', e => { 256 | this.showReplyDialog(e.target); 257 | }) 258 | }); 259 | 260 | deleteButtons.forEach(btn => { 261 | btn.addEventListener('click', async (e) => { 262 | await this.deleteComment(e.target); 263 | }) 264 | }); 265 | 266 | editButtons.forEach(btn => { 267 | btn.addEventListener('click', async (e) => { 268 | await this.toggleEditArea(e.target); 269 | }) 270 | }); 271 | } 272 | 273 | /** 274 | * 275 | * @param {HTMLButtonElement} target 276 | */ 277 | showReplyDialog(target) { 278 | const commentId = target.dataset.id; 279 | const replyDialogSelector = `#reply-dialog-${commentId}` 280 | 281 | if ($(replyDialogSelector)) { 282 | return; 283 | } 284 | 285 | if (undefined !== this.currentReplyDialog) { 286 | this.currentReplyDialog.remove(); 287 | } 288 | 289 | const replyDialogElement = getReplyDialogElement(commentId); 290 | target.parentElement.parentElement.insertAdjacentHTML('afterend', replyDialogElement); 291 | 292 | const answerButton = $('#answer-button'); 293 | const cancelAnswerCommentButton = $('#hide-reply-dialog-button'); 294 | 295 | $('.reply-form').addEventListener('submit', async (e) => { 296 | e.preventDefault(); 297 | await this.addAnswer(e.target); 298 | }); 299 | 300 | cancelAnswerCommentButton.addEventListener('click', e => { 301 | this.currentReplyDialog.remove(); 302 | }) 303 | 304 | this.currentReplyDialog = $(replyDialogSelector); 305 | } 306 | 307 | /** 308 | * @param {HTMLElement} element 309 | * @returns {*} 310 | */ 311 | getReplyDialogFromActionArea(element) { 312 | return element.parentElement.parentElement.parentElement; 313 | } 314 | 315 | /** 316 | * @param {HTMLElement} element 317 | * @returns {string} 318 | */ 319 | getAnswerContentFromActionArea(element) { 320 | return this.getReplyDialogFromActionArea(element).querySelector('#answer-content').value 321 | } 322 | 323 | getCardTextElement(commentId) { 324 | return $(`#c${commentId} > .card > .comment-content > .card-text`); 325 | } 326 | 327 | getCardFooterElement(commentId) { 328 | return $(`#c${commentId} > .card > .card-footer`); 329 | } 330 | 331 | addCardFooterEventListeners(commentId) { 332 | const cardFooter = this.getCardFooterElement(commentId); 333 | 334 | cardFooter.querySelector('#show-reply-dialog-button').addEventListener('click', (e) => { 335 | this.showReplyDialog(e.target); 336 | }); 337 | 338 | cardFooter.querySelector('#edit-comment-button').addEventListener('click', async (e) => { 339 | await this.toggleEditArea(e.target); 340 | }); 341 | 342 | cardFooter.querySelector('#delete-comment-button').addEventListener('click', async (e) => { 343 | await this.deleteComment(e.target); 344 | }); 345 | } 346 | } -------------------------------------------------------------------------------- /assets/js/comment_html_helper.js: -------------------------------------------------------------------------------- 1 | import TimeAgo from 'javascript-time-ago' 2 | import fr from 'javascript-time-ago/locale/fr' 3 | 4 | TimeAgo.addDefaultLocale(fr); 5 | 6 | export function getCommentElement(comment, userId, fromEdit = false) { 7 | let html = fromEdit ? '' : '
'; 8 | 9 | const timeAgo = new TimeAgo('fr-FR'); 10 | const createdAt = timeAgo.format(new Date(comment.createdAt)); 11 | 12 | html += `
13 |
14 | 15 | ${comment.username} 16 | 17 |
18 | ${createdAt} 19 |

${comment.content}

20 |
` 21 | 22 | html += `'; 31 | 32 | if (!fromEdit) { 33 | html += '
'; 34 | } 35 | 36 | return html; 37 | } 38 | 39 | /** 40 | * 41 | * @param {string} commentId 42 | * @returns {string} 43 | */ 44 | export function getReplyDialogElement(commentId) { 45 | return `
46 | 47 | 48 |
49 | 50 | 51 |
52 |
`; 53 | } -------------------------------------------------------------------------------- /assets/js/functions/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {RequestInfo} url 3 | * @param {RequestInit} init 4 | * @returns {Promise} 5 | */ 6 | export async function jsonFetch(url, init = {}) { 7 | init = { 8 | headers: { 9 | 'X-Requested-With': 'XMLHttpRequest', 10 | }, 11 | ...init 12 | } 13 | 14 | const response = await fetch(url, init); 15 | 16 | if (!response.ok) { 17 | return null; 18 | } 19 | 20 | return await response.json(); 21 | } -------------------------------------------------------------------------------- /assets/js/functions/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} selector 3 | * @return {HTMLElement} 4 | */ 5 | export function $(selector) { 6 | return document.querySelector(selector); 7 | } 8 | 9 | /** 10 | * @param {string} selector 11 | * @return {HTMLElement[]} 12 | */ 13 | export function $$(selector) { 14 | return Array.from(document.querySelectorAll(selector)); 15 | } -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =8.1", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "api-platform/core": "^3.1", 11 | "doctrine/annotations": "^1.14.3", 12 | "doctrine/doctrine-bundle": "^2.9.1", 13 | "doctrine/doctrine-migrations-bundle": "^3.2.2", 14 | "doctrine/orm": "^2.14.2", 15 | "easycorp/easyadmin-bundle": "^4.6.1", 16 | "knplabs/knp-paginator-bundle": "^5.9", 17 | "knplabs/knp-time-bundle": "^1.20", 18 | "nelmio/cors-bundle": "^2.3", 19 | "phpdocumentor/reflection-docblock": "^5.3", 20 | "phpstan/phpdoc-parser": "^1.20", 21 | "symfony/apache-pack": "^1.0.1", 22 | "symfony/asset": "6.3.*", 23 | "symfony/console": "6.3.*", 24 | "symfony/dotenv": "6.3.*", 25 | "symfony/expression-language": "6.3.*", 26 | "symfony/flex": "^2.2.5", 27 | "symfony/form": "6.3.*", 28 | "symfony/framework-bundle": "6.3.*", 29 | "symfony/http-client": "6.3.*", 30 | "symfony/intl": "6.3.*", 31 | "symfony/mailer": "6.3.*", 32 | "symfony/mime": "6.3.*", 33 | "symfony/monolog-bundle": "^3.8", 34 | "symfony/notifier": "6.3.*", 35 | "symfony/password-hasher": "6.3.*", 36 | "symfony/process": "6.3.*", 37 | "symfony/property-access": "6.3.*", 38 | "symfony/property-info": "6.3.*", 39 | "symfony/proxy-manager-bridge": "6.3.*", 40 | "symfony/runtime": "6.3.*", 41 | "symfony/security-bundle": "6.3.*", 42 | "symfony/security-csrf": "6.3.*", 43 | "symfony/serializer": "6.3.*", 44 | "symfony/string": "6.3.*", 45 | "symfony/translation": "6.3.*", 46 | "symfony/twig-bundle": "6.3.*", 47 | "symfony/validator": "6.3.*", 48 | "symfony/web-link": "6.3.*", 49 | "symfony/webapp-meta": "^1.0", 50 | "symfony/webpack-encore-bundle": "^1.16.1", 51 | "symfony/yaml": "6.3.*", 52 | "twig/extra-bundle": "^2.12|^3.5.1", 53 | "twig/twig": "^2.12|^3.5.1" 54 | }, 55 | "config": { 56 | "allow-plugins": { 57 | "composer/package-versions-deprecated": true, 58 | "symfony/flex": true, 59 | "symfony/runtime": true 60 | }, 61 | "optimize-autoloader": true, 62 | "preferred-install": { 63 | "*": "dist" 64 | }, 65 | "sort-packages": true 66 | }, 67 | "autoload": { 68 | "psr-4": { 69 | "App\\": "src/" 70 | } 71 | }, 72 | "autoload-dev": { 73 | "psr-4": { 74 | "App\\Tests\\": "tests/" 75 | } 76 | }, 77 | "replace": { 78 | "symfony/polyfill-ctype": "*", 79 | "symfony/polyfill-iconv": "*", 80 | "symfony/polyfill-php72": "*", 81 | "symfony/polyfill-php73": "*", 82 | "symfony/polyfill-php74": "*", 83 | "symfony/polyfill-php80": "*" 84 | }, 85 | "scripts": { 86 | "auto-scripts": { 87 | "cache:clear": "symfony-cmd", 88 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 89 | }, 90 | "post-install-cmd": [ 91 | "@auto-scripts" 92 | ], 93 | "post-update-cmd": [ 94 | "@auto-scripts" 95 | ] 96 | }, 97 | "conflict": { 98 | "symfony/symfony": "*" 99 | }, 100 | "extra": { 101 | "symfony": { 102 | "allow-contrib": false, 103 | "require": "6.3.*" 104 | } 105 | }, 106 | "require-dev": { 107 | "doctrine/doctrine-fixtures-bundle": "^3.4.3", 108 | "symfony/debug-bundle": "6.3.*", 109 | "symfony/maker-bundle": "^1.48", 110 | "symfony/stopwatch": "6.3.*", 111 | "symfony/web-profiler-bundle": "6.3.*" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 7 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 8 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 9 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 10 | Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], 11 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 12 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 13 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 14 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 15 | EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true], 16 | Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], 17 | Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], 18 | Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true], 19 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], 20 | ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], 21 | ]; 22 | -------------------------------------------------------------------------------- /config/packages/assets.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | assets: 3 | json_manifest_path: '%kernel.project_dir%/public/build/manifest.json' 4 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /config/packages/dev/debug.yaml: -------------------------------------------------------------------------------- 1 | debug: 2 | # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. 3 | # See the "server:dump" command to start a new server. 4 | dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" 5 | -------------------------------------------------------------------------------- /config/packages/dev/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: stream 5 | path: "%kernel.logs_dir%/%kernel.environment%.log" 6 | level: debug 7 | channels: ["!event"] 8 | # uncomment to get logging in your browser 9 | # you may have to allow bigger header sizes in your Web server configuration 10 | #firephp: 11 | # type: firephp 12 | # level: info 13 | #chromephp: 14 | # type: chromephp 15 | # level: info 16 | console: 17 | type: console 18 | process_psr_3_messages: false 19 | channels: ["!event", "!doctrine", "!console"] 20 | -------------------------------------------------------------------------------- /config/packages/dev/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: true 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { only_exceptions: false } 7 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '13' 8 | orm: 9 | auto_generate_proxy_classes: true 10 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 11 | auto_mapping: true 12 | mappings: 13 | App: 14 | is_bundle: false 15 | dir: '%kernel.project_dir%/src/Entity' 16 | prefix: 'App\Entity' 17 | alias: App 18 | -------------------------------------------------------------------------------- /config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | migrations_paths: 3 | # namespace is arbitrary but should be different from App\Migrations 4 | # as migrations classes should NOT be autoloaded 5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations' 6 | enable_profiler: '%kernel.debug%' 7 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | csrf_protection: true 5 | http_method_override: false 6 | 7 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 8 | # Remove or comment this section to explicitly disable session support. 9 | session: 10 | handler_id: null 11 | cookie_secure: auto 12 | cookie_samesite: lax 13 | storage_factory_id: session.storage.factory.native 14 | 15 | #esi: true 16 | #fragments: true 17 | php_errors: 18 | log: true 19 | 20 | when@test: 21 | framework: 22 | test: true 23 | session: 24 | storage_factory_id: session.storage.factory.mock_file 25 | 26 | when@prod: 27 | error_controller: App\Controller\ErrorController::show -------------------------------------------------------------------------------- /config/packages/mailer.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | mailer: 3 | dsn: '%env(MAILER_DSN)%' 4 | -------------------------------------------------------------------------------- /config/packages/nelmio_cors.yaml: -------------------------------------------------------------------------------- 1 | nelmio_cors: 2 | defaults: 3 | origin_regex: true 4 | allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] 5 | allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] 6 | allow_headers: ['Content-Type', 'Authorization'] 7 | expose_headers: ['Link'] 8 | max_age: 3600 9 | paths: 10 | '^/': null 11 | -------------------------------------------------------------------------------- /config/packages/notifier.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | notifier: 3 | #chatter_transports: 4 | # slack: '%env(SLACK_DSN)%' 5 | # telegram: '%env(TELEGRAM_DSN)%' 6 | #texter_transports: 7 | # twilio: '%env(TWILIO_DSN)%' 8 | # nexmo: '%env(NEXMO_DSN)%' 9 | channel_policy: 10 | # use chat/slack, chat/telegram, sms/twilio or sms/nexmo 11 | urgent: ['email'] 12 | high: ['email'] 13 | medium: ['email'] 14 | low: ['email'] 15 | admin_recipients: 16 | - { email: admin@example.com } 17 | -------------------------------------------------------------------------------- /config/packages/paginator.yaml: -------------------------------------------------------------------------------- 1 | knp_paginator: 2 | page_range: 5 # number of links shown in the pagination menu (e.g: you have 10 pages, a page_range of 3, on the 5th page you'll see links to page 4, 5, 6) 3 | default_options: 4 | page_name: page # page query parameter name 5 | sort_field_name: sort # sort field query parameter name 6 | sort_direction_name: direction # sort direction query parameter name 7 | distinct: true # ensure distinct results, useful when ORM queries are using GROUP BY statements 8 | filter_field_name: filterField # filter field query parameter name 9 | filter_value_name: filterValue # filter value query parameter name 10 | template: 11 | pagination: '@KnpPaginator/Pagination/bootstrap_v5_pagination.html.twig' # sliding pagination controls template 12 | sortable: '@KnpPaginator/Pagination/bootstrap_v5_bi_sortable_link.html.twig' # sort link template 13 | filtration: '@KnpPaginator/Pagination/bootstrap_v5_filtration.html.twig' # filters template -------------------------------------------------------------------------------- /config/packages/prod/deprecations.yaml: -------------------------------------------------------------------------------- 1 | # As of Symfony 5.1, deprecations are logged in the dedicated "deprecation" channel when it exists 2 | #monolog: 3 | # channels: [deprecation] 4 | # handlers: 5 | # deprecation: 6 | # type: stream 7 | # channels: [deprecation] 8 | # path: php://stderr 9 | -------------------------------------------------------------------------------- /config/packages/prod/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | orm: 3 | auto_generate_proxy_classes: false 4 | query_cache_driver: 5 | type: pool 6 | pool: doctrine.system_cache_pool 7 | result_cache_driver: 8 | type: pool 9 | pool: doctrine.result_cache_pool 10 | 11 | framework: 12 | cache: 13 | pools: 14 | doctrine.result_cache_pool: 15 | adapter: cache.app 16 | doctrine.system_cache_pool: 17 | adapter: cache.system 18 | -------------------------------------------------------------------------------- /config/packages/prod/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 9 | nested: 10 | type: stream 11 | path: php://stderr 12 | level: debug 13 | formatter: monolog.formatter.json 14 | console: 15 | type: console 16 | process_psr_3_messages: false 17 | channels: ["!event", "!doctrine"] 18 | -------------------------------------------------------------------------------- /config/packages/prod/webpack_encore.yaml: -------------------------------------------------------------------------------- 1 | #webpack_encore: 2 | # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes) 3 | # Available in version 1.2 4 | #cache: true 5 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | 5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 7 | #default_uri: http://localhost 8 | 9 | when@prod: 10 | framework: 11 | router: 12 | strict_requirements: null 13 | -------------------------------------------------------------------------------- /config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | enable_authenticator_manager: true 3 | # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords 4 | password_hashers: 5 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' 6 | App\Entity\User: 7 | algorithm: auto 8 | 9 | # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider 10 | providers: 11 | # used to reload user from session & other features (e.g. switch_user) 12 | app_user_provider: 13 | entity: 14 | class: App\Entity\User 15 | property: username 16 | firewalls: 17 | dev: 18 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 19 | security: false 20 | main: 21 | lazy: true 22 | provider: app_user_provider 23 | form_login: 24 | login_path: login 25 | check_path: login 26 | enable_csrf: true 27 | logout: 28 | path: logout 29 | 30 | # activate different ways to authenticate 31 | # https://symfony.com/doc/current/security.html#the-firewall 32 | 33 | # https://symfony.com/doc/current/security/impersonating_user.html 34 | # switch_user: true 35 | 36 | # Easy way to control access for large sections of your site 37 | # Note: Only the *first* access control that matches will be used 38 | access_control: 39 | # - { path: ^/admin, roles: ROLE_ADMIN } 40 | # - { path: ^/profile, roles: ROLE_USER } 41 | 42 | role_hierarchy: 43 | ROLE_ADMIN: [ROLE_USER, ROLE_AUTHOR] 44 | 45 | when@test: 46 | security: 47 | password_hashers: 48 | # By default, password hashers are resource intensive and take time. This is 49 | # important to generate secure password hashes. In tests however, secure hashes 50 | # are not important, waste resources and increase test times. The following 51 | # reduces the work factor to the lowest possible values. 52 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 53 | algorithm: auto 54 | cost: 4 # Lowest possible value for bcrypt 55 | time_cost: 3 # Lowest possible value for argon 56 | memory_cost: 10 # Lowest possible value for argon 57 | -------------------------------------------------------------------------------- /config/packages/test/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | # "TEST_TOKEN" is typically set by ParaTest 4 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 5 | -------------------------------------------------------------------------------- /config/packages/test/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | channels: ["!event"] 9 | nested: 10 | type: stream 11 | path: "%kernel.logs_dir%/%kernel.environment%.log" 12 | level: debug 13 | -------------------------------------------------------------------------------- /config/packages/test/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | not_compromised_password: false 4 | -------------------------------------------------------------------------------- /config/packages/test/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: false 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { collect: false } 7 | -------------------------------------------------------------------------------- /config/packages/test/webpack_encore.yaml: -------------------------------------------------------------------------------- 1 | #webpack_encore: 2 | # strict_mode: false 3 | -------------------------------------------------------------------------------- /config/packages/translation.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | default_locale: fr 3 | translator: 4 | default_path: '%kernel.project_dir%/translations' 5 | fallbacks: 6 | - en 7 | # providers: 8 | # crowdin: 9 | # dsn: '%env(CROWDIN_DSN)%' 10 | # loco: 11 | # dsn: '%env(LOCO_DSN)%' 12 | # lokalise: 13 | # dsn: '%env(LOKALISE_DSN)%' 14 | -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | default_path: '%kernel.project_dir%/templates' 3 | form_themes: ['bootstrap_5_layout.html.twig'] 4 | globals: 5 | menu_service: '@App\Service\MenuService' 6 | option_service: '@App\Service\OptionService' 7 | 8 | when@test: 9 | twig: 10 | strict_variables: true 11 | -------------------------------------------------------------------------------- /config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | email_validation_mode: html5 4 | 5 | # Enables validator auto-mapping support. 6 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 7 | #auto_mapping: 8 | # App\Entity\: [] 9 | -------------------------------------------------------------------------------- /config/packages/webpack_encore.yaml: -------------------------------------------------------------------------------- 1 | webpack_encore: 2 | # The path where Encore is building the assets - i.e. Encore.setOutputPath() 3 | output_path: '%kernel.project_dir%/public/build' 4 | # If multiple builds are defined (as shown below), you can disable the default build: 5 | # output_path: false 6 | 7 | # Set attributes that will be rendered on all script and link tags 8 | script_attributes: 9 | defer: true 10 | 'data-turbo-track': reload 11 | link_attributes: 12 | 'data-turbo-track': reload 13 | 14 | # If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials') 15 | # crossorigin: 'anonymous' 16 | 17 | # Preload all rendered script and link tags automatically via the HTTP/2 Link header 18 | # preload: true 19 | 20 | # Throw an exception if the entrypoints.json file is missing or an entry is missing from the data 21 | # strict_mode: false 22 | 23 | # If you have multiple builds: 24 | # builds: 25 | # pass "frontend" as the 3rg arg to the Twig functions 26 | # {{ encore_entry_script_tags('entry1', null, 'frontend') }} 27 | 28 | # frontend: '%kernel.project_dir%/public/frontend/build' 29 | 30 | # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes) 31 | # Put in config/packages/prod/webpack_encore.yaml 32 | # cache: true 33 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE article (id INT AUTO_INCREMENT NOT NULL, featured_image_id INT DEFAULT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, content LONGTEXT DEFAULT NULL, featured_text LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, INDEX IDX_23A0E663569D950 (featured_image_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 24 | $this->addSql('CREATE TABLE article_category (article_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_53A4EDAA7294869C (article_id), INDEX IDX_53A4EDAA12469DE2 (category_id), PRIMARY KEY(article_id, category_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 25 | $this->addSql('CREATE TABLE category (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, color VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 26 | $this->addSql('CREATE TABLE comment (id INT AUTO_INCREMENT NOT NULL, article_id INT NOT NULL, user_id INT NOT NULL, parent_id INT DEFAULT NULL, content LONGTEXT NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_9474526C7294869C (article_id), INDEX IDX_9474526CA76ED395 (user_id), INDEX IDX_9474526C727ACA70 (parent_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 27 | $this->addSql('CREATE TABLE media (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, filename VARCHAR(255) NOT NULL, alt_text VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 28 | $this->addSql('CREATE TABLE menu (id INT AUTO_INCREMENT NOT NULL, article_id INT DEFAULT NULL, category_id INT DEFAULT NULL, page_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, menu_order INT DEFAULT NULL, is_visible TINYINT(1) NOT NULL, link VARCHAR(255) DEFAULT NULL, INDEX IDX_7D053A937294869C (article_id), INDEX IDX_7D053A9312469DE2 (category_id), INDEX IDX_7D053A93C4663E4 (page_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 29 | $this->addSql('CREATE TABLE menu_menu (menu_source INT NOT NULL, menu_target INT NOT NULL, INDEX IDX_B54ACADD8CCD27AB (menu_source), INDEX IDX_B54ACADD95287724 (menu_target), PRIMARY KEY(menu_source, menu_target)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 30 | $this->addSql('CREATE TABLE `option` (id INT AUTO_INCREMENT NOT NULL, label VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, value LONGTEXT DEFAULT NULL, type VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_5A8600B05E237E06 (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 31 | $this->addSql('CREATE TABLE page (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, content LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 32 | $this->addSql('CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, username VARCHAR(180) NOT NULL, roles LONGTEXT NOT NULL COMMENT \'(DC2Type:json)\', password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649F85E0677 (username), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 33 | $this->addSql('ALTER TABLE article ADD CONSTRAINT FK_23A0E663569D950 FOREIGN KEY (featured_image_id) REFERENCES media (id)'); 34 | $this->addSql('ALTER TABLE article_category ADD CONSTRAINT FK_53A4EDAA7294869C FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE'); 35 | $this->addSql('ALTER TABLE article_category ADD CONSTRAINT FK_53A4EDAA12469DE2 FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE'); 36 | $this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526C7294869C FOREIGN KEY (article_id) REFERENCES article (id)'); 37 | $this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526CA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)'); 38 | $this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526C727ACA70 FOREIGN KEY (parent_id) REFERENCES comment (id) ON DELETE CASCADE'); 39 | $this->addSql('ALTER TABLE menu ADD CONSTRAINT FK_7D053A937294869C FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE'); 40 | $this->addSql('ALTER TABLE menu ADD CONSTRAINT FK_7D053A9312469DE2 FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE'); 41 | $this->addSql('ALTER TABLE menu ADD CONSTRAINT FK_7D053A93C4663E4 FOREIGN KEY (page_id) REFERENCES page (id) ON DELETE CASCADE'); 42 | $this->addSql('ALTER TABLE menu_menu ADD CONSTRAINT FK_B54ACADD8CCD27AB FOREIGN KEY (menu_source) REFERENCES menu (id) ON DELETE CASCADE'); 43 | $this->addSql('ALTER TABLE menu_menu ADD CONSTRAINT FK_B54ACADD95287724 FOREIGN KEY (menu_target) REFERENCES menu (id) ON DELETE CASCADE'); 44 | } 45 | 46 | public function down(Schema $schema): void 47 | { 48 | // this down() migration is auto-generated, please modify it to your needs 49 | $this->addSql('ALTER TABLE article DROP FOREIGN KEY FK_23A0E663569D950'); 50 | $this->addSql('ALTER TABLE article_category DROP FOREIGN KEY FK_53A4EDAA7294869C'); 51 | $this->addSql('ALTER TABLE article_category DROP FOREIGN KEY FK_53A4EDAA12469DE2'); 52 | $this->addSql('ALTER TABLE comment DROP FOREIGN KEY FK_9474526C7294869C'); 53 | $this->addSql('ALTER TABLE comment DROP FOREIGN KEY FK_9474526CA76ED395'); 54 | $this->addSql('ALTER TABLE comment DROP FOREIGN KEY FK_9474526C727ACA70'); 55 | $this->addSql('ALTER TABLE menu DROP FOREIGN KEY FK_7D053A937294869C'); 56 | $this->addSql('ALTER TABLE menu DROP FOREIGN KEY FK_7D053A9312469DE2'); 57 | $this->addSql('ALTER TABLE menu DROP FOREIGN KEY FK_7D053A93C4663E4'); 58 | $this->addSql('ALTER TABLE menu_menu DROP FOREIGN KEY FK_B54ACADD8CCD27AB'); 59 | $this->addSql('ALTER TABLE menu_menu DROP FOREIGN KEY FK_B54ACADD95287724'); 60 | $this->addSql('DROP TABLE article'); 61 | $this->addSql('DROP TABLE article_category'); 62 | $this->addSql('DROP TABLE category'); 63 | $this->addSql('DROP TABLE comment'); 64 | $this->addSql('DROP TABLE media'); 65 | $this->addSql('DROP TABLE menu'); 66 | $this->addSql('DROP TABLE menu_menu'); 67 | $this->addSql('DROP TABLE `option`'); 68 | $this->addSql('DROP TABLE page'); 69 | $this->addSql('DROP TABLE user'); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /migrations/Version20230420131909.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE article CHANGE content content LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:json)\''); 24 | $this->addSql('ALTER TABLE article ADD author_id INT NOT NULL'); 25 | $this->addSql('ALTER TABLE article ADD CONSTRAINT FK_23A0E66F675F31B FOREIGN KEY (author_id) REFERENCES user (id)'); 26 | $this->addSql('CREATE INDEX IDX_23A0E66F675F31B ON article (author_id)'); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | // this down() migration is auto-generated, please modify it to your needs 32 | $this->addSql('ALTER TABLE article CHANGE content content LONGTEXT DEFAULT NULL'); 33 | $this->addSql('ALTER TABLE article DROP FOREIGN KEY FK_23A0E66F675F31B'); 34 | $this->addSql('DROP INDEX IDX_23A0E66F675F31B ON article'); 35 | $this->addSql('ALTER TABLE article DROP author_id'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@symfony/webpack-encore": "^4.3.0", 4 | "core-js": "^3.30.1", 5 | "regenerator-runtime": "^0.13.11", 6 | "sass": "^1.62.0", 7 | "sass-loader": "^13.2.2", 8 | "webpack-notifier": "^1.15.0" 9 | }, 10 | "license": "UNLICENSED", 11 | "private": true, 12 | "scripts": { 13 | "dev-server": "encore dev-server", 14 | "dev": "encore dev", 15 | "watch": "encore dev --watch", 16 | "build": "encore production --progress" 17 | }, 18 | "dependencies": { 19 | "@editorjs/checklist": "^1.5.0", 20 | "@editorjs/editorjs": "^2.26.5", 21 | "@editorjs/embed": "^2.5.3", 22 | "@editorjs/header": "^2.7.0", 23 | "@editorjs/list": "^1.8.0", 24 | "@fortawesome/fontawesome-free": "^6.4.0", 25 | "@tarekraafat/autocomplete.js": "^10.2.7", 26 | "bootstrap": "^5.2.3", 27 | "javascript-time-ago": "^2.5.9" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | setHtmlAttributes([ 33 | 'target' => '_blank' 34 | ]) 35 | ->linkToCrudAction('viewArticle'); 36 | 37 | return $actions 38 | ->add(Crud::PAGE_EDIT, $viewArticle) 39 | ->add(Crud::PAGE_INDEX, $viewArticle); 40 | } 41 | 42 | public function configureCrud(Crud $crud): Crud 43 | { 44 | return $crud->setEntityPermission('ROLE_AUTHOR'); 45 | } 46 | 47 | public function configureFields(string $pageName): iterable 48 | { 49 | yield TextField::new('title'); 50 | 51 | yield SlugField::new('slug') 52 | ->setTargetFieldName('title'); 53 | 54 | yield TextareaField::new('featuredText', 'Texte mis en avant'); 55 | 56 | yield AssociationField::new('categories'); 57 | 58 | yield AssociationField::new('featuredImage'); 59 | 60 | yield CollectionField::new('comments') 61 | ->setEntryType(CommentType::class) 62 | ->allowAdd(false) 63 | ->allowDelete(false) 64 | ->onlyOnForms() 65 | ->hideWhenCreating(); 66 | 67 | yield DateTimeField::new('createdAt')->hideOnForm(); 68 | } 69 | 70 | public function persistEntity(EntityManagerInterface $entityManager, $entityInstance): void 71 | { 72 | /** @var Article $article */ 73 | $article = $entityInstance; 74 | 75 | $article->setAuthor($this->getUser()); 76 | 77 | parent::persistEntity($entityManager, $article); 78 | } 79 | 80 | public function viewArticle(AdminContext $context): Response 81 | { 82 | /** @var Article $article */ 83 | $article = $context->getEntity()->getInstance(); 84 | 85 | return $this->redirectToRoute('article_show', [ 86 | 'slug' => $article->getSlug() 87 | ]); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Controller/Admin/CategoryCrudController.php: -------------------------------------------------------------------------------- 1 | setPageTitle(Crud::PAGE_INDEX, 'Catégories'); 23 | } 24 | 25 | 26 | public function configureFields(string $pageName): iterable 27 | { 28 | yield TextField::new('name', 'Nom'); 29 | yield SlugField::new('slug')->setTargetFieldName('name'); 30 | yield ColorField::new('color'); 31 | yield AssociationField::new('articles') 32 | ->setFormTypeOption('by_reference', false); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Controller/Admin/CommentCrudController.php: -------------------------------------------------------------------------------- 1 | remove(Crud::PAGE_INDEX, Action::NEW); 25 | } 26 | 27 | public function configureFields(string $pageName): iterable 28 | { 29 | return [ 30 | TextareaField::new('content'), 31 | DateTimeField::new('createdAt'), 32 | DateTimeField::new('updatedAt'), 33 | AssociationField::new('user'), 34 | ]; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/Controller/Admin/DashboardController.php: -------------------------------------------------------------------------------- 1 | isGranted('IS_AUTHENTICATED_FULLY')) { 34 | return $this->redirectToRoute('login'); 35 | } 36 | 37 | $controller = $this->isGranted('ROLE_ADMIN') ? MenuCrudController::class : ArticleCrudController::class; 38 | 39 | $url = $this->adminUrlGenerator 40 | ->setController($controller) 41 | ->generateUrl(); 42 | 43 | return $this->redirect($url); 44 | } 45 | 46 | public function configureDashboard(): Dashboard 47 | { 48 | return Dashboard::new() 49 | ->setTitle('Pentiminax CMS') 50 | ->renderContentMaximized(); 51 | } 52 | 53 | public function configureMenuItems(): iterable 54 | { 55 | yield MenuItem::linkToRoute('Aller sur le site', 'fas fa-undo', 'home'); 56 | 57 | if ($this->isGranted('ROLE_ADMIN')) { 58 | yield MenuItem::subMenu('Menus', 'fas fa-list')->setSubItems([ 59 | MenuItem::linkToCrud('Pages', 'fas fa-file', Menu::class) 60 | ->setQueryParameter('submenuIndex', 0), 61 | MenuItem::linkToCrud('Articles', 'fas fa-newspaper', Menu::class) 62 | ->setQueryParameter('submenuIndex', 1), 63 | MenuItem::linkToCrud('Liens personnalisés', 'fas fa-link', Menu::class) 64 | ->setQueryParameter('submenuIndex', 2), 65 | MenuItem::linkToCrud('Catégories', 'fab fa-delicious', Menu::class) 66 | ->setQueryParameter('submenuIndex', 3), 67 | ]); 68 | } 69 | 70 | if ($this->isGranted('ROLE_AUTHOR')) { 71 | yield MenuItem::subMenu('Articles', 'fas fa-newspaper')->setSubItems([ 72 | MenuItem::linkToCrud('Tous les articles', 'fas fa-newspaper', Article::class), 73 | MenuItem::linkToCrud('Ajouter', 'fas fa-plus', Article::class)->setAction(Crud::PAGE_NEW), 74 | MenuItem::linkToCrud('Catégories', 'fas fa-list', Category::class) 75 | ]); 76 | 77 | yield MenuItem::subMenu('Médias', 'fas fa-photo-video')->setSubItems([ 78 | MenuItem::linkToCrud('Médiathèque', 'fas fa-photo-video', Media::class), 79 | MenuItem::linkToCrud('Ajouter', 'fas fa-plus', Media::class)->setAction(Crud::PAGE_NEW), 80 | ]); 81 | } 82 | 83 | if ($this->isGranted('ROLE_ADMIN')) { 84 | yield MenuItem::subMenu('Pages', 'fas fa-file')->setSubItems([ 85 | MenuItem::linkToCrud('Toutes les pages', 'fas fa-file', Page::class), 86 | MenuItem::linkToCrud('Ajouter une page', 'fas fa-plus', Page::class)->setAction(Crud::PAGE_NEW) 87 | ]); 88 | 89 | yield MenuItem::linkToCrud('Commentaires', 'fas fa-comment', Comment::class); 90 | 91 | yield MenuItem::subMenu('Comptes', 'fas fa-user')->setSubItems([ 92 | MenuItem::linkToCrud('Tous les comptes', 'fas fa-user-friends', User::class), 93 | MenuItem::linkToCrud('Ajouter', 'fas fa-plus', User::class)->setAction(Crud::PAGE_NEW) 94 | ]); 95 | 96 | yield MenuItem::subMenu('Réglages', 'fas fa-cog')->setSubItems([ 97 | MenuItem::linkToCrud('Général', 'fas fa-cog', Option::class), 98 | ]); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Controller/Admin/MediaCrudController.php: -------------------------------------------------------------------------------- 1 | getParameter('medias_directory'); 30 | $uploadsDir = $this->getParameter('uploads_directory'); 31 | 32 | yield TextField::new('name'); 33 | 34 | $imageField = ImageField::new('filename', 'Média') 35 | ->setBasePath($uploadsDir) 36 | ->setUploadDir($mediasDir) 37 | ->setUploadedFileNamePattern('[slug]-[uuid].[extension]'); 38 | 39 | if (Crud::PAGE_EDIT == $pageName) { 40 | $imageField->setRequired(false); 41 | } 42 | 43 | yield $imageField; 44 | } 45 | 46 | public function persistEntity(EntityManagerInterface $entityManager, $entityInstance): void 47 | { 48 | /** @var Media $media */ 49 | $media = $entityInstance; 50 | 51 | $media->setTitle($media->getFilename()); 52 | $media->setCreatedAt(new \DateTime()); 53 | 54 | parent::persistEntity($entityManager, $media); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/Controller/Admin/MenuCrudController.php: -------------------------------------------------------------------------------- 1 | getSubMenuIndex(); 42 | 43 | $entityLabelInSingular = 'un menu'; 44 | 45 | $entityLabelInPlural = match ($subMenuIndex) { 46 | self::MENU_ARTICLES => 'Articles', 47 | self::MENU_CATEGORIES => 'Catégories', 48 | self::MENU_LINKS => 'Liens personnalisés', 49 | default => 'Pages' 50 | }; 51 | 52 | return $crud 53 | ->setEntityLabelInSingular($entityLabelInSingular) 54 | ->setEntityLabelInPlural($entityLabelInPlural); 55 | } 56 | 57 | public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder 58 | { 59 | $subMenuIndex = $this->getSubMenuIndex(); 60 | 61 | return $this->menuRepo->getIndexQueryBuilder($this->getFieldNameFromSubMenuIndex($subMenuIndex)); 62 | } 63 | 64 | public function configureFields(string $pageName): iterable 65 | { 66 | $subMenuIndex = $this->getSubMenuIndex(); 67 | 68 | yield TextField::new('name', 'Titre de la navigation'); 69 | 70 | yield NumberField::new('menuOrder', 'Ordre'); 71 | 72 | yield $this->getFieldFromSubMenuIndex($subMenuIndex) 73 | ->setRequired(true); 74 | 75 | yield BooleanField::new('isVisible', 'Visible'); 76 | 77 | yield AssociationField::new('subMenus', 'Sous-éléments'); 78 | } 79 | 80 | private function getFieldNameFromSubMenuIndex(int $subMenuIndex): string 81 | { 82 | return match ($subMenuIndex) { 83 | self::MENU_ARTICLES => 'article', 84 | self::MENU_CATEGORIES => 'category', 85 | self::MENU_LINKS => 'link', 86 | default => 'page' 87 | }; 88 | } 89 | 90 | private function getFieldFromSubMenuIndex(int $subMenuIndex): AssociationField|TextField 91 | { 92 | $fieldName = $this->getFieldNameFromSubMenuIndex($subMenuIndex); 93 | 94 | return ($fieldName === 'link') ? TextField::new($fieldName) : AssociationField::new($fieldName); 95 | } 96 | 97 | private function getSubMenuIndex(): int 98 | { 99 | $query = $this->requestStack->getMainRequest()->query; 100 | 101 | if ($referer = $query->get('referrer')) { 102 | parse_str(parse_url($referer, PHP_URL_QUERY), $query); 103 | 104 | return $query['submenuIndex'] ?? 0; 105 | } 106 | 107 | return $query->getInt('submenuIndex'); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Controller/Admin/OptionCrudController.php: -------------------------------------------------------------------------------- 1 | remove(Crud::PAGE_INDEX, Action::BATCH_DELETE) 42 | ->remove(Crud::PAGE_INDEX, Action::DELETE) 43 | ->remove(Crud::PAGE_INDEX, Action::NEW); 44 | } 45 | 46 | public function configureCrud(Crud $crud): Crud 47 | { 48 | return $crud 49 | ->setEntityPermission('ROLE_ADMIN') 50 | ->setSearchFields(null) 51 | ->setEntityLabelInPlural('Réglages généraux') 52 | ->showEntityActionsInlined(); 53 | } 54 | 55 | public function createEditForm(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormInterface 56 | { 57 | $formBuilder = parent::createEditForm($entityDto, $formOptions, $context); 58 | 59 | $value = $formBuilder->getViewData()->getValue(); 60 | $type = $formBuilder->get('type')->getData(); 61 | 62 | $formBuilder->add('value', $type, [ 63 | 'data' => $type === CheckboxType::class ? boolval($value) : $value, 64 | ]); 65 | 66 | return $formBuilder; 67 | } 68 | 69 | public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder 70 | { 71 | return $this->optionRepo->getIndexQueryBuilder(); 72 | } 73 | 74 | public function index(AdminContext $context): KeyValueStore|Response 75 | { 76 | if (!$this->isGranted('ROLE_ADMIN')) { 77 | return $this->redirectToRoute('login'); 78 | } 79 | 80 | $response = parent::index($context); 81 | 82 | if ($response instanceof Response) { 83 | return $response; 84 | } 85 | 86 | /** @var EntityCollection $entities */ 87 | $entities = $response->get('entities'); 88 | 89 | foreach ($entities as $entity) { 90 | $fields = $entity->getFields(); 91 | 92 | $valueField = $fields->getByProperty('value'); 93 | $typeField = $fields->getByProperty('type'); 94 | 95 | $type = $typeField->getValue(); 96 | 97 | $valueField->setFormType($type); 98 | 99 | $entity->getFields()->unset($typeField); 100 | } 101 | 102 | $response->set('entities', $entities); 103 | 104 | return $response; 105 | } 106 | 107 | public function configureFields(string $pageName): iterable 108 | { 109 | yield TextField::new('label', 'Option') 110 | ->setFormTypeOption('attr', [ 111 | 'readonly' => true 112 | ]) 113 | ->setSortable(false); 114 | 115 | yield TextField::new('value'); 116 | 117 | yield HiddenField::new('type'); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Controller/Admin/PageCrudController.php: -------------------------------------------------------------------------------- 1 | hideOnForm(), 26 | TextField::new('title'), 27 | SlugField::new('slug')->setTargetFieldName('title'), 28 | DateTimeField::new('modifiedAt')->hideOnForm(), 29 | TextEditorField::new('content'), 30 | ]; 31 | } 32 | 33 | public function persistEntity(EntityManagerInterface $entityManager, $entityInstance): void 34 | { 35 | /** @var Page $page */ 36 | $page = $entityInstance; 37 | 38 | $page->setCreatedAt(new \DateTime()); 39 | 40 | parent::persistEntity($entityManager, $entityInstance); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Controller/Admin/UserCrudController.php: -------------------------------------------------------------------------------- 1 | getUser()->getId(); 38 | 39 | $response = $this->entityRepo->createQueryBuilder($searchDto, $entityDto, $fields, $filters); 40 | $response->andWhere('entity.id != :userId')->setParameter('userId', $userId ); 41 | 42 | return $response; 43 | } 44 | 45 | public function configureCrud(Crud $crud): Crud 46 | { 47 | return $crud 48 | ->setEntityLabelInSingular('Utilisateur') 49 | ->setEntityLabelInPlural('Utilisateurs'); 50 | } 51 | 52 | public function configureFields(string $pageName): iterable 53 | { 54 | yield TextField::new('username'); 55 | 56 | yield TextField::new('password')->onlyOnForms() 57 | ->setFormType(PasswordType::class); 58 | 59 | yield ChoiceField::new('roles') 60 | ->allowMultipleChoices() 61 | ->renderAsBadges([ 62 | 'ROLE_ADMIN' => 'success', 63 | 'ROLE_AUTHOR' => 'warning' 64 | ]) 65 | ->setChoices([ 66 | 'Administrateur' => 'ROLE_ADMIN', 67 | 'Auteur' => 'ROLE_AUTHOR' 68 | ]); 69 | } 70 | 71 | public function persistEntity(EntityManagerInterface $entityManager, $entityInstance): void 72 | { 73 | /** @var User $user */ 74 | $user = $entityInstance; 75 | 76 | $plainPassword = $user->getPassword(); 77 | $hashedPassword = $this->passwordHasher->hashPassword($user, $plainPassword); 78 | 79 | $user->setPassword($hashedPassword); 80 | 81 | parent::persistEntity($entityManager, $user); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Controller/ArticleController.php: -------------------------------------------------------------------------------- 1 | redirectToRoute('home'); 25 | } 26 | 27 | $parameters = [ 28 | 'entity' => $article, 29 | 'preview' => $request->query->getBoolean('preview') 30 | ]; 31 | 32 | if ($this->isGranted('IS_AUTHENTICATED_FULLY')) { 33 | $commentForm = $this->createForm(CommentType::class, new Comment($article, $this->getUser())); 34 | $parameters['commentForm'] = $commentForm; 35 | } 36 | 37 | return $this->render('article/index.html.twig', $parameters); 38 | } 39 | 40 | #[Route('/ajax/articles/{id}/comments', name: 'article_list_comments', methods: ['GET'])] 41 | public function listComments(?Article $article, NormalizerInterface $normalizer): Response 42 | { 43 | $comments = $normalizer->normalize($article->getComments(), context: [ 44 | 'groups' => 'comment' 45 | ]); 46 | 47 | return $this->json($comments); 48 | } 49 | } -------------------------------------------------------------------------------- /src/Controller/CategoryController.php: -------------------------------------------------------------------------------- 1 | redirectToRoute('home'); 18 | } 19 | 20 | return $this->render('category/index.html.twig', [ 21 | 'entity' => $category, 22 | 'articles' => $articleService->getPaginatedArticles($category) 23 | ]); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Controller/CommentController.php: -------------------------------------------------------------------------------- 1 | isGranted('IS_AUTHENTICATED_FULLY')) { 31 | return $this->json([ 32 | 'code' => 'NOT_AUTHENTICATED' 33 | ], Response::HTTP_UNAUTHORIZED); 34 | } 35 | 36 | $data = $request->request->all('comment'); 37 | 38 | if (!$this->isCsrfTokenValid('comment-add', $data['_token'])) { 39 | return $this->json([ 40 | 'code' => 'INVALID_CSRF_TOKEN' 41 | ], Response::HTTP_BAD_REQUEST); 42 | } 43 | 44 | $article = $this->articleRepo->findOneBy(['id' => $data['article']]); 45 | 46 | $comment = $this->commentService->add($data, $article); 47 | 48 | if (!$article) { 49 | return $this->json([ 50 | 'code' => 'ARTICLE_NOT_FOUND' 51 | ], Response::HTTP_BAD_REQUEST); 52 | } 53 | 54 | $html = $this->renderView('comment/index.html.twig', [ 55 | 'comment' => $comment 56 | ]); 57 | 58 | return $this->json([ 59 | 'code' => 'COMMENT_ADDED_SUCCESSFULLY', 60 | 'detail' => [ 61 | 'comment' => $this->commentService->normalize($comment), 62 | 'numberOfComments' => $this->commentRepo->count(['article' => $article]) 63 | ], 64 | 'message' => $html, 65 | ]); 66 | } 67 | 68 | #[Route('/ajax/comments/answer', name: 'comment_answer_add', methods: ['POST'])] 69 | public function addAnswer(Request $request): Response 70 | { 71 | if (!$this->isGranted('IS_AUTHENTICATED_FULLY')) { 72 | return $this->json([ 73 | 'code' => 'NOT_AUTHENTICATED' 74 | ], Response::HTTP_UNAUTHORIZED); 75 | } 76 | 77 | $data = $request->request->all('comment'); 78 | 79 | $comment = $this->commentRepo->findOneBy(['id' => $data['id']]); 80 | 81 | $answer = $this->commentService->add($data, $comment->getArticle(), $comment, true); 82 | 83 | $html = $this->renderView('comment/index.html.twig', [ 84 | 'comment' => $answer 85 | ]); 86 | 87 | return $this->json([ 88 | 'code' => 'ANSWER_ADDED_SUCCESSFULLY', 89 | 'detail' => [ 90 | 'answer' => $this->commentService->normalize($answer) 91 | ], 92 | 'message' => $html 93 | ]); 94 | } 95 | 96 | #[Route('/ajax/comments/{id}', name: 'comment_edit', methods: ['GET', 'PATCH'])] 97 | public function editComment(?Comment $comment, Request $request): Response 98 | { 99 | if (!$comment) { 100 | return $this->json([ 101 | 'code' => 'COMMENT_NOT_FOUND' 102 | ], Response::HTTP_NOT_FOUND); 103 | } 104 | 105 | if ($request->isMethod(Request::METHOD_GET)) { 106 | $cardText = $this->renderView('comment/_card_text.html.twig', [ 107 | 'action' => 'edit', 108 | 'content' => $comment->getContent() 109 | ]); 110 | 111 | $cardFooter = $this->renderView('comment/_card_footer.twig', [ 112 | 'action' => 'edit', 113 | 'id' => $comment->getId() 114 | ]); 115 | 116 | return $this->json([ 117 | 'cardText' => trim($cardText), 118 | 'cardFooter' => trim($cardFooter) 119 | ]); 120 | } 121 | 122 | $content = json_decode($request->getContent(), true)['content']; 123 | 124 | $this->commentService->edit($comment, $content); 125 | 126 | return $this->json([ 127 | 'code' => 'COMMENT_SUCCESSFULLY_EDITED', 128 | 'detail' => [ 129 | 'comment' => $this->commentService->normalize($comment) 130 | ], 131 | 'message' => null 132 | ]); 133 | } 134 | 135 | #[Route('/ajax/comments/{id}', name: 'comment_delete', methods: ['DELETE'])] 136 | public function deleteComment(?Comment $comment): Response 137 | { 138 | $preliminaryChecks = $this->commentService->deletePreliminaryChecks($comment); 139 | 140 | if ($preliminaryChecks instanceof JsonResponse) { 141 | return $preliminaryChecks; 142 | } 143 | 144 | $this->commentService->delete($comment); 145 | 146 | return $this->json([ 147 | 'code' => 'COMMENT_SUCCESSFULLY_DELETED', 148 | 'detail' => [ 149 | 'numberOfComments' => $this->commentRepo->count(['article' => $comment->getArticle()]) 150 | ], 151 | 'message' => null 152 | ]); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Controller/ErrorController.php: -------------------------------------------------------------------------------- 1 | getStatusCode()}.html.twig"; 17 | 18 | if (!$environment->getLoader()->exists($view)) { 19 | $view = "bundles/TwigBundle/Exception/error500.html.twig"; 20 | } 21 | 22 | return $this->render($view); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Controller/HomeController.php: -------------------------------------------------------------------------------- 1 | render('home/index.html.twig', [ 30 | 'articles' => $articleService->getPaginatedArticles(), 31 | 'categories' => $categoryRepo->findAllForWidget() 32 | ]); 33 | } 34 | 35 | #[Route('/welcome', name: 'welcome')] 36 | public function welcome(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher): Response 37 | { 38 | if ($this->optionService->getValue(WelcomeModel::SITE_INSTALLED_NAME)) { 39 | return $this->redirectToRoute('home'); 40 | } 41 | 42 | $welcomeForm = $this->createForm(WelcomeType::class, new WelcomeModel()); 43 | 44 | $welcomeForm->handleRequest($request); 45 | 46 | if ($welcomeForm->isSubmitted() && $welcomeForm->isValid()) { 47 | /** @var WelcomeModel $data */ 48 | $data = $welcomeForm->getData(); 49 | 50 | $siteTitle = new Option(WelcomeModel::SITE_TITLE_LABEL, WelcomeModel::SITE_TITLE_NAME, $data->getSiteTitle(), TextType::class); 51 | $siteInstalled = new Option(WelcomeModel::SITE_INSTALLED_LABEL, WelcomeModel::SITE_INSTALLED_NAME, true, null); 52 | 53 | $user = new User($data->getUsername()); 54 | $user->setRoles(['ROLE_ADMIN']); 55 | $user->setPassword($passwordHasher->hashPassword($user, $data->getPassword())); 56 | 57 | $em->persist($siteTitle); 58 | $em->persist($siteInstalled); 59 | 60 | $em->persist($user); 61 | 62 | $em->flush(); 63 | 64 | return $this->redirectToRoute('home'); 65 | } 66 | 67 | return $this->render('home/welcome.html.twig', [ 68 | 'form' => $welcomeForm->createView() 69 | ]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Controller/PageController.php: -------------------------------------------------------------------------------- 1 | redirectToRoute('home'); 17 | } 18 | 19 | return $this->render('page/index.html.twig', [ 20 | 'entity' => $page 21 | ]); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Controller/UserController.php: -------------------------------------------------------------------------------- 1 | createForm(RegistrationFormType::class, $user); 23 | $form->handleRequest($request); 24 | 25 | if ($form->isSubmitted() && $form->isValid()) { 26 | $user->setPassword($userPasswordHasher->hashPassword($user, $form->get('plainPassword')->getData())); 27 | 28 | $em->persist($user); 29 | $em->flush(); 30 | 31 | return $this->redirectToRoute('home'); 32 | } 33 | 34 | return $this->render('user/register.html.twig', [ 35 | 'registrationForm' => $form->createView(), 36 | ]); 37 | } 38 | 39 | #[Route('/user/login', name: 'login')] 40 | public function login(AuthenticationUtils $authenticationUtils): Response 41 | { 42 | if ($this->isGranted('IS_AUTHENTICATED_FULLY')) { 43 | return $this->redirectToRoute('home'); 44 | } 45 | 46 | return $this->render('user/login.html.twig', [ 47 | 'last_username' => $authenticationUtils->getLastUsername(), 48 | 'error' => $authenticationUtils->getLastAuthenticationError() 49 | ]); 50 | } 51 | 52 | #[Route('/user/logout', name: 'logout', methods: ['GET'])] 53 | public function logout() 54 | { 55 | throw new \Exception('Don\'t forget to activate logout in security.yaml'); 56 | } 57 | 58 | #[Route('/user/{username}', name: 'user')] 59 | public function index(User $user): Response 60 | { 61 | return $this->render('user/index.html.twig', [ 62 | 'user' => $user 63 | ]); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/DataFixtures/OptionFixtures.php: -------------------------------------------------------------------------------- 1 | persist($option); 23 | } 24 | 25 | $manager->flush(); 26 | } 27 | } -------------------------------------------------------------------------------- /src/Entity/Article.php: -------------------------------------------------------------------------------- 1 | ['article:patch']], 21 | denormalizationContext: ['groups' => ['article:patch']], 22 | security: "is_granted('ROLE_ADMIN') or object.getAuthor() == user" 23 | ) 24 | ] 25 | )] 26 | class Article implements TimestampedInterface 27 | { 28 | #[ORM\Id] 29 | #[ORM\GeneratedValue] 30 | #[ORM\Column(type: 'integer')] 31 | private ?int $id; 32 | 33 | #[ORM\Column(type: 'string', length: 255)] 34 | private ?string $title; 35 | 36 | #[ORM\Column(type: 'string', length: 255)] 37 | private ?string $slug; 38 | 39 | #[ORM\Column(type: 'json', nullable: true)] 40 | #[Groups(['article:patch'])] 41 | private ?array $content; 42 | 43 | #[ORM\Column(type: 'text', nullable: true)] 44 | private ?string $featuredText; 45 | 46 | #[ORM\ManyToOne(targetEntity: Media::class)] 47 | private ?Media $featuredImage; 48 | 49 | #[ORM\ManyToMany(targetEntity: Category::class, inversedBy: 'articles')] 50 | private Collection $categories; 51 | 52 | #[ORM\OneToMany(mappedBy: 'article', targetEntity: Comment::class)] 53 | #[ORM\OrderBy(['createdAt' => 'DESC'])] 54 | private Collection $comments; 55 | 56 | #[ORM\Column(type: 'datetime')] 57 | private ?\DateTimeInterface $createdAt; 58 | 59 | #[ORM\Column(type: 'datetime', nullable: true)] 60 | private ?\DateTimeInterface $updatedAt; 61 | 62 | #[ORM\ManyToOne(inversedBy: 'articles')] 63 | #[ORM\JoinColumn(nullable: false)] 64 | private ?User $author = null; 65 | 66 | public function __construct() 67 | { 68 | $this->categories = new ArrayCollection(); 69 | $this->comments = new ArrayCollection(); 70 | } 71 | 72 | public function getId(): ?int 73 | { 74 | return $this->id; 75 | } 76 | 77 | public function getTitle(): ?string 78 | { 79 | return $this->title; 80 | } 81 | 82 | public function setTitle(string $title): self 83 | { 84 | $this->title = $title; 85 | 86 | return $this; 87 | } 88 | 89 | public function getContent(): ?array 90 | { 91 | return $this->content; 92 | } 93 | 94 | public function setContent(?array $content): self 95 | { 96 | $this->content = $content; 97 | 98 | return $this; 99 | } 100 | 101 | /** 102 | * @return Collection|Category[] 103 | */ 104 | public function getCategories(): Collection 105 | { 106 | return $this->categories; 107 | } 108 | 109 | public function addCategory(Category $category): self 110 | { 111 | if (!$this->categories->contains($category)) { 112 | $this->categories[] = $category; 113 | } 114 | 115 | return $this; 116 | } 117 | 118 | public function removeCategory(Category $category): self 119 | { 120 | $this->categories->removeElement($category); 121 | 122 | return $this; 123 | } 124 | 125 | public function getCreatedAt(): ?\DateTimeInterface 126 | { 127 | return $this->createdAt; 128 | } 129 | 130 | public function setCreatedAt(\DateTimeInterface $createdAt): self 131 | { 132 | $this->createdAt = $createdAt; 133 | 134 | return $this; 135 | } 136 | 137 | public function getUpdatedAt(): ?\DateTimeInterface 138 | { 139 | return $this->updatedAt; 140 | } 141 | 142 | public function setUpdatedAt(?\DateTimeInterface $updatedAt): self 143 | { 144 | $this->updatedAt = $updatedAt; 145 | 146 | return $this; 147 | } 148 | 149 | public function __toString(): string 150 | { 151 | return $this->title; 152 | } 153 | 154 | public function getSlug(): ?string 155 | { 156 | return $this->slug; 157 | } 158 | 159 | public function setSlug(string $slug): self 160 | { 161 | $this->slug = $slug; 162 | 163 | return $this; 164 | } 165 | 166 | public function getFeaturedText(): ?string 167 | { 168 | return $this->featuredText; 169 | } 170 | 171 | public function setFeaturedText(?string $featuredText): self 172 | { 173 | $this->featuredText = $featuredText; 174 | 175 | return $this; 176 | } 177 | 178 | /** 179 | * @return Collection|Comment[] 180 | */ 181 | public function getComments(): Collection 182 | { 183 | return $this->comments; 184 | } 185 | 186 | public function addComment(Comment $comment): self 187 | { 188 | if (!$this->comments->contains($comment)) { 189 | $this->comments[] = $comment; 190 | $comment->setArticle($this); 191 | } 192 | 193 | return $this; 194 | } 195 | 196 | public function removeComment(Comment $comment): self 197 | { 198 | if ($this->comments->removeElement($comment)) { 199 | // set the owning side to null (unless already changed) 200 | if ($comment->getArticle() === $this) { 201 | $comment->setArticle(null); 202 | } 203 | } 204 | 205 | return $this; 206 | } 207 | 208 | public function getFeaturedImage(): ?Media 209 | { 210 | return $this->featuredImage; 211 | } 212 | 213 | public function setFeaturedImage(?Media $featuredImage): self 214 | { 215 | $this->featuredImage = $featuredImage; 216 | 217 | return $this; 218 | } 219 | 220 | public function getAuthor(): ?User 221 | { 222 | return $this->author; 223 | } 224 | 225 | public function setAuthor(?User $author): self 226 | { 227 | $this->author = $author; 228 | 229 | return $this; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/Entity/Category.php: -------------------------------------------------------------------------------- 1 | articles = new ArrayCollection(); 35 | } 36 | 37 | public function getId(): ?int 38 | { 39 | return $this->id; 40 | } 41 | 42 | public function getName(): ?string 43 | { 44 | return $this->name; 45 | } 46 | 47 | public function setName(string $name): self 48 | { 49 | $this->name = $name; 50 | 51 | return $this; 52 | } 53 | 54 | public function getSlug(): ?string 55 | { 56 | return $this->slug; 57 | } 58 | 59 | public function setSlug(string $slug): self 60 | { 61 | $this->slug = $slug; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * @return Collection|Article[] 68 | */ 69 | public function getArticles(): Collection 70 | { 71 | return $this->articles; 72 | } 73 | 74 | public function addArticle(Article $article): self 75 | { 76 | if (!$this->articles->contains($article)) { 77 | $this->articles[] = $article; 78 | $article->addCategory($this); 79 | } 80 | 81 | return $this; 82 | } 83 | 84 | public function removeArticle(Article $article): self 85 | { 86 | if ($this->articles->removeElement($article)) { 87 | $article->removeCategory($this); 88 | } 89 | 90 | return $this; 91 | } 92 | 93 | public function __toString(): string 94 | { 95 | return $this->name; 96 | } 97 | 98 | public function getColor(): ?string 99 | { 100 | return $this->color; 101 | } 102 | 103 | public function setColor(?string $color): self 104 | { 105 | $this->color = $color; 106 | 107 | return $this; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Entity/Comment.php: -------------------------------------------------------------------------------- 1 | article = $article; 48 | $this->user = $user; 49 | $this->replies = new ArrayCollection(); 50 | } 51 | 52 | public function getId(): ?int 53 | { 54 | return $this->id; 55 | } 56 | 57 | public function getCreatedAt(): ?\DateTimeInterface 58 | { 59 | return $this->createdAt; 60 | } 61 | 62 | public function setCreatedAt(\DateTimeInterface $createdAt): self 63 | { 64 | $this->createdAt = $createdAt; 65 | 66 | return $this; 67 | } 68 | 69 | public function getContent(): ?string 70 | { 71 | return $this->content; 72 | } 73 | 74 | public function setContent(string $content): self 75 | { 76 | $this->content = $content; 77 | 78 | return $this; 79 | } 80 | 81 | public function getUser(): User 82 | { 83 | return $this->user; 84 | } 85 | 86 | public function setUser(User $user): self 87 | { 88 | $this->user = $user; 89 | 90 | return $this; 91 | } 92 | 93 | public function getArticle(): ?Article 94 | { 95 | return $this->article; 96 | } 97 | 98 | public function setArticle(?Article $article): self 99 | { 100 | $this->article = $article; 101 | 102 | return $this; 103 | } 104 | 105 | public function getParent(): ?self 106 | { 107 | return $this->parent; 108 | } 109 | 110 | public function setParent(?self $parent): self 111 | { 112 | $this->parent = $parent; 113 | 114 | return $this; 115 | } 116 | 117 | public function addReply(Comment $comment): self 118 | { 119 | if (!$this->replies->contains($comment)) { 120 | $this->replies->add($comment); 121 | $comment->setParent($this); 122 | } 123 | 124 | return $this; 125 | } 126 | 127 | public function getReplies(): Collection 128 | { 129 | return $this->replies; 130 | } 131 | 132 | #[Groups('comment')] 133 | public function getUserId(): ?int 134 | { 135 | return $this->user?->getId(); 136 | } 137 | 138 | #[Groups('comment')] 139 | public function getUsername(): ?string 140 | { 141 | return $this->user?->getUsername(); 142 | } 143 | 144 | #[Groups('comment')] 145 | public function getParentId(): ?int 146 | { 147 | return $this->parent?->getId(); 148 | } 149 | 150 | public function __toString(): string 151 | { 152 | return "{$this->user->getUsername()} {$this->createdAt->format('d/m/y à H:i:s')}"; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Entity/Media.php: -------------------------------------------------------------------------------- 1 | id; 30 | } 31 | 32 | public function getName(): ?string 33 | { 34 | return $this->name; 35 | } 36 | 37 | public function setName(string $name): self 38 | { 39 | $this->name = $name; 40 | 41 | return $this; 42 | } 43 | 44 | public function getFilename(): ?string 45 | { 46 | return $this->filename; 47 | } 48 | 49 | public function setFilename(string $filename): self 50 | { 51 | $this->filename = $filename; 52 | 53 | return $this; 54 | } 55 | 56 | public function getAltText(): ?string 57 | { 58 | return $this->altText; 59 | } 60 | 61 | public function setAltText(?string $altText): self 62 | { 63 | $this->altText = $altText; 64 | 65 | return $this; 66 | } 67 | 68 | public function __toString(): string 69 | { 70 | return $this->name; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Entity/Menu.php: -------------------------------------------------------------------------------- 1 | subMenus = new ArrayCollection(); 50 | } 51 | 52 | public function getId(): ?int 53 | { 54 | return $this->id; 55 | } 56 | 57 | public function getName(): ?string 58 | { 59 | return $this->name; 60 | } 61 | 62 | public function setName(string $name): self 63 | { 64 | $this->name = $name; 65 | 66 | return $this; 67 | } 68 | 69 | public function getMenuOrder(): ?int 70 | { 71 | return $this->menuOrder; 72 | } 73 | 74 | public function setMenuOrder(int $menuOrder): self 75 | { 76 | $this->menuOrder = $menuOrder; 77 | 78 | return $this; 79 | } 80 | 81 | public function getLink(): ?string 82 | { 83 | return $this->link; 84 | } 85 | 86 | public function setLink(?string $link): self 87 | { 88 | $this->link = $link; 89 | 90 | return $this; 91 | } 92 | 93 | public function getPage(): ?Page 94 | { 95 | return $this->page; 96 | } 97 | 98 | public function setPage(?Page $page): self 99 | { 100 | $this->page = $page; 101 | 102 | return $this; 103 | } 104 | 105 | public function getArticle(): ?Article 106 | { 107 | return $this->article; 108 | } 109 | 110 | public function setArticle(?Article $article): self 111 | { 112 | $this->article = $article; 113 | 114 | return $this; 115 | } 116 | 117 | public function getCategory(): ?Category 118 | { 119 | return $this->category; 120 | } 121 | 122 | public function setCategory(?Category $category): self 123 | { 124 | $this->category = $category; 125 | 126 | return $this; 127 | } 128 | 129 | /** 130 | * @return Collection 131 | */ 132 | public function getSubMenus(): Collection 133 | { 134 | return $this->subMenus; 135 | } 136 | 137 | public function addSubMenu(self $subMenu): self 138 | { 139 | if (!$this->subMenus->contains($subMenu)) { 140 | $this->subMenus[] = $subMenu; 141 | } 142 | 143 | return $this; 144 | } 145 | 146 | public function removeSubMenu(self $subMenu): self 147 | { 148 | $this->subMenus->removeElement($subMenu); 149 | 150 | return $this; 151 | } 152 | 153 | public function __toString(): string 154 | { 155 | return $this->name; 156 | } 157 | 158 | public function getIsVisible(): ?bool 159 | { 160 | return $this->isVisible; 161 | } 162 | 163 | public function setIsVisible(bool $isVisible): self 164 | { 165 | $this->isVisible = $isVisible; 166 | 167 | return $this; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Entity/Option.php: -------------------------------------------------------------------------------- 1 | label = $label; 36 | $this->name = $name; 37 | $this->value = $value; 38 | $this->type = $type; 39 | } 40 | 41 | public function getId(): ?int 42 | { 43 | return $this->id; 44 | } 45 | 46 | public function getName(): ?string 47 | { 48 | return $this->name; 49 | } 50 | 51 | public function setName(string $name): self 52 | { 53 | $this->name = $name; 54 | 55 | return $this; 56 | } 57 | 58 | public function getValue(): ?string 59 | { 60 | return $this->value; 61 | } 62 | 63 | public function setValue(?string $value): self 64 | { 65 | $this->value = $value; 66 | 67 | return $this; 68 | } 69 | 70 | public function getLabel(): ?string 71 | { 72 | return $this->label; 73 | } 74 | 75 | public function setLabel(string $label): self 76 | { 77 | $this->label = $label; 78 | 79 | return $this; 80 | } 81 | 82 | public function getType(): ?string 83 | { 84 | return $this->type; 85 | } 86 | 87 | public function setType(string $type): self 88 | { 89 | $this->type = $type; 90 | 91 | return $this; 92 | } 93 | 94 | public function __toString(): string 95 | { 96 | return $this->value ?? ''; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Entity/Page.php: -------------------------------------------------------------------------------- 1 | id; 35 | } 36 | 37 | public function getTitle(): ?string 38 | { 39 | return $this->title; 40 | } 41 | 42 | public function setTitle(string $title): self 43 | { 44 | $this->title = $title; 45 | 46 | return $this; 47 | } 48 | 49 | public function getUpdatedAt(): ?\DateTimeInterface 50 | { 51 | return $this->updatedAt; 52 | } 53 | 54 | public function setUpdatedAt(\DateTimeInterface $updatedAt): self 55 | { 56 | $this->updatedAt = $updatedAt; 57 | 58 | return $this; 59 | } 60 | 61 | public function getCreatedAt(): ?\DateTimeInterface 62 | { 63 | return $this->createdAt; 64 | } 65 | 66 | public function setCreatedAt(\DateTimeInterface $createdAt): self 67 | { 68 | $this->createdAt = $createdAt; 69 | 70 | return $this; 71 | } 72 | 73 | public function getContent(): ?string 74 | { 75 | return $this->content; 76 | } 77 | 78 | public function setContent(?string $content): self 79 | { 80 | $this->content = $content; 81 | 82 | return $this; 83 | } 84 | 85 | public function getSlug(): ?string 86 | { 87 | return $this->slug; 88 | } 89 | 90 | public function setSlug(string $slug): self 91 | { 92 | $this->slug = $slug; 93 | 94 | return $this; 95 | } 96 | 97 | public function __toString(): string 98 | { 99 | return $this->title; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Entity/User.php: -------------------------------------------------------------------------------- 1 | comments = new ArrayCollection(); 42 | $this->username = $username; 43 | $this->articles = new ArrayCollection(); 44 | } 45 | 46 | public function getId(): ?int 47 | { 48 | return $this->id; 49 | } 50 | 51 | public function getUsername(): ?string 52 | { 53 | return $this->username; 54 | } 55 | 56 | public function setUsername(string $username): self 57 | { 58 | $this->username = $username; 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * A visual identifier that represents this user. 65 | * 66 | * @see UserInterface 67 | */ 68 | public function getUserIdentifier(): string 69 | { 70 | return (string) $this->username; 71 | } 72 | 73 | /** 74 | * @see UserInterface 75 | */ 76 | public function getRoles(): array 77 | { 78 | $roles = $this->roles; 79 | // guarantee every user at least has ROLE_USER 80 | $roles[] = 'ROLE_USER'; 81 | 82 | return array_unique($roles); 83 | } 84 | 85 | public function setRoles(array $roles): self 86 | { 87 | $this->roles = $roles; 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * @see PasswordAuthenticatedUserInterface 94 | */ 95 | public function getPassword(): string 96 | { 97 | return $this->password; 98 | } 99 | 100 | public function setPassword(string $password): self 101 | { 102 | $this->password = $password; 103 | 104 | return $this; 105 | } 106 | 107 | /** 108 | * @see UserInterface 109 | */ 110 | public function eraseCredentials() 111 | { 112 | // If you store any temporary, sensitive data on the user, clear it here 113 | // $this->plainPassword = null; 114 | } 115 | 116 | /** 117 | * @return Collection|Comment[] 118 | */ 119 | public function getComments(): Collection 120 | { 121 | return $this->comments; 122 | } 123 | 124 | public function addComment(Comment $comment): self 125 | { 126 | if (!$this->comments->contains($comment)) { 127 | $this->comments[] = $comment; 128 | $comment->setUser($this); 129 | } 130 | 131 | return $this; 132 | } 133 | 134 | public function removeComment(Comment $comment): self 135 | { 136 | if ($this->comments->removeElement($comment)) { 137 | // set the owning side to null (unless already changed) 138 | if ($comment->getUser() === $this) { 139 | $comment->setUser(null); 140 | } 141 | } 142 | 143 | return $this; 144 | } 145 | 146 | public function __toString(): string 147 | { 148 | return $this->username; 149 | } 150 | 151 | /** 152 | * @return Collection 153 | */ 154 | public function getArticles(): Collection 155 | { 156 | return $this->articles; 157 | } 158 | 159 | public function addArticle(Article $article): self 160 | { 161 | if (!$this->articles->contains($article)) { 162 | $this->articles->add($article); 163 | $article->setAuthor($this); 164 | } 165 | 166 | return $this; 167 | } 168 | 169 | public function removeArticle(Article $article): self 170 | { 171 | if ($this->articles->removeElement($article)) { 172 | // set the owning side to null (unless already changed) 173 | if ($article->getAuthor() === $this) { 174 | $article->setAuthor(null); 175 | } 176 | } 177 | 178 | return $this; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/EventListener/ExceptionListener.php: -------------------------------------------------------------------------------- 1 | getThrowable(); 22 | 23 | if ($exception instanceof ConnectionException || $exception instanceof TableNotFoundException) { 24 | $this->databaseService->createDatabase(); 25 | $response = new RedirectResponse($this->router->generate('welcome')); 26 | $event->setResponse($response); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/EventSubscriber/ControllerSubscriber.php: -------------------------------------------------------------------------------- 1 | 'onControllerEvent' 25 | ]; 26 | } 27 | 28 | public function onControllerEvent(RequestEvent $event): void 29 | { 30 | if ($event->getRequest()->isXmlHttpRequest()) { 31 | return; 32 | } 33 | 34 | $route = $event->getRequest()->attributes->getAlpha('_route'); 35 | 36 | if ('welcome' !== $route && !$this->optionService->getValue(WelcomeModel::SITE_INSTALLED_NAME)) { 37 | $event->setResponse(new RedirectResponse($this->router->generate('welcome'))); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/EventSubscriber/EasyAdminSubscriber.php: -------------------------------------------------------------------------------- 1 | ['setEntityCreatedAt'], 16 | BeforeEntityUpdatedEvent::class => ['setEntityUpdatedAt'], 17 | ]; 18 | } 19 | 20 | public function setEntityCreatedAt(BeforeEntityPersistedEvent $event) 21 | { 22 | $entity = $event->getEntityInstance(); 23 | 24 | if (!$entity instanceof TimestampedInterface) { 25 | return; 26 | } 27 | 28 | $entity->setCreatedAt(new \DateTime()); 29 | } 30 | 31 | public function setEntityUpdatedAt(BeforeEntityUpdatedEvent $event) 32 | { 33 | $entity = $event->getEntityInstance(); 34 | 35 | if (!$entity instanceof TimestampedInterface) { 36 | return; 37 | } 38 | 39 | $entity->setUpdatedAt(new \DateTime()); 40 | } 41 | } -------------------------------------------------------------------------------- /src/Form/Type/Admin/CommentType.php: -------------------------------------------------------------------------------- 1 | add('content', TextareaType::class, [ 17 | 'attr' => [ 18 | 'readonly' => true 19 | ], 20 | 'label' => 'Message' 21 | ]); 22 | } 23 | 24 | public function configureOptions(OptionsResolver $resolver) 25 | { 26 | $resolver->setDefaults([ 27 | 'data_class' => Comment::class, 28 | ]); 29 | } 30 | } -------------------------------------------------------------------------------- /src/Form/Type/Admin/MenuType.php: -------------------------------------------------------------------------------- 1 | add('name', TextType::class, [ 19 | 'label' => 'Nom' 20 | ]) 21 | ->add('category', EntityType::class, [ 22 | 'class' => Category::class, 23 | 'label' => 'Catégorie' 24 | ]); 25 | } 26 | 27 | public function configureOptions(OptionsResolver $resolver) 28 | { 29 | $resolver->setDefaults([ 30 | 'data_class' => Menu::class, 31 | ]); 32 | } 33 | } -------------------------------------------------------------------------------- /src/Form/Type/CommentType.php: -------------------------------------------------------------------------------- 1 | add('content', TextareaType::class, [ 21 | 'label' => 'Votre message' 22 | ]) 23 | ->add('article', HiddenType::class) 24 | ->add('send', SubmitType::class, [ 25 | 'label' => 'Envoyer' 26 | ]); 27 | 28 | $builder->get('article') 29 | ->addModelTransformer(new CallbackTransformer( 30 | fn (Article $article) => $article->getId(), 31 | fn (Article $article) => $article->getTitle())); 32 | } 33 | 34 | public function configureOptions(OptionsResolver $resolver) 35 | { 36 | $resolver->setDefaults([ 37 | 'data_class' => Comment::class, 38 | 'csrf_token_id' => 'comment-add' 39 | ]); 40 | } 41 | } -------------------------------------------------------------------------------- /src/Form/Type/RegistrationFormType.php: -------------------------------------------------------------------------------- 1 | add('username', TextType::class, [ 22 | 'label' => "Nom d'utilisateur" 23 | ]) 24 | ->add('plainPassword', RepeatedType::class, [ 25 | 'mapped' => false, 26 | 'type' => PasswordType::class, 27 | 'label' => 'Mot de passe', 28 | 'options' => [ 29 | 'attr' => [ 30 | 'type' => 'password' 31 | ] 32 | ], 33 | 'first_options' => ['label' => 'Mot de passe'], 34 | 'second_options' => ['label' => 'Confirmer le mot de passe'], 35 | 'constraints' => [ 36 | new NotBlank(), 37 | new Length([ 38 | 'min' => 6 39 | ]) 40 | ], 41 | ]) 42 | ->add('save', SubmitType::class, [ 43 | 'label' => "S'inscrire" 44 | ]) 45 | ; 46 | } 47 | 48 | public function configureOptions(OptionsResolver $resolver): void 49 | { 50 | $resolver->setDefaults([ 51 | 'data_class' => User::class, 52 | ]); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Form/Type/WelcomeType.php: -------------------------------------------------------------------------------- 1 | add('siteTitle', TextType::class, [ 19 | 'label' => 'Titre du site' 20 | ]) 21 | ->add('username', TextType::class, [ 22 | 'label' => "Nom d'utilisateur" 23 | ]) 24 | ->add('password', PasswordType::class, [ 25 | 'label' => "Mot de passe" 26 | ]) 27 | ->add('submit', SubmitType::class, [ 28 | 'label' => 'Installer Symfony' 29 | ]); 30 | } 31 | 32 | public function configureOptions(OptionsResolver $resolver) 33 | { 34 | $resolver->setDefaults([ 35 | 'data_class' => WelcomeModel::class, 36 | ]); 37 | } 38 | } -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | username; 25 | } 26 | 27 | /** 28 | * @param string|null $username 29 | */ 30 | public function setUsername(?string $username): void 31 | { 32 | $this->username = $username; 33 | } 34 | 35 | /** 36 | * @return string|null 37 | */ 38 | public function getPassword(): ?string 39 | { 40 | return $this->password; 41 | } 42 | 43 | /** 44 | * @param string|null $password 45 | */ 46 | public function setPassword(?string $password): void 47 | { 48 | $this->password = $password; 49 | } 50 | 51 | /** 52 | * @return string|null 53 | */ 54 | public function getSiteTitle(): ?string 55 | { 56 | return $this->siteTitle; 57 | } 58 | 59 | /** 60 | * @param string|null $siteTitle 61 | */ 62 | public function setSiteTitle(?string $siteTitle): void 63 | { 64 | $this->siteTitle = $siteTitle; 65 | } 66 | } -------------------------------------------------------------------------------- /src/Repository/ArticleRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('a') 27 | ->orderBy('a.createdAt', 'DESC'); 28 | 29 | if ($category) { 30 | $qb->leftJoin('a.categories', 'c') 31 | ->where($qb->expr()->eq('c.id', ':id')) 32 | ->setParameter('id', $category->getId()); 33 | } 34 | 35 | return $qb->getQuery(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Repository/CategoryRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('c') 25 | ->orderBy('c.name') 26 | ->getQuery() 27 | ->getResult(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Repository/CommentRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('c') 27 | ->orderBy('c.createdAt', 'DESC'); 28 | 29 | if ($article) { 30 | $qb 31 | ->leftJoin('c.article', 'article') 32 | ->leftJoin('c.answers', 'answers') 33 | ->where($qb->expr()->eq('article.id', ':articleId')) 34 | ->setParameter('articleId', $article->getId()); 35 | } 36 | 37 | return $qb->getQuery(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Repository/MediaRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('m') 29 | ->where('m.isVisible = true') 30 | ->orderBy('m.menuOrder') 31 | ->getQuery() 32 | ->getResult(); 33 | } 34 | 35 | public function getIndexQueryBuilder(string $field): QueryBuilder 36 | { 37 | return $this->createQueryBuilder('m') 38 | ->where("m.$field IS NOT NULL OR (m.page IS NULL AND m.article IS NULL AND m.link IS NULL AND m.category IS NULL)"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Repository/OptionRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('o', 'o.name') 28 | ->getQuery() 29 | ->getResult(); 30 | } 31 | 32 | public function getValue(string $name): mixed 33 | { 34 | try { 35 | return $this->createQueryBuilder('o') 36 | ->select('o.value') 37 | ->where('o.name = :name') 38 | ->setParameter('name', $name) 39 | ->getQuery() 40 | ->getSingleScalarResult(); 41 | } catch (NoResultException|NonUniqueResultException) { 42 | return null; 43 | } 44 | } 45 | 46 | public function getIndexQueryBuilder(): QueryBuilder 47 | { 48 | return $this->createQueryBuilder('o') 49 | ->where("o.type IS NOT NULL") 50 | ->orderBy('o.label'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Repository/PageRepository.php: -------------------------------------------------------------------------------- 1 | setPassword($newHashedPassword); 35 | $this->_em->persist($user); 36 | $this->_em->flush(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Service/ArticleService.php: -------------------------------------------------------------------------------- 1 | requestStack->getMainRequest(); 26 | $articlesQuery = $this->articleRepo->findForPagination($category); 27 | $page = $request->query->getInt('page', 1); 28 | $limit = $this->optionService->getValue(Option::BLOG_ARTICLES_LIMIT); 29 | 30 | return $this->paginator->paginate($articlesQuery, $page, $limit); 31 | } 32 | } -------------------------------------------------------------------------------- /src/Service/CommentService.php: -------------------------------------------------------------------------------- 1 | requestStack->getMainRequest(); 36 | $page = $request->query->getInt('page', 1); 37 | $limit = 3; 38 | 39 | $commentsQuery = $this->commentRepo->findForPagination($article); 40 | 41 | return $this->paginator->paginate($commentsQuery, $page, $limit); 42 | } 43 | 44 | 45 | public function add(array $data, Article $article, ?Comment $parent = null, bool $isAnswer = false): ?Comment 46 | { 47 | $comment = new Comment($article, $this->security->getUser()); 48 | $comment->setContent($data['content']); 49 | $comment->setCreatedAt(new \DateTime()); 50 | 51 | if ($isAnswer) { 52 | $comment->setParent($parent); 53 | } 54 | 55 | $this->em->persist($comment); 56 | $this->em->flush(); 57 | 58 | return $comment; 59 | } 60 | 61 | public function deletePreliminaryChecks(?Comment $comment): ?JsonResponse 62 | { 63 | if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) { 64 | return new JsonResponse([ 65 | 'code' => 'NOT_AUTHENTICATED' 66 | ], Response::HTTP_UNAUTHORIZED); 67 | } 68 | 69 | if (!$comment) { 70 | return new JsonResponse([ 71 | 'code' => 'COMMENT_NOT_FOUND' 72 | ], Response::HTTP_NOT_FOUND); 73 | } 74 | 75 | if ($this->security->getUser() !== $comment->getUser()) { 76 | return new JsonResponse([ 77 | 'code' => 'UNAUTHORIZED' 78 | ], Response::HTTP_UNAUTHORIZED); 79 | } 80 | 81 | return null; 82 | } 83 | 84 | public function edit(Comment $comment, string $content): void 85 | { 86 | $comment->setContent($content); 87 | 88 | $this->em->flush(); 89 | } 90 | 91 | public function delete(Comment $comment): void 92 | { 93 | $this->em->remove($comment); 94 | $this->em->flush(); 95 | } 96 | 97 | public function normalize(Comment $comment): array 98 | { 99 | return $this->normalizer->normalize($comment, context: [ 100 | 'groups' => 'comment' 101 | ]); 102 | } 103 | } -------------------------------------------------------------------------------- /src/Service/DatabaseService.php: -------------------------------------------------------------------------------- 1 | kernel); 19 | $application->setAutoExit(false); 20 | 21 | $input = new ArrayInput([ 22 | 'command' => 'doctrine:database:create' 23 | ]); 24 | 25 | $this->run($application, $input); 26 | 27 | $input = new ArrayInput([ 28 | 'command' => 'd:s:u', 29 | '--force' => true 30 | ]); 31 | 32 | $this->run($application, $input); 33 | 34 | $input = new ArrayInput([ 35 | 'command' => 'doctrine:fixtures:load', 36 | '--append' => true 37 | ]); 38 | 39 | $this->run($application, $input); 40 | 41 | return Command::SUCCESS; 42 | } 43 | 44 | private function run(Application $application, ArrayInput $input): bool 45 | { 46 | try { 47 | $result = $application->run($input); 48 | } catch (\Exception $e) { 49 | $result = false; 50 | } 51 | 52 | return $result; 53 | } 54 | } -------------------------------------------------------------------------------- /src/Service/MenuService.php: -------------------------------------------------------------------------------- 1 | menuRepo->findAllForTwig(); 20 | } 21 | } -------------------------------------------------------------------------------- /src/Service/OptionService.php: -------------------------------------------------------------------------------- 1 | optionRepo->findAllForTwig(); 19 | } 20 | 21 | public function getValue(string $name): mixed 22 | { 23 | return $this->optionRepo->getValue($name); 24 | } 25 | } -------------------------------------------------------------------------------- /src/Twig/AppExtension.php: -------------------------------------------------------------------------------- 1 | getLink() ?: '#'; 55 | 56 | if ($url !== '#') { 57 | return $url; 58 | } 59 | 60 | $page = $menu->getPage(); 61 | 62 | if ($page) { 63 | $name = 'page_show'; 64 | $slug = $page->getSlug(); 65 | } 66 | 67 | $article = $menu->getArticle(); 68 | 69 | if ($article) { 70 | $name = 'article_show'; 71 | $slug = $article->getSlug(); 72 | } 73 | 74 | $category = $menu->getCategory(); 75 | 76 | if ($category) { 77 | $name = 'category_show'; 78 | $slug = $category->getSlug(); 79 | } 80 | 81 | return $this->router->generate($name, [ 82 | 'slug' => $slug 83 | ]); 84 | } 85 | 86 | public function categoriesToString(Collection $categories): string 87 | { 88 | $generateCategoryLink = function(Category $category) { 89 | $url = $this->router->generate('category_show', [ 90 | 'slug' => $category->getSlug() 91 | ]); 92 | return "{$category->getName()}"; 93 | }; 94 | 95 | $categoryLinks = array_map($generateCategoryLink, $categories->toArray()); 96 | 97 | return implode(', ', $categoryLinks); 98 | } 99 | 100 | public function getEditCurrentEntityLabel(object $entity): string 101 | { 102 | return match($entity::class) { 103 | Article::class => "Modifier l'article", 104 | Category::class => 'Modifier la catégorie', 105 | Page::class => 'Modifier la page' 106 | }; 107 | } 108 | 109 | public function getAdminUrl(string $controller, string $action = Action::INDEX): string 110 | { 111 | return $this->adminUrlGenerator 112 | ->setController(self::ADMIN_NAMESPACE . '\\' . $controller) 113 | ->setAction($action) 114 | ->generateUrl(); 115 | } 116 | 117 | public function getAdminEditUrl(object $entity): ?string 118 | { 119 | $crudController = match ($entity::class) { 120 | Article::class => ArticleCrudController::class, 121 | Category::class => CategoryCrudController::class, 122 | Page::class => PageCrudController::class 123 | }; 124 | 125 | return $this->adminUrlGenerator 126 | ->setController($crudController) 127 | ->setAction(Action::EDIT) 128 | ->setEntityId($entity->getId()) 129 | ->generateUrl(); 130 | } 131 | 132 | public function isCommentAuthor(Comment $comment): bool 133 | { 134 | return $this->security->getUser() === $comment->getUser(); 135 | } 136 | } -------------------------------------------------------------------------------- /templates/article/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% set article = entity %} 4 | 5 | {% block title %}{{ article.title }}{% endblock %} 6 | 7 | {% block body %} 8 |
9 | 10 |
11 |
12 |

{{ article.title }}

13 |
14 |
15 | 16 |
17 | 18 |
19 |
20 |
21 |
22 |
23 | 24 | 25 |
26 | 27 |

{{ article.comments|length }} commentaire(s)

28 | 29 |
30 | 31 | {% if is_granted('IS_AUTHENTICATED_FULLY') %} 32 |
33 | {{ form(commentForm, { attr: { class: 'comment-form' } }) }} 34 |
35 |
36 | {% endif %} 37 | 38 |
39 |
40 |
41 | Loading... 42 |
43 |
44 |
45 |
46 | 47 |
52 | 53 |
54 | 55 | {% endblock %} 56 | 57 | {% block javascripts %} 58 | {{ parent() }} 59 | {{ encore_entry_script_tags('article') }} 60 | {{ encore_entry_script_tags('comment') }} 61 | {% endblock %} -------------------------------------------------------------------------------- /templates/article/item.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {% if article.featuredImage %} 5 | 6 | {{ article.featuredImage.altText }} 7 | 8 | {% endif %} 9 |
10 |
11 |

12 | {{ article.title }} 13 |

14 |

15 | {{ article.createdAt|date('d M Y') }} / {{ article.categories|categoriesToString|raw }} 16 | {% if is_granted('IS_AUTHENTICATED_FULLY') %} 17 | / Laisser un commentaire 18 | {% endif %} 19 |

20 | {{ article.featuredText ?: '' }} 21 |
22 |
23 |
-------------------------------------------------------------------------------- /templates/article/list.html.twig: -------------------------------------------------------------------------------- 1 | {% for article in articles %} 2 | {% include 'article/item.html.twig' with { article, leftCol: leftCol, rightCol: rightCol } %} 3 | {% endfor %} 4 | 5 |
6 |
7 |
8 | {{ knp_pagination_render(articles) }} 9 |
10 |
11 |
-------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | {% set menus = menu_service.findAll %} 2 | {% set options = option_service.findAll %} 3 | 4 | {% set ea_edit_current_entity = null %} 5 | 6 | {% if entity is defined %} 7 | {% set label_edit_current_entity = entity_label(entity) %} 8 | {% set ea_edit_current_entity = ea_edit(entity) %} 9 | {% endif %} 10 | 11 | 12 | 13 | 14 | 15 | {{ options['blog_title'] }} - {% block title %}{% endblock %} 16 | {% block stylesheets %} 17 | {{ encore_entry_link_tags('app') }} 18 | {% endblock %} 19 | 20 | {% block javascripts %} 21 | {{ encore_entry_script_tags('app') }} 22 | {% endblock %} 23 | 24 | 25 | 26 | {% block javascript_page_color_scheme %} 27 | 44 | {% endblock javascript_page_color_scheme %} 45 | 46 | {% if is_granted('ROLE_AUTHOR') %} 47 | 84 | {% endif %} 85 | 86 |
87 |
88 | 89 | {{ options['blog_title'] }} 90 | 91 | 169 |
170 |
171 | 172 | {% block body %}{% endblock %} 173 | 174 |
175 |
176 |
177 | © {{ 'now'|date('Y') }} {{ options['blog_copyright'] }} 178 |
179 |
180 |
181 | 182 | -------------------------------------------------------------------------------- /templates/bundles/TwigBundle/Exception/error403.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'bundles/TwigBundle/Exception/index.html.twig' %} 2 | 3 | {% block title %}Accès refusé{% endblock %} 4 | {% block message %}Accès refusé{% endblock %} 5 | {% block description %}Vous n'êtes pas autorisé à voir cette resource{% endblock %} -------------------------------------------------------------------------------- /templates/bundles/TwigBundle/Exception/error404.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'bundles/TwigBundle/Exception/index.html.twig' %} 2 | 3 | {% block title %}Page introuvable{% endblock %} 4 | {% block message %}Page introuvable{% endblock %} 5 | {% block description %}Cette page est inaccessible. Désolé.{% endblock %} 6 | -------------------------------------------------------------------------------- /templates/bundles/TwigBundle/Exception/error500.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'bundles/TwigBundle/Exception/index.html.twig' %} 2 | 3 | {% block title %}Erreur serveur{% endblock %} 4 | {% block message %}Erreur serveur{% endblock %} 5 | {% block description %}Le serveur a rencontré une erreur interne.{% endblock %} -------------------------------------------------------------------------------- /templates/bundles/TwigBundle/Exception/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 |
5 |

{% block message %}{% endblock %}

6 |

{% block description %}{% endblock %}

7 |
8 | {% endblock %} -------------------------------------------------------------------------------- /templates/category/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% set category = entity %} 4 | 5 | {% block title %}{{ category.name }}{% endblock %} 6 | 7 | {% block body %} 8 |
9 |
10 |
11 |

{{ category.name }}

12 |
13 | {% include 'article/list.html.twig' with { articles: articles, leftCol: 4, rightCol: 8 } %} 14 |
15 |
16 |
17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /templates/comment/_card_footer.twig: -------------------------------------------------------------------------------- 1 | {% if action == 'show' %} 2 | 3 | 4 | {% if isCommentAuthor %} 5 | 6 | 7 | {% endif %} 8 | 9 | {% elseif action == 'edit' %} 10 | 11 | 12 | {% endif %} -------------------------------------------------------------------------------- /templates/comment/_card_text.html.twig: -------------------------------------------------------------------------------- 1 | {% if action == 'show' %} 2 |

{{ content }}

3 | {% elseif action == 'edit' %} 4 | 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /templates/comment/answer.html.twig: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |
6 | 7 | {{ answer.user.username }} 8 | 9 |
10 | {{ answer.createdAt|ago }} 11 |

{{ answer.content }}

12 |
13 | {% if is_granted('IS_AUTHENTICATED_FULLY') %} 14 | 17 | {% endif %} 18 |
19 | 20 |
21 | -------------------------------------------------------------------------------- /templates/comment/index.html.twig: -------------------------------------------------------------------------------- 1 | {% set username = app.user.username %} 2 | 3 |
4 |
5 |
6 |
7 | 8 | {{ username }} 9 | 10 |
11 | {{ comment.createdAt|ago }} 12 | {% include 'comment/_card_text.html.twig' with { 'action': 'show', 'content': comment.content } %} 13 |
14 | {% if is_granted('IS_AUTHENTICATED_FULLY') %} 15 | 18 | {% endif %} 19 |
20 | 21 |
22 | {% if item.answers is defined %} 23 | {% for answer in item.answers %} 24 | {% include 'comment/answer.html.twig' with { 'answer': answer } %} 25 | {% endfor %} 26 | {% endif %} 27 |
28 | 29 |
-------------------------------------------------------------------------------- /templates/comment/list.html.twig: -------------------------------------------------------------------------------- 1 | {% for item in data %} 2 | {% if item.comment is defined %} 3 | {% set comment = item.comment %} 4 | {% set username = item.comment.user.username %} 5 | 6 |
7 |
8 |
9 |
10 | 11 | {{ username }} 12 | {{ comment.createdAt|date('d M Y à H:i:s') }} 13 |
14 | {% include 'comment/_card_text.html.twig' with { 'action': 'show', 'content': comment.content } %} 15 |
16 | {% if is_granted('IS_AUTHENTICATED_FULLY') %} 17 | 20 | {% endif %} 21 |
22 | {% endif %} 23 | 24 |
25 | {% if item.answers is defined %} 26 | {% for answer in item.answers %} 27 | {% include 'comment/answer.html.twig' with { 'answer': answer } %} 28 | {% endfor %} 29 | {% endif %} 30 |
31 |
32 | {% endfor %} 33 | -------------------------------------------------------------------------------- /templates/home/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Accueil{% endblock %} 4 | 5 | {% block body %} 6 |
7 |
8 |
9 |

Articles récents

10 |
11 | {% include 'article/list.html.twig' with { articles: articles, leftCol: 5, rightCol: 7 } %} 12 |
13 |
14 |
15 |
16 | {% include 'widget/about.html.twig' %} 17 | {% include 'widget/categories.html.twig' %} 18 |
19 |
20 |
21 |
22 | {% endblock %} -------------------------------------------------------------------------------- /templates/home/welcome.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Bienvenue 6 | {% block stylesheets %} 7 | {{ encore_entry_link_tags('app') }} 8 | {% endblock %} 9 | 10 | {% block javascripts %} 11 | {{ encore_entry_script_tags('app') }} 12 | {% endblock %} 13 | 14 | 15 |
16 |
17 |
18 |
19 |
20 |
Bienvenue
21 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed interdum mi ligula, sit amet accumsan felis tincidunt at. Etiam diam neque, mollis vel ex nec, lacinia dapibus mi. Maecenas nec nisi arcu. Nulla facilisi. Proin pulvinar dolor id metus dignissim porttitor. Proin feugiat quis massa pellentesque facilisis. Nam eros lectus, scelerisque id vestibulum ultricies, convallis in sem. Morbi in congue orci. Proin tristique eros sit amet mauris fringilla convallis.

22 |
23 |
Informations nécessaires
24 |
25 | 26 | {{ form(form) }} 27 | 28 |
29 |
30 |
31 |
32 |
33 | 34 | -------------------------------------------------------------------------------- /templates/page/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% set page = entity %} 4 | 5 | {% block title %}{{ page.title }}{% endblock %} 6 | 7 | {% block body %} 8 |
9 | {{ page.content|raw }} 10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /templates/user/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}{{ user.username|capitalize }}{% endblock %} 4 | 5 | {% block body %} 6 |
7 |
8 |
9 |

Profil de {{ user.username|capitalize }}

10 |
11 |
12 | 13 | 14 |
15 |
16 | @ 17 | 18 |
19 |
20 | 21 |
22 |

Nombre de commentaires posté : {{ user.comments|length }}

23 |
24 | 25 |
26 |
27 |
28 |
29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /templates/user/login.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Se connecter{% endblock %} 4 | 5 | {% block body %} 6 |
7 |

Se connecter

8 | 9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 |
23 | {% if error %} 24 | 27 | {% endif %} 28 |
29 | {% endblock %} -------------------------------------------------------------------------------- /templates/user/register.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Inscription{% endblock %} 4 | 5 | {% block body %} 6 |
7 |

S'inscrire

8 | {{ form(registrationForm) }} 9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /templates/widget/about.html.twig: -------------------------------------------------------------------------------- 1 |
2 |

A propos

3 |

{{ options['blog_about'].value }}

4 |
-------------------------------------------------------------------------------- /templates/widget/categories.html.twig: -------------------------------------------------------------------------------- 1 |
2 |

Catégories

3 |
    4 | {% for category in categories %} 5 |
  1. {{ category.name }}
  2. 6 | {% endfor %} 7 |
8 |
-------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const Encore = require('@symfony/webpack-encore'); 2 | 3 | // Manually configure the runtime environment if not already configured yet by the "encore" command. 4 | // It's useful when you use tools that rely on webpack.config.js file. 5 | if (!Encore.isRuntimeEnvironmentConfigured()) { 6 | Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev'); 7 | } 8 | 9 | Encore 10 | // directory where compiled assets will be stored 11 | .setOutputPath('public/build/') 12 | // public path used by the web server to access the output path 13 | .setPublicPath('/build') 14 | // only needed for CDN's or sub-directory deploy 15 | //.setManifestKeyPrefix('build/') 16 | 17 | /* 18 | * ENTRY CONFIG 19 | * 20 | * Each entry will result in one JavaScript file (e.g. main.js) 21 | * and one CSS file (e.g. style.scss) if your JavaScript imports CSS. 22 | */ 23 | .addEntry('app', './assets/js/app.js') 24 | .addEntry('article', './assets/js/article.js') 25 | .addEntry('comment', './assets/js/comment.js') 26 | 27 | // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. 28 | .splitEntryChunks() 29 | 30 | // will require an extra script tag for runtime.js 31 | // but, you probably want this, unless you're building a single-page app 32 | .enableSingleRuntimeChunk() 33 | 34 | /* 35 | * FEATURE CONFIG 36 | * 37 | * Enable & configure other features below. For a full 38 | * list of features, see: 39 | * https://symfony.com/doc/current/frontend.html#adding-more-features 40 | */ 41 | .cleanupOutputBeforeBuild() 42 | .enableBuildNotifications() 43 | .enableSourceMaps(!Encore.isProduction()) 44 | // enables hashed filenames (e.g. app.abc123.css) 45 | .enableVersioning(Encore.isProduction()) 46 | 47 | .configureBabel((config) => { 48 | config.plugins.push('@babel/plugin-proposal-class-properties'); 49 | }) 50 | 51 | // enables @babel/preset-env polyfills 52 | .configureBabelPresetEnv((config) => { 53 | config.useBuiltIns = 'usage'; 54 | config.corejs = 3; 55 | }) 56 | 57 | // enables Sass/SCSS support 58 | .enableSassLoader() 59 | 60 | // uncomment if you use TypeScript 61 | //.enableTypeScriptLoader() 62 | 63 | // uncomment if you use React 64 | //.enableReactPreset() 65 | 66 | // uncomment to get integrity="..." attributes on your script & link tags 67 | // requires WebpackEncoreBundle 1.4 or higher 68 | //.enableIntegrityHashes(Encore.isProduction()) 69 | 70 | // uncomment if you're having problems with a jQuery plugin 71 | //.autoProvidejQuery() 72 | ; 73 | 74 | module.exports = Encore.getWebpackConfig(); 75 | --------------------------------------------------------------------------------