├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin └── build.js ├── composer.json ├── config └── nested-comments.php ├── database ├── factories │ └── ModelFactory.php └── migrations │ └── create_nested_comments_table.php.stub ├── package-lock.json ├── postcss.config.cjs ├── resources ├── css │ └── index.css ├── dist │ ├── .gitkeep │ ├── nested-comments.css │ └── nested-comments.js ├── js │ └── index.js ├── lang │ └── en │ │ └── nested-comments.php └── views │ ├── .gitkeep │ ├── components │ ├── comment-card.blade.php │ ├── comments.blade.php │ └── reactions.blade.php │ ├── filament │ ├── infolists │ │ ├── comment-card-entry.blade.php │ │ ├── comments-entry.blade.php │ │ └── reactions-entry.blade.php │ └── widgets │ │ └── comments-widget.blade.php │ └── livewire │ ├── add-comment.blade.php │ ├── comment-card.blade.php │ ├── comments.blade.php │ └── reaction-panel.blade.php ├── src ├── Commands │ └── NestedCommentsCommand.php ├── Concerns │ ├── HasComments.php │ └── HasReactions.php ├── Facades │ └── NestedComments.php ├── Filament │ ├── Actions │ │ └── CommentsAction.php │ ├── Infolists │ │ ├── CommentCardEntry.php │ │ ├── CommentsEntry.php │ │ └── ReactionsEntry.php │ ├── Tables │ │ └── Actions │ │ │ └── CommentsAction.php │ └── Widgets │ │ └── CommentsWidget.php ├── Http │ └── Middleware │ │ └── GuestCommentatorMiddleware.php ├── Livewire │ ├── AddComment.php │ ├── CommentCard.php │ ├── Comments.php │ └── ReactionPanel.php ├── Models │ ├── Comment.php │ └── Reaction.php ├── NestedComments.php ├── NestedCommentsPlugin.php ├── NestedCommentsServiceProvider.php └── Testing │ └── TestsNestedComments.php └── stubs └── .gitkeep /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `nested-comments` will be documented in this file. 4 | 5 | ## 1.0.0 - 202X-XX-XX 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) coolsam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Filament Nested Comments & Emoji Reactions 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/coolsam/nested-comments.svg?style=flat-square)](https://packagist.org/packages/coolsam/nested-comments) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/coolsam726/nested-comments/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/coolsam726/nested-comments/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/coolsam726/nested-comments/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/coolsam726/nested-comments/actions?query=workflow%3A"Fix+PHP+Code+Styling"+branch%3Amain) 6 | [![GitHub PHPStan Action Status](https://img.shields.io/github/actions/workflow/status/coolsam726/nested-comments/phpstan.yml?branch=main&label=phpstan&style=flat-square)](https://github.com/coolsam726/nested-comments/actions?query=workflow%3APHPStan+branch%3Amain) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/coolsam/nested-comments.svg?style=flat-square)](https://packagist.org/packages/coolsam/nested-comments) 8 | 9 | 10 | This package allows you to incorporate comments and replies in your Filament forms, infolists, pages, widgets etc, or even simply in your livewire components. Comment replies can be nested as deep as you want, using the Nested Set data structure. Additionally, the package comes with a Reactions feature to enable your users to react to any of your models (e.g comments or posts) with selected emoji reactions. 11 | 12 | ![image](https://github.com/user-attachments/assets/2900e2a4-9ad2-40e2-8819-2650b6d70803) 13 | 14 | ## Installation 15 | 16 | You can install the package via composer: 17 | 18 | ```bash 19 | composer require coolsam/nested-comments 20 | ``` 21 | 22 | Run the installation command and follow the prompts: 23 | 24 | ```bash 25 | php artisan nested-comments:install 26 | ``` 27 | During the installation, you will be asked if you would like to publish and replace the config file. 28 | This is important especially if you are upgrading the package to a newer version in which the config file structure has changed. 29 | No worries, if you have customizations in your config file that you would like to keep, your current config file will be backed up to `config/nested-comments.php.bak` before the new config file is published. 30 | 31 | You will also be asked if you would like to re-publish the package's assets. This is also important in case the package's styles and scripts have changed in the new version. 32 | 33 | Adjust the configuration file as necessary, then run migrations. 34 | 35 | `That's it! You are now ready to add nested comments 36 | 37 | ## Usage: Comments 38 | At the very basic level, this package is simply a Livewire Component that takes in a model record which is commentable. Follow the following steps to prepare your model to be commentable or reactable: 39 | 1. Add the `HasComments` trait to your model 40 | ```php 41 | 42 | use Coolsam\NestedComments\Traits\HasComments; 43 | 44 | class Conference extends Model 45 | { 46 | use HasComments; 47 | 48 | // ... 49 | } 50 | 51 | ``` 52 | 2. If you would like to be able to react to your model directly as well, add the `HasReactions` trait to your model 53 | ```php 54 | use Coolsam\NestedComments\Traits\HasReactions; 55 | 56 | class Conference extends Model 57 | { 58 | use HasReactions; 59 | 60 | // ... 61 | } 62 | ``` 63 | 3. You can now access the comments and reactions of your model in the following ways 64 | 65 | ### Using the Comments Infolist Entry 66 | 67 | ```php 68 | public static function infolist(Infolist $infolist): Infolist 69 | { 70 | return $infolist 71 | ->schema([ 72 | Section::make('Basic Details') 73 | ->schema([ 74 | TextEntry::make('name'), 75 | TextEntry::make('start_date') 76 | ->dateTime(), 77 | TextEntry::make('end_date') 78 | ->dateTime(), 79 | TextEntry::make('created_at') 80 | ->dateTime(), 81 | ]), 82 | 83 | // Add the comments entry 84 | \Coolsam\NestedComments\Filament\Infolists\CommentsEntry::make('comments'), 85 | ]); 86 | } 87 | ``` 88 | ![image](https://github.com/user-attachments/assets/da84b49e-66c7-4453-b5d4-b7b18f204bba) 89 | 90 | 91 | 92 | ### Using the Comments Widget inside a Resource Page (e.g EditRecord) 93 | 94 | As long as the resource page interacts with the record, the CommentsWidget will resolve the record automatically. 95 | 96 | ```php 97 | class EditConference extends EditRecord 98 | { 99 | protected static string $resource = ConferenceResource::class; 100 | 101 | protected function getHeaderActions(): array 102 | { 103 | return [ 104 | Actions\ViewAction::make(), 105 | Actions\DeleteAction::make(), 106 | ]; 107 | } 108 | 109 | protected function getFooterWidgets(): array 110 | { 111 | return [ 112 | \Coolsam\NestedComments\Filament\Widgets\CommentsWidget::class, 113 | ]; 114 | } 115 | } 116 | ``` 117 | ![image](https://github.com/user-attachments/assets/bd56d52d-b791-4f24-a202-b0948574d811) 118 | 119 | ### Using the Comments Widget in a custom Filament Page (You have to pass $record manually) 120 | 121 | ```php 122 | // NOTE: It's up to you how to get your record, as long as you pass it to the widget 123 | public function getRecord(): ?Conference 124 | { 125 | return Conference::latest()->first(); 126 | } 127 | 128 | protected function getFooterWidgets(): array 129 | { 130 | return [ 131 | CommentsWidget::make(['record' => $this->getRecord()]) 132 | ]; 133 | } 134 | ``` 135 | 136 | ### Using the Comments Page Action in a Resource Page (which interacts with $record) 137 | 138 | ```php 139 | namespace App\Filament\Resources\ConferenceResource\Pages; 140 | 141 | use App\Filament\Resources\ConferenceResource; 142 | use Coolsam\NestedComments\Filament\Actions\CommentsAction; 143 | use Filament\Actions; 144 | use Filament\Resources\Pages\ViewRecord; 145 | use Illuminate\Database\Eloquent\Model; 146 | 147 | class ViewConference extends ViewRecord 148 | { 149 | protected static string $resource = ConferenceResource::class; 150 | 151 | protected function getHeaderActions(): array 152 | { 153 | return [ 154 | CommentsAction::make() 155 | ->badgeColor('danger') 156 | ->badge(fn(Model $record) => $record->getAttribute('comments_count')), 157 | Actions\EditAction::make(), 158 | ]; 159 | } 160 | } 161 | ``` 162 | ![image](https://github.com/user-attachments/assets/678d3f1e-b3f9-4a77-b263-af5538c72e2b) 163 | 164 | ![image](https://github.com/user-attachments/assets/372c6390-ea4e-4d19-8943-784506126cc1) 165 | 166 | ### Using the Comments Page Action in a custom Filament Page (You have to pass $record manually) 167 | In this case you will have to pass the record attribute manually. 168 | 169 | ```php 170 | protected function getHeaderActions(): array 171 | { 172 | return [ 173 | CommentsAction::make() 174 | ->record($this->getRecord()) // Define the logic for getting your record e.g in $this->getRecord() 175 | ->badgeColor('danger') 176 | ->badge(fn(Model $record) => $record->getAttribute('comments_count')), 177 | Actions\EditAction::make(), 178 | ]; 179 | } 180 | ``` 181 | 182 | ### Using the Comments Table Action 183 | 184 | ```php 185 | public static function table(Table $table): Table 186 | { 187 | return $table 188 | ->columns([ 189 | // ... Columns 190 | ]) 191 | ->actions([ 192 | \Coolsam\NestedComments\Filament\Tables\Actions\CommentsAction::make() 193 | ->button() 194 | ->badgeColor('danger') 195 | ->badge(fn(Conference $record) => $record->getAttribute('comments_count')), 196 | // ... Other actions 197 | ]); 198 | } 199 | ``` 200 | ![image](https://github.com/user-attachments/assets/27eead51-c237-4865-b185-3245629cabe4) 201 | 202 | ### Using the Comments Blade Component ANYWHERE! 203 | This unlocks incredible possibilities. It allows you to render your comments even in your own frontend blade page. All you have to do is simply pass the commentable `$record` to the blade component 204 | ```php 205 | $record = Conference::find(1); // Get your record from the database then, 206 | 207 | 208 | ``` 209 | 210 | Alternatively, you could use the Livewire component if you prefer. 211 | ```php 212 | $record = Conference::find(1); // Get your record from the database then, 213 | 214 | 215 | ``` 216 | 217 | ### Mentions 218 | The package uses Filament TipTap Editor which supports mentions. You can mention users in your comments by typing `@` followed by the user's name. 219 | In the future, the package will support sending notifications to the mentioned users via database notifications if supported. 220 | For more on how to customize the mentions, see the [Package Customization](#customize-how-to-get-the-mention-items) section below. 221 | 222 | ![image](https://github.com/user-attachments/assets/bd7a395a-fc32-4057-b6bc-24763132f555) 223 | 224 | 225 | ## Usage: Emoji Reactions 226 | This package also allows you to add emoji reactions to your models. You can use the `HasReactions` trait to add reactions to any model. The reactions are stored in a separate table, and you can customize the reactions that are available via the configuration file. 227 | The Comments model that powers the comments feature described above already uses emoji reactions. 228 | 229 | In order to start using reactions for your model, add the `HasReactions` trait to your model. You can then use the `reactions` method to get the reactions for the model. 230 | 231 | ```php 232 | use Coolsam\NestedComments\Traits\HasReactions; 233 | 234 | class Conference extends Model 235 | { 236 | use HasReactions; 237 | 238 | // ... 239 | } 240 | ``` 241 | The above trait adds the `react()` method to your model, allowing you to toggle a reaction for the model. You can also use the `reactions` method to get the reactions for the model. 242 | 243 | ```php 244 | $conference = Conference::find(1); 245 | $comference->react('👍'); // React to the conference with a thumbs up emoji 246 | ``` 247 | You can also use the `reactions` method to get the reactions for the model. 248 | 249 | ```php 250 | $conference = Conference::find(1); 251 | $reactions = $conference->reactions; // Get the reactions for the conference 252 | ``` 253 | Other useful methods include 254 | ```php 255 | /** 256 | * @var \Illuminate\Database\Eloquent\Model&\Coolsam\NestedComments\Concerns\HasReactions $conference 257 | */ 258 | $conference = Conference::find(1); 259 | $conference->total_reactions; // Get the total number of reactions for the conference 260 | $conference->reactions_counts; // Get the no of reactions for each emoji for the model 261 | $conference->my_reactions; // Get the reactions for the current user 262 | $conference->emoji_reactors // Get the list of users who reacted to the model, grouped by emoji 263 | $conference->isAllowed('👍') // check if the app allows the user to react with the specified emoji 264 | $conference->reactions_map // return the map of all the reactions for the model, grouped by emoji. This tells you the number of reactions for each emoji, and whether the current user has reacted with that emoji 265 | ``` 266 | To interact with the methods above with ease within and even outside Filament, this package comes with the following handy components: 267 | 268 | ### Reactions Infolist Entry 269 | ```php 270 | use Coolsam\NestedComments\Filament\Infolists\ReactionsEntry; 271 | 272 | public static function infolist(Infolist $infolist): Infolist 273 | { 274 | return $infolist 275 | ->schema([ 276 | Section::make('Basic Details') 277 | ->schema([ 278 | TextEntry::make('name'), 279 | TextEntry::make('start_date') 280 | ->dateTime(), 281 | TextEntry::make('end_date') 282 | ->dateTime(), 283 | TextEntry::make('created_at') 284 | ->dateTime(), 285 | // Add the reactions entry 286 | ReactionsEntry::make('reactions')->columnSpanFull(), 287 | ])->columns(4), 288 | ]); 289 | } 290 | ``` 291 | ![image](https://github.com/user-attachments/assets/06ae7e76-1668-4e92-9a4f-a125f7d94b03) 292 | 293 | ### Reactions Blade Component 294 | Just include the blade component anywhere in your blade file and pass the model record to it. 295 | ```php 296 | $record = Conference::find(1); // Get your record from the database then, 297 | ``` 298 | In your view: 299 | ```bladehtml 300 | 301 | ``` 302 | 303 | ### Reactions Livewire Component 304 | Similar to the blade component, you can use the Livewire component anywhere in your Livewire component and pass the model record to it. 305 | ```php 306 | $record = Conference::find(1); // Get your record from the database then, 307 | ``` 308 | In your view: 309 | ```bladehtml 310 | 311 | ``` 312 | The two components can be used anywhere, in resource pages, custom pages, actions, form fields, widgets, livewire components or just plain blade views. Here is a sample screenshot of how the components will be rendered: 313 | ![image](https://github.com/user-attachments/assets/0162f294-0477-454c-ae5c-67424edc207f) 314 | 315 | 316 | ## Package Customization 317 | You can customize the package by changing most of the default values in the config file after publishing it. 318 | Additionally, you can customize how the package interacts with your models by overriding some methods in your commentable model. 319 | 320 | ### Customize how to get the Comment Author's Name 321 | You can customize how to get the comment author's name by overriding the `getUserName` method in your commentable model. 322 | By default, the package uses the `name` attribute of the user model, but you can change this to any other attribute or method that returns a string. 323 | 324 | This name will be displayed in the comment card, and it will also be used to mention the user in the comment text. 325 | 326 | ```php 327 | // e.g in your Post model or any other model that uses the HasComments trait 328 | use Coolsam\NestedComments\Traits\HasComments; 329 | 330 | public function getUserName(Model|Authenticatable|null $user): string 331 | { 332 | return $user?->getAttribute('username') ?? $user?->getAttribute('guest_name') ?? 'Guest'; 333 | } 334 | ``` 335 | 336 | ### Customize the User's Avatar 337 | You can customize the user's avatar by overriding the `getUserAvatar` method in your commentable model. 338 | 339 | By default, the package uses [ui-avatars](https://ui-avatars.com) to generate the avatar based on the user's name, but you can change this to any other method that returns a URL to the user's avatar image. 340 | 341 | ```php 342 | // e.g in your Post model or any other model that uses the HasComments trait 343 | use Coolsam\NestedComments\Traits\HasComments; 344 | 345 | public function getUserAvatar(Model|Authenticatable|string|null $user): ?string 346 | { 347 | // return 'https://yourprofile.url.png'; 348 | return $user->getAttribute('profile_url') // get your user's profile url here, assuming you have defined it in your user's model. 349 | } 350 | ``` 351 | 352 | ### Customize how to get the Mention Items 353 | You can customize how to get the mention items by overriding and changing the `getMentionsQuery` method in your commentable model. 354 | By default, the package gets mention items from all users in your database. 355 | For example, if you would only like to mention users who have commented on the current thread, you can do so by changing the method to return only those users. 356 | There is a handy method included in the default class to achieve this. Alternatively, you can go wild and mention fruits instead of users! The choice is within your freedom. 357 | 358 | ```php 359 | // e.g in your Post model or any other model that uses the HasComments trait 360 | use Coolsam\NestedComments\Traits\HasComments; 361 | 362 | public function getMentionsQuery(string $query): Builder 363 | { 364 | return app(NestedComments::class)->getCurrentThreadUsersQuery($query, $this); 365 | } 366 | ``` 367 | 368 | ### Customize the Supported Emoji Reactions 369 | You can customize the supported emoji reactions by changing the `reactions` array in the config file. 370 | Decent defaults are provided, but you can change them to any emojis you prefer. 371 | 372 | ```php 373 | return [ 374 | '👍', 375 | '❤️', 376 | '😂', 377 | '😮', 378 | '😢', 379 | '😡', 380 | ]; 381 | ``` 382 | ## Testing 383 | 384 | ```bash 385 | composer test 386 | ``` 387 | 388 | ## Open Source Dependencies 389 | 390 | This package uses the following awesome open source packages, among many others under the hood: 391 | 392 | * [Filament](https://filamentphp.com/) 393 | * [Livewire](https://livewire.laravel.com/) 394 | * [Laravel](https://laravel.com/) 395 | * [AlpineJS](https://alpinejs.dev/) 396 | * [Laravel NestedSet](https://github.com/lazychaser/laravel-nestedset) 397 | * [Filament Tiptap Editor](https://github.com/awcodes/filament-tiptap-editor) 398 | 399 | I am grateful for the work that has been put into these packages. They have made it possible to build this package in a short time. 400 | 401 | ## Changelog 402 | 403 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 404 | 405 | ## Contributing 406 | 407 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 408 | 409 | ## Security Vulnerabilities 410 | 411 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 412 | 413 | ## Credits 414 | 415 | - [Sam Maosa](https://github.com/coolsam726) 416 | - [All Contributors](../../contributors) 417 | 418 | ## License 419 | 420 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 421 | -------------------------------------------------------------------------------- /bin/build.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | 3 | const isDev = process.argv.includes('--dev') 4 | 5 | async function compile(options) { 6 | const context = await esbuild.context(options) 7 | 8 | if (isDev) { 9 | await context.watch() 10 | } else { 11 | await context.rebuild() 12 | await context.dispose() 13 | } 14 | } 15 | 16 | const defaultOptions = { 17 | define: { 18 | 'process.env.NODE_ENV': isDev ? `'development'` : `'production'`, 19 | }, 20 | bundle: true, 21 | mainFields: ['module', 'main'], 22 | platform: 'neutral', 23 | sourcemap: isDev ? 'inline' : false, 24 | sourcesContent: isDev, 25 | treeShaking: true, 26 | target: ['es2020'], 27 | minify: !isDev, 28 | plugins: [{ 29 | name: 'watchPlugin', 30 | setup: function (build) { 31 | build.onStart(() => { 32 | console.log(`Build started at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) 33 | }) 34 | 35 | build.onEnd((result) => { 36 | if (result.errors.length > 0) { 37 | console.log(`Build failed at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`, result.errors) 38 | } else { 39 | console.log(`Build finished at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) 40 | } 41 | }) 42 | } 43 | }], 44 | } 45 | 46 | compile({ 47 | ...defaultOptions, 48 | entryPoints: ['./resources/js/index.js'], 49 | outfile: './resources/dist/nested-comments.js', 50 | }) 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coolsam/nested-comments", 3 | "description": "Add Nested comments/replies to filament forms, infolists and resources", 4 | "keywords": [ 5 | "coolsam", 6 | "laravel", 7 | "nested-comments" 8 | ], 9 | "homepage": "https://github.com/coolsam/nested-comments", 10 | "support": { 11 | "issues": "https://github.com/coolsam/nested-comments/issues", 12 | "source": "https://github.com/coolsam/nested-comments" 13 | }, 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Sam Maosa", 18 | "email": "maosa.sam@gmail.com", 19 | "role": "Developer" 20 | } 21 | ], 22 | "require": { 23 | "php": "^8.2", 24 | "awcodes/filament-tiptap-editor": "^3.5.12", 25 | "filament/filament": "^3.3", 26 | "kalnoy/nestedset": "^6.0.5", 27 | "spatie/laravel-package-tools": "^1.15.0", 28 | "tangodev-it/filament-emoji-picker": "^1.0.3" 29 | }, 30 | "require-dev": { 31 | "barryvdh/laravel-ide-helper": "^3.5", 32 | "laravel/pint": "^1.0", 33 | "nunomaduro/larastan": "^3.1.0", 34 | "orchestra/testbench": "^9.12", 35 | "pestphp/pest-plugin-laravel": "^3.1", 36 | "pestphp/pest-plugin-livewire": "^3.0", 37 | "phpstan/extension-installer": "^1.4.3", 38 | "spatie/laravel-ray": "^1.39" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Coolsam\\NestedComments\\": "src/", 43 | "Coolsam\\NestedComments\\Database\\Factories\\": "database/factories/" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Coolsam\\NestedComments\\Tests\\": "tests/" 49 | } 50 | }, 51 | "scripts": { 52 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 53 | "analyse": "vendor/bin/phpstan analyse", 54 | "test": "vendor/bin/pest", 55 | "test-coverage": "vendor/bin/pest --coverage", 56 | "format": "vendor/bin/pint" 57 | }, 58 | "config": { 59 | "sort-packages": true, 60 | "allow-plugins": { 61 | "pestphp/pest-plugin": true, 62 | "phpstan/extension-installer": true 63 | } 64 | }, 65 | "extra": { 66 | "laravel": { 67 | "providers": [ 68 | "Coolsam\\NestedComments\\NestedCommentsServiceProvider" 69 | ], 70 | "aliases": { 71 | "NestedComments": "Coolsam\\NestedComments\\Facades\\NestedComments" 72 | } 73 | } 74 | }, 75 | "minimum-stability": "dev", 76 | "prefer-stable": true 77 | } 78 | -------------------------------------------------------------------------------- /config/nested-comments.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'comments' => 'comments', 8 | 'reactions' => 'reactions', 9 | 'users' => 'users', // The table that will be used to get the authenticated user 10 | ], 11 | 12 | 'models' => [ 13 | 'comment' => \Coolsam\NestedComments\Models\Comment::class, 14 | 'reaction' => \Coolsam\NestedComments\Models\Reaction::class, 15 | 'user' => env('AUTH_MODEL', 'App\Models\User'), // The model that will be used to get the authenticated user 16 | ], 17 | 18 | 'policies' => [ 19 | 'comment' => null, 20 | 'reaction' => null, 21 | ], 22 | 'allowed-reactions' => [ 23 | '👍' => 'thumbs up', // thumbs up 24 | '👎' => 'thumbs down', // thumbs down 25 | '❤️' => 'heart', // heart 26 | '😂' => 'laughing', // laughing 27 | '😮' => 'surprised', // surprised 28 | '😢' => 'crying', // crying 29 | '💯' => 'hundred points', // angry 30 | '🔥' => 'fire', // fire 31 | '🎉' => 'party popper', // party popper 32 | '🚀' => 'rocket', // rocket 33 | ], 34 | 'allow-all-reactions' => env('ALLOW_ALL_REACTIONS', false), // Allow any other emoji apart from the ones listed above 35 | 'allow-multiple-reactions' => env('ALLOW_MULTIPLE_REACTIONS', false), // Allow multiple reactions from the same user 36 | 'allow-guest-reactions' => env('ALLOW_GUEST_REACTIONS', false), // Allow guest users to react 37 | 'allow-guest-comments' => env('ALLOW_GUEST_COMMENTS', false), // Allow guest users to comment 38 | 'mentions' => [ 39 | 'items-placeholder' => 'Search users by name or email address', 40 | 'empty-items-message' => 'No users found', 41 | ], 42 | ]; 43 | -------------------------------------------------------------------------------- /database/factories/ModelFactory.php: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->nestedSet(); 16 | $table->foreignId('user_id')->nullable()->constrained($users)->cascadeOnDelete(); 17 | $table->text('body'); 18 | $table->morphs('commentable'); 19 | $table->ulid('guest_id')->nullable()->index(); 20 | $table->string('guest_name')->nullable(); 21 | $table->ipAddress()->nullable(); 22 | $table->boolean('is_published')->default(false); 23 | $table->timestamps(); 24 | }); 25 | 26 | Schema::create(Config::get('nested-comments.tables.reactions'), function (Blueprint $table) use ($users) { 27 | $table->id(); 28 | $table->foreignId('user_id')->nullable()->constrained($users)->cascadeOnDelete(); 29 | $table->morphs('reactable'); 30 | $table->string('emoji'); 31 | $table->ulid('guest_id')->nullable()->index(); 32 | $table->string('guest_name')->nullable(); 33 | $table->ipAddress()->nullable(); 34 | $table->boolean('is_published')->default(false); 35 | $table->timestamps(); 36 | }); 37 | } 38 | 39 | public function down() 40 | { 41 | Schema::dropIfExists(Config::get('nested-comments.tables.reactions')); 42 | Schema::dropIfExists(Config::get('nested-comments.tables.comments')); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "tailwindcss/nesting": {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /resources/css/index.css: -------------------------------------------------------------------------------- 1 | @import '../../vendor/filament/filament/resources/css/theme.css'; 2 | @import '../../vendor/awcodes/filament-tiptap-editor/resources/css/plugin.css'; 3 | 4 | .comment-mention, [data-mention-id] { 5 | @apply bg-primary-50 !text-primary-500 dark:bg-gray-700 dark:text-primary-50 dark:border-primary-500 p-1 rounded-full px-2 before:content-["@"]; 6 | } -------------------------------------------------------------------------------- /resources/dist/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolsam726/nested-comments/53b075a07303dda3e1e93b8bc70b0ca77516964a/resources/dist/.gitkeep -------------------------------------------------------------------------------- /resources/dist/nested-comments.css: -------------------------------------------------------------------------------- 1 | *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border-width:0;border-style:solid;border-color:rgba(var(--gray-200),1)}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family),ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:rgba(var(--gray-400),1)}input::placeholder,textarea::placeholder{opacity:1;color:rgba(var(--gray-400),1)}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:rgba(var(--gray-500),var(--tw-border-opacity,1));border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:rgba(var(--gray-500),var(--tw-text-opacity,1));opacity:1}input::placeholder,textarea::placeholder{color:rgba(var(--gray-500),var(--tw-text-opacity,1));opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:rgba(var(--gray-500),var(--tw-border-opacity,1));border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active){[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active){[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active){[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root.dark{color-scheme:dark}[data-field-wrapper]{scroll-margin-top:8rem}.-bottom-4{bottom:-1rem}.-left-8{left:-2rem}.right-0{right:0}.top-full{top:100%}.z-0{z-index:0}.size-4{width:1rem;height:1rem}.h-12{height:3rem}.h-2{height:.5rem}.h-64{height:16rem}.h-\[100dvh\]{height:100dvh}.h-auto{height:auto}.h-dvh{height:100dvh}.max-h-48{max-height:12rem}.max-h-\[40rem\]{max-height:40rem}.min-h-\[56px\]{min-height:56px}.w-12{width:3rem}.w-2{width:.5rem}.min-w-\[144px\]{min-width:144px}.rotate-180,.rotate-45{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-45{--tw-rotate:45deg}.scale-100,.scale-95{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.place-content-center{place-content:center}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.divide-gray-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgba(var(--gray-300),var(--tw-divide-opacity,1))}.divide-gray-950\/10>:not([hidden])~:not([hidden]){border-color:rgba(var(--gray-950),.1)}.overflow-y-scroll{overflow-y:scroll}.rounded-b-md{border-bottom-right-radius:.375rem;border-bottom-left-radius:.375rem}.rounded-t-md{border-top-left-radius:.375rem;border-top-right-radius:.375rem}.rounded-bl-xl{border-bottom-left-radius:.75rem}.border-l{border-left-width:1px}.border-dashed{border-style:dashed}.border-gray-950\/10{border-color:rgba(var(--gray-950),.1)}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.\!bg-gray-500\/30{background-color:rgba(var(--gray-500),.3)!important}.bg-gray-950\/20{background-color:rgba(var(--gray-950),.2)}.bg-inherit{background-color:inherit}.\!p-0{padding:0!important}.p-8{padding:2rem}.pb-2{padding-bottom:.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.text-\[0\.625rem\]{font-size:.625rem}.text-gray-800{--tw-text-opacity:1;color:rgba(var(--gray-800),var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgba(var(--gray-900),var(--tw-text-opacity,1))}.\!shadow-none{--tw-shadow:0 0 #0000!important;--tw-shadow-colored:0 0 #0000!important;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)!important}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!ring-0{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)!important;--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)!important;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)!important}.ring-gray-100{--tw-ring-opacity:1;--tw-ring-color:rgba(var(--gray-100),var(--tw-ring-opacity,1))}.ring-transparent{--tw-ring-color:transparent}.focus-within\:z-10:focus-within{z-index:10}.focus-within\:ring:focus-within{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-within\:ring-primary-500:focus-within{--tw-ring-opacity:1;--tw-ring-color:rgba(var(--primary-500),var(--tw-ring-opacity,1))}.hover\:bg-gray-500\/20:hover{background-color:rgba(var(--gray-500),.2)}.hover\:bg-gray-500\/40:hover{background-color:rgba(var(--gray-500),.4)}.hover\:bg-primary-500:hover{--tw-bg-opacity:1;background-color:rgba(var(--primary-500),var(--tw-bg-opacity,1))}.hover\:ring-primary-500:hover{--tw-ring-opacity:1;--tw-ring-color:rgba(var(--primary-500),var(--tw-ring-opacity,1))}.focus\:not-sr-only:focus{position:static;width:auto;height:auto;padding:0;margin:0;overflow:visible;clip:auto;white-space:normal}.focus\:absolute:focus{position:absolute}.focus\:bg-gray-500\/20:focus{background-color:rgba(var(--gray-500),.2)}.focus\:bg-gray-500\/40:focus{background-color:rgba(var(--gray-500),.4)}.focus\:bg-primary-500:focus{--tw-bg-opacity:1;background-color:rgba(var(--primary-500),var(--tw-bg-opacity,1))}.focus\:bg-white:focus{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.focus\:px-3:focus{padding-left:.75rem;padding-right:.75rem}.focus\:py-1:focus{padding-top:.25rem;padding-bottom:.25rem}.focus\:text-gray-900:focus{--tw-text-opacity:1;color:rgba(var(--gray-900),var(--tw-text-opacity,1))}.focus\:ring-primary-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgba(var(--primary-500),var(--tw-ring-opacity,1))}.dark\:divide-gray-700:is(.dark *)>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgba(var(--gray-700),var(--tw-divide-opacity,1))}.dark\:divide-white\/20:is(.dark *)>:not([hidden])~:not([hidden]){border-color:hsla(0,0%,100%,.2)}.dark\:border-white\/20:is(.dark *){border-color:hsla(0,0%,100%,.2)}.dark\:bg-gray-950\/20:is(.dark *){background-color:rgba(var(--gray-950),.2)}.dark\:text-gray-300:is(.dark *){--tw-text-opacity:1;color:rgba(var(--gray-300),var(--tw-text-opacity,1))}.dark\:ring-danger-600:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgba(var(--danger-600),var(--tw-ring-opacity,1))}@media (min-width:768px){.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}}:checked+*>.\[\:checked\+\*\>\&\]\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}input:checked+.\[input\:checked\+\&\]\:bg-custom-600{--tw-bg-opacity:1;background-color:rgba(var(--c-600),var(--tw-bg-opacity,1))}input:checked+.\[input\:checked\+\&\]\:bg-gray-400{--tw-bg-opacity:1;background-color:rgba(var(--gray-400),var(--tw-bg-opacity,1))}input:checked+.\[input\:checked\+\&\]\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}input:checked+.\[input\:checked\+\&\]\:ring-0{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}input:checked+.\[input\:checked\+\&\]\:hover\:bg-custom-500:hover{--tw-bg-opacity:1;background-color:rgba(var(--c-500),var(--tw-bg-opacity,1))}input:checked+.\[input\:checked\+\&\]\:hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgba(var(--gray-300),var(--tw-bg-opacity,1))}input:checked+.dark\:\[input\:checked\+\&\]\:bg-custom-500:is(.dark *){--tw-bg-opacity:1;background-color:rgba(var(--c-500),var(--tw-bg-opacity,1))}input:checked+.dark\:\[input\:checked\+\&\]\:bg-gray-600:is(.dark *){--tw-bg-opacity:1;background-color:rgba(var(--gray-600),var(--tw-bg-opacity,1))}input:checked+.dark\:\[input\:checked\+\&\]\:hover\:bg-custom-400:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgba(var(--c-400),var(--tw-bg-opacity,1))}input:checked+.dark\:\[input\:checked\+\&\]\:hover\:bg-gray-500:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgba(var(--gray-500),var(--tw-bg-opacity,1))}input:checked:focus-visible+.\[input\:checked\:focus-visible\+\&\]\:ring-custom-500\/50{--tw-ring-color:rgba(var(--c-500),0.5)}input:checked:focus-visible+.dark\:\[input\:checked\:focus-visible\+\&\]\:ring-custom-400\/50:is(.dark *){--tw-ring-color:rgba(var(--c-400),0.5)}input:focus-visible+.\[input\:focus-visible\+\&\]\:z-10{z-index:10}input:focus-visible+.\[input\:focus-visible\+\&\]\:ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}input:focus-visible+.\[input\:focus-visible\+\&\]\:ring-gray-950\/10{--tw-ring-color:rgba(var(--gray-950),0.1)}input:focus-visible+.dark\:\[input\:focus-visible\+\&\]\:ring-white\/20:is(.dark *){--tw-ring-color:hsla(0,0%,100%,.2)}.tiptap-editor .ProseMirror .hljs{background:rgba(var(--gray-800),1);color:#d6deeb;padding:.5rem 1rem;border-radius:.5rem;font-size:.875rem;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace,monospace}.tiptap-editor .ProseMirror .hljs-keyword{color:#c792ea}.tiptap-editor .ProseMirror .hljs-built_in{color:#addb67}.tiptap-editor .ProseMirror .hljs-type{color:#82aaff}.tiptap-editor .ProseMirror .hljs-literal{color:#ff5874}.tiptap-editor .ProseMirror .hljs-number{color:#f78c6c}.tiptap-editor .ProseMirror .hljs-regexp{color:#5ca7e4}.tiptap-editor .ProseMirror .hljs-string{color:#ecc48d}.tiptap-editor .ProseMirror .hljs-subst{color:#d3423e}.tiptap-editor .ProseMirror .hljs-symbol{color:#82aaff}.tiptap-editor .ProseMirror .hljs-class{color:#ffcb8b}.tiptap-editor .ProseMirror .hljs-function{color:#82aaff}.tiptap-editor .ProseMirror .hljs-title{color:#dcdcaa}.tiptap-editor .ProseMirror .hljs-params{color:#7fdbca}.tiptap-editor .ProseMirror .hljs-comment{color:#637777}.tiptap-editor .ProseMirror .hljs-doctag{color:#7fdbca}.tiptap-editor .ProseMirror .hljs-meta,.tiptap-editor .ProseMirror .hljs-meta .hljs-keyword{color:#82aaff}.tiptap-editor .ProseMirror .hljs-meta .hljs-string{color:#ecc48d}.tiptap-editor .ProseMirror .hljs-section{color:#82b1ff}.tiptap-editor .ProseMirror .hljs-attr,.tiptap-editor .ProseMirror .hljs-name,.tiptap-editor .ProseMirror .hljs-tag{color:#7fdbca}.tiptap-editor .ProseMirror .hljs-attribute{color:#80cbc4}.tiptap-editor .ProseMirror .hljs-variable{color:#addb67}.tiptap-editor .ProseMirror .hljs-bullet{color:#d9f5dd}.tiptap-editor .ProseMirror .hljs-code{color:#80cbc4}.tiptap-editor .ProseMirror .hljs-emphasis{color:#c792ea;font-style:italic}.tiptap-editor .ProseMirror .hljs-strong{color:#addb67;font-weight:700}.tiptap-editor .ProseMirror .hljs-formula{color:#c792ea}.tiptap-editor .ProseMirror .hljs-link{color:#ff869a}.tiptap-editor .ProseMirror .hljs-quote{color:#697098}.tiptap-editor .ProseMirror .hljs-selector-tag{color:#ff6363}.tiptap-editor .ProseMirror .hljs-selector-id{color:#fad430}.tiptap-editor .ProseMirror .hljs-selector-class{color:#addb67}.tiptap-editor .ProseMirror .hljs-selector-attr,.tiptap-editor .ProseMirror .hljs-selector-pseudo,.tiptap-editor .ProseMirror .hljs-template-tag{color:#c792ea}.tiptap-editor .ProseMirror .hljs-template-variable{color:#addb67}.tiptap-editor .ProseMirror .hljs-addition{color:#addb67;font-style:italic}.tiptap-editor .ProseMirror .hljs-deletion{color:#ef535090;font-style:italic}[wire\:key*=filament_tiptap_source] .fi-fo-component-ctn,[wire\:key*=filament_tiptap_source] .fi-fo-component-ctn>div,[wire\:key*=filament_tiptap_source] .fi-fo-component-ctn>div .fi-fo-field-wrp{height:100%}[wire\:key*=filament_tiptap_source] .fi-fo-component-ctn>div .fi-fo-field-wrp>div{height:100%;grid-template-rows:auto 1fr}[wire\:key*=filament_tiptap_source] .fi-fo-component-ctn>div .fi-fo-field-wrp>div .source_code_editor *{height:100%!important}.sorting .tiptap-wrapper{pointer-events:none}.tiptap-wrapper.tiptap-fullscreen{position:fixed;top:0;left:0;bottom:0;right:0;z-index:40;display:flex;flex-direction:column;height:100%}.tiptap-wrapper.tiptap-fullscreen .tiptap-toolbar{border-radius:0}.tiptap-wrapper.tiptap-fullscreen .tiptap-prosemirror-wrapper{max-height:100%;padding-block-end:3rem}.tiptap-editor .tiptap-content{display:flex;flex-direction:column}.tiptap-prosemirror-wrapper{word-break:break-word;overflow-wrap:break-word}.tiptap-prosemirror-wrapper.prosemirror-w-sm{padding:0 max(1rem,calc(50% - 12rem))}.tiptap-prosemirror-wrapper.prosemirror-w-md{padding:0 max(1rem,calc(50% - 14rem))}.tiptap-prosemirror-wrapper.prosemirror-w-lg{padding:0 max(1rem,calc(50% - 16rem))}.tiptap-prosemirror-wrapper.prosemirror-w-xl{padding:0 max(1rem,calc(50% - 18rem))}.tiptap-prosemirror-wrapper.prosemirror-w-2xl{padding:0 max(1rem,calc(50% - 21rem))}.tiptap-prosemirror-wrapper.prosemirror-w-3xl{padding:0 max(1rem,calc(50% - 24rem))}.tiptap-prosemirror-wrapper.prosemirror-w-4xl{padding:0 max(1rem,calc(50% - 28rem))}.tiptap-prosemirror-wrapper.prosemirror-w-5xl{padding:0 max(1rem,calc(50% - 32rem))}.tiptap-prosemirror-wrapper.prosemirror-w-6xl{padding:0 max(1rem,calc(50% - 36rem))}.tiptap-prosemirror-wrapper.prosemirror-w-7xl{padding:0 max(1rem,calc(50% - 40rem))}.tiptap-prosemirror-wrapper.prosemirror-w-none{padding:0 1rem}.tiptap-editor .ProseMirror{border-bottom-left-radius:.375rem;border-bottom-right-radius:.375rem;flex:1 1 0;padding-block:1rem;margin-inline:auto;position:relative;width:100%;color:#000}.tiptap-editor .ProseMirror.ProseMirror-focused .ProseMirror-selectednode{outline-style:dashed;outline-width:2px;outline-offset:2px;outline-color:rgba(var(--gray-700),1)}.tiptap-editor .ProseMirror.ProseMirror-focused .ProseMirror-selectednode:is(.dark *){outline-color:rgba(var(--gray-300),1)}.tiptap-editor .ProseMirror .tiptap-block-wrapper{overflow:hidden;border-radius:.375rem;--tw-bg-opacity:1;background-color:rgba(var(--gray-100),var(--tw-bg-opacity,1))}.tiptap-editor .ProseMirror .tiptap-block-wrapper:is(.dark *){--tw-bg-opacity:1;background-color:rgba(var(--gray-800),var(--tw-bg-opacity,1))}.tiptap-editor .ProseMirror .tiptap-block-wrapper .tiptap-block .tiptap-block-heading{display:flex;align-items:center;justify-content:space-between;--tw-bg-opacity:1;background-color:rgba(var(--gray-200),var(--tw-bg-opacity,1));padding:.25rem .75rem;line-height:1;--tw-text-opacity:1;color:rgba(var(--gray-900),var(--tw-text-opacity,1))}.tiptap-editor .ProseMirror .tiptap-block-wrapper .tiptap-block .tiptap-block-heading:is(.dark *){--tw-bg-opacity:1;background-color:rgba(var(--gray-950),var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.tiptap-editor .ProseMirror .tiptap-block-wrapper .tiptap-block .tiptap-block-heading .tiptap-block-title{font-size:.875rem;line-height:1.25rem;font-weight:700;text-transform:uppercase;opacity:.8}.tiptap-editor .ProseMirror .tiptap-block-wrapper .tiptap-block .tiptap-block-actions{display:flex;align-items:center;gap:.5rem}.tiptap-editor .ProseMirror .tiptap-block-wrapper .tiptap-block .tiptap-block-actions button{opacity:.75}.tiptap-editor .ProseMirror .tiptap-block-wrapper .tiptap-block .tiptap-block-actions button:focus,.tiptap-editor .ProseMirror .tiptap-block-wrapper .tiptap-block .tiptap-block-actions button:hover{--tw-text-opacity:1;color:rgba(var(--primary-500),var(--tw-text-opacity,1));opacity:1}.tiptap-editor .ProseMirror .tiptap-block-wrapper .tiptap-block .preview{padding:1rem}.tiptap-editor .ProseMirror .filament-tiptap-hurdle{width:100%;max-width:100vw;padding-block:1rem;background-color:rgba(var(--gray-800),1);position:relative}.tiptap-editor .ProseMirror .filament-tiptap-hurdle:after,.tiptap-editor .ProseMirror .filament-tiptap-hurdle:before{content:"";position:absolute;display:block;width:100%;top:0;bottom:0;background-color:inherit}.tiptap-editor .ProseMirror .filament-tiptap-hurdle:before{left:-100%}.tiptap-editor .ProseMirror .filament-tiptap-hurdle:after{right:-100%}.tiptap-editor .ProseMirror .filament-tiptap-hurdle[data-color=gray_light]{color:rgba(var(--gray-900),1);background-color:rgba(var(--gray-300),1)}.tiptap-editor .ProseMirror .filament-tiptap-hurdle[data-color=gray]{color:#fff;background-color:rgba(var(--gray-500),1)}.tiptap-editor .ProseMirror .filament-tiptap-hurdle[data-color=gray_dark]{color:#fff;background-color:rgba(var(--gray-800),1)}.tiptap-editor .ProseMirror .filament-tiptap-hurdle[data-color=primary]{color:rgba(var(--gray-900),1);background-color:rgba(var(--primary-500),1)}.tiptap-editor .ProseMirror .filament-tiptap-hurdle[data-color=secondary]{color:rgba(var(--gray-900),1);background-color:rgba(var(--warning-500),1)}.tiptap-editor .ProseMirror .filament-tiptap-hurdle[data-color=tertiary]{color:#fff;background-color:rgba(var(--success-500),1)}.tiptap-editor .ProseMirror .filament-tiptap-hurdle[data-color=accent]{color:#fff;background-color:rgba(var(--danger-500),1)}.tiptap-editor .ProseMirror.ProseMirror-focused{outline:none}.tiptap-editor .ProseMirror>*+*{margin-block-start:1rem}.tiptap-editor .ProseMirror>*+h1,.tiptap-editor .ProseMirror>*+h2,.tiptap-editor .ProseMirror>*+h3,.tiptap-editor .ProseMirror>*+h4,.tiptap-editor .ProseMirror>*+h5,.tiptap-editor .ProseMirror>*+h6{margin-block-start:2rem}.tiptap-editor .ProseMirror img{display:inline-block}.tiptap-editor .ProseMirror h1,.tiptap-editor .ProseMirror h2,.tiptap-editor .ProseMirror h3,.tiptap-editor .ProseMirror h4,.tiptap-editor .ProseMirror h5,.tiptap-editor .ProseMirror h6{font-weight:700}.tiptap-editor .ProseMirror h1{font-size:1.75rem;line-height:1.1}.tiptap-editor .ProseMirror h2{font-size:1.5rem;line-height:1.1}.tiptap-editor .ProseMirror h3{font-size:1.25rem;line-height:1.25}.tiptap-editor .ProseMirror h4{font-size:1.125rem}.tiptap-editor .ProseMirror .lead{font-size:1.375rem;line-height:1.3}.tiptap-editor .ProseMirror small{font-size:.75rem}.tiptap-editor .ProseMirror ol>:not([hidden])~:not([hidden]),.tiptap-editor .ProseMirror ul>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.tiptap-editor .ProseMirror ol,.tiptap-editor .ProseMirror ul{padding-inline-start:1rem;margin-inline-start:1rem}.tiptap-editor .ProseMirror ul{list-style:disc}.tiptap-editor .ProseMirror ol{list-style:decimal}.tiptap-editor .ProseMirror ul.checked-list{list-style-type:none;margin-inline-start:0}.tiptap-editor .ProseMirror ul.checked-list li{display:flex;align-items:baseline;gap:.375em}.tiptap-editor .ProseMirror ul.checked-list li:before{content:"✓";width:1.25rem;height:1.25rem;flex-shrink:0}.tiptap-editor .ProseMirror blockquote{border-left:.25rem solid rgba(var(--gray-400),1);padding-inline-start:.5rem;margin-inline-start:1rem;font-size:1.25rem}.tiptap-editor .ProseMirror hr{border-color:rgba(var(--gray-400),1)}.tiptap-editor .ProseMirror a{color:#2563eb;text-decoration:underline}.tiptap-editor .ProseMirror a[id]{color:#000;text-decoration:none}.tiptap-editor .ProseMirror a[id]:before{content:"# ";color:rgba(var(--gray-500),1);opacity:1}.tiptap-editor .ProseMirror a[data-as-button=true]{background-color:rgba(var(--gray-900),1);color:#fff!important;text-decoration:none;display:inline-block;border-radius:.375rem;padding:.5rem 1.25rem}.tiptap-editor .ProseMirror a[data-as-button=true][data-as-button-theme=primary]{background-color:rgba(var(--primary-600),1)}.tiptap-editor .ProseMirror a[data-as-button=true][data-as-button-theme=secondary]{background-color:rgba(var(--warning-600),1)}.tiptap-editor .ProseMirror a[data-as-button=true][data-as-button-theme=tertiary]{background-color:rgba(var(--success-600),1)}.tiptap-editor .ProseMirror a[data-as-button=true][data-as-button-theme=accent]{background-color:rgba(var(--danger-600),1)}.tiptap-editor .ProseMirror sup{font-size:65%}.tiptap-editor .ProseMirror img{border:2px dashed transparent}.tiptap-editor .ProseMirror img.ProseMirror-selectednode{border-radius:.25rem;outline-offset:2px;outline:rgba(var(--gray-900),1) dashed 2px}.tiptap-editor .ProseMirror table{border-collapse:collapse;margin:0;overflow:hidden;table-layout:fixed;width:100%;position:relative}.tiptap-editor .ProseMirror table td,.tiptap-editor .ProseMirror table th{border:1px solid rgba(var(--gray-400),1);min-width:1em;padding:3px 5px;vertical-align:top;background-clip:padding-box}.tiptap-editor .ProseMirror table td>*,.tiptap-editor .ProseMirror table th>*{margin-bottom:0}.tiptap-editor .ProseMirror table th{background-color:rgba(var(--gray-200),1);color:rgba(var(--gray-700),1);font-weight:700;text-align:left}.tiptap-editor .ProseMirror table .selectedCell{position:relative}.tiptap-editor .ProseMirror table .selectedCell:after{background:rgba(200,200,255,.4);content:"";left:0;right:0;top:0;bottom:0;pointer-events:none;position:absolute;z-index:2}.tiptap-editor .ProseMirror table .column-resize-handle{background-color:#adf;bottom:-2px;position:absolute;right:-2px;pointer-events:none;top:0;width:4px}.tiptap-editor .ProseMirror table p{margin:0}.tiptap-editor .ProseMirror .tableWrapper{padding:1rem 0;overflow-x:auto}.tiptap-editor .ProseMirror .resize-cursor{cursor:col-resize}.tiptap-editor .ProseMirror pre{padding:.75rem 1rem;border-radius:.25rem;font-size:.875rem}.tiptap-editor .ProseMirror pre code{border-radius:0;padding-inline:0}.tiptap-editor .ProseMirror code{background-color:rgba(var(--gray-300),1);border-radius:.25rem;padding-inline:.25rem}.tiptap-editor .ProseMirror pre.hljs{direction:ltr}.tiptap-editor .ProseMirror pre.hljs code{background-color:transparent}.tiptap-editor .ProseMirror .filament-tiptap-grid,.tiptap-editor .ProseMirror .filament-tiptap-grid-builder{display:grid;gap:1rem;box-sizing:border-box}.tiptap-editor .ProseMirror .filament-tiptap-grid .filament-tiptap-grid-builder__column,.tiptap-editor .ProseMirror .filament-tiptap-grid .filament-tiptap-grid__column,.tiptap-editor .ProseMirror .filament-tiptap-grid-builder .filament-tiptap-grid-builder__column,.tiptap-editor .ProseMirror .filament-tiptap-grid-builder .filament-tiptap-grid__column{box-sizing:border-box;border-style:dashed;border-width:1px;border-color:rgba(var(--gray-400),1);padding:.5rem;border-radius:.25rem}.tiptap-editor .ProseMirror .filament-tiptap-grid .filament-tiptap-grid-builder__column>*+*,.tiptap-editor .ProseMirror .filament-tiptap-grid .filament-tiptap-grid__column>*+*,.tiptap-editor .ProseMirror .filament-tiptap-grid-builder .filament-tiptap-grid-builder__column>*+*,.tiptap-editor .ProseMirror .filament-tiptap-grid-builder .filament-tiptap-grid__column>*+*{margin-block-start:1rem}.tiptap-editor .ProseMirror .filament-tiptap-grid-builder.ProseMirror-selectednode,.tiptap-editor .ProseMirror .filament-tiptap-grid.ProseMirror-selectednode{border-radius:.25rem;outline-offset:2px;outline:rgba(var(--gray-900),1) dashed 2px}.tiptap-editor .ProseMirror .filament-tiptap-grid[type^=asymetric]{grid-template-columns:1fr;grid-template-rows:auto}@media (max-width:640px){.tiptap-editor .ProseMirror .filament-tiptap-grid-builder[data-stack-at=sm]{grid-template-columns:1fr!important}.tiptap-editor .ProseMirror .filament-tiptap-grid-builder[data-stack-at=sm] .filament-tiptap-grid-builder__column{grid-column:span 1!important}}@media (max-width:768px){.tiptap-editor .ProseMirror .filament-tiptap-grid-builder[data-stack-at=md]{grid-template-columns:1fr!important}.tiptap-editor .ProseMirror .filament-tiptap-grid-builder[data-stack-at=md] .filament-tiptap-grid-builder__column{grid-column:span 1!important}}@media (max-width:1024px){.tiptap-editor .ProseMirror .filament-tiptap-grid-builder[data-stack-at=lg]{grid-template-columns:1fr!important}.tiptap-editor .ProseMirror .filament-tiptap-grid-builder[data-stack-at=lg] .filament-tiptap-grid-builder__column{grid-column:span 1!important}}@media (min-width:768px){.tiptap-editor .ProseMirror .filament-tiptap-grid[type=asymetric-right-thirds]{grid-template-columns:1fr 2fr}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=asymetric-left-thirds]{grid-template-columns:2fr 1fr}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=asymetric-right-fourths]{grid-template-columns:1fr 3fr}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=asymetric-left-fourths]{grid-template-columns:3fr 1fr}}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=responsive]{grid-template-columns:1fr;grid-template-rows:auto}@media (min-width:768px){.tiptap-editor .ProseMirror .filament-tiptap-grid[type=responsive][cols="2"]{grid-template-columns:repeat(2,1fr)}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=responsive][cols="3"]{grid-template-columns:repeat(3,1fr)}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=responsive][cols="4"]{grid-template-columns:repeat(2,1fr)}}@media (min-width:1024px){.tiptap-editor .ProseMirror .filament-tiptap-grid[type=responsive][cols="4"]{grid-template-columns:repeat(4,1fr)}}@media (min-width:768px){.tiptap-editor .ProseMirror .filament-tiptap-grid[type=responsive][cols="5"]{grid-template-columns:repeat(5,1fr)}}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=fixed][cols="2"]{grid-template-columns:repeat(2,1fr)}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=fixed][cols="3"]{grid-template-columns:repeat(3,1fr)}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=fixed][cols="4"]{grid-template-columns:repeat(4,1fr)}.tiptap-editor .ProseMirror .filament-tiptap-grid[type=fixed][cols="5"]{grid-template-columns:repeat(5,1fr)}.tiptap-editor .ProseMirror [data-native-video],.tiptap-editor .ProseMirror [data-vimeo-video],.tiptap-editor .ProseMirror [data-youtube-video]{border:1px dashed transparent}.tiptap-editor .ProseMirror [data-native-video].ProseMirror-selectednode,.tiptap-editor .ProseMirror [data-vimeo-video].ProseMirror-selectednode,.tiptap-editor .ProseMirror [data-youtube-video].ProseMirror-selectednode{border-radius:.25rem;outline-offset:2px;outline:rgba(var(--gray-900),1) dashed 2px}.tiptap-editor .ProseMirror [data-native-video] iframe,.tiptap-editor .ProseMirror [data-native-video] video,.tiptap-editor .ProseMirror [data-vimeo-video] iframe,.tiptap-editor .ProseMirror [data-vimeo-video] video,.tiptap-editor .ProseMirror [data-youtube-video] iframe,.tiptap-editor .ProseMirror [data-youtube-video] video{pointer-events:none}.tiptap-editor .ProseMirror div[data-type=details]{box-sizing:border-box;border-style:dashed;border-width:1px;border-color:rgba(var(--gray-400),1);border-radius:.25rem;position:relative}.tiptap-editor .ProseMirror div[data-type=details] summary{padding:.375rem .5rem;font-weight:700;border-bottom:1px solid rgba(var(--gray-200),1)}.tiptap-editor .ProseMirror div[data-type=details] summary::marker{content:"";display:none}.tiptap-editor .ProseMirror div[data-type=details] div[data-type=details-content]{padding:.5rem;height:auto}.tiptap-editor .ProseMirror div[data-type=details] div[data-type=details-content]>*+*{margin-block-start:1rem}.dark .tiptap-editor .ProseMirror{color:rgba(var(--gray-200),1)}.dark .tiptap-editor .ProseMirror blockquote{border-left-color:rgba(var(--gray-500),1)}.dark .tiptap-editor .ProseMirror hr{border-color:rgba(var(--gray-500),1)}.dark .tiptap-editor .ProseMirror a{color:#60a5fa}.dark .tiptap-editor .ProseMirror a[id]{color:rgba(var(--gray-200),1)}.dark .tiptap-editor .ProseMirror code{background-color:rgba(var(--gray-800),1)}.dark .tiptap-editor .ProseMirror table td,.dark .tiptap-editor .ProseMirror table th{border-color:rgba(var(--gray-600),1)}.dark .tiptap-editor .ProseMirror table th{background-color:rgba(var(--gray-800),1);color:rgba(var(--gray-100),1)}.dark .tiptap-editor .ProseMirror .filament-tiptap-grid .filament-tiptap-grid__column{border-color:rgba(var(--gray-500),1)}.dark .tiptap-editor .ProseMirror .filament-tiptap-grid.ProseMirror-selectednode,.dark .tiptap-editor .ProseMirror [data-native-video].ProseMirror-selectednode,.dark .tiptap-editor .ProseMirror [data-vimeo-video].ProseMirror-selectednode,.dark .tiptap-editor .ProseMirror [data-youtube-video].ProseMirror-selectednode,.dark .tiptap-editor .ProseMirror img.ProseMirror-selectednode{outline-color:rgba(var(--gray-400),1)}.dark .tiptap-editor .ProseMirror div[data-type=details]{box-sizing:border-box;border-color:rgba(var(--gray-500),1);border-radius:.25rem;position:relative}.dark .tiptap-editor .ProseMirror div[data-type=details] summary{border-bottom-color:rgba(var(--gray-500),1)}.dark .tiptap-editor .ProseMirror-focused .ProseMirror-gapcursor:after{border-top:1px solid #fff}.filament-tiptap-editor-source-modal textarea{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace monospace}.tiptap-editor p.is-editor-empty:first-child:before,.tiptap-wrapper .is-empty:before{content:attr(data-placeholder);float:left;height:0;pointer-events:none;--tw-text-opacity:1;color:rgba(var(--gray-400),var(--tw-text-opacity,1))}.tiptap-editor p.is-editor-empty:first-child:is(.dark *):before,.tiptap-wrapper .is-empty:is(.dark *):before{--tw-text-opacity:1;color:rgba(var(--gray-500),var(--tw-text-opacity,1))}.tippy-content-p-0{margin:-.25rem -.5rem}span[data-type=mergeTag]{margin-left:.25rem;margin-right:.25rem;border-radius:.25rem;--tw-bg-opacity:1;background-color:rgba(var(--gray-100),var(--tw-bg-opacity,1));padding:.25rem .5rem}span[data-type=mergeTag]:is(.dark *){--tw-bg-opacity:1;background-color:rgba(var(--gray-800),var(--tw-bg-opacity,1))}.tiptap-editor .mention{border-radius:.375rem;background-color:rgba(var(--primary-600),var(--tw-bg-opacity,1));--tw-bg-opacity:0.1;-webkit-box-decoration-break:clone;box-decoration-break:clone;padding:.125rem .25rem;--tw-text-opacity:1;color:rgba(var(--primary-600),var(--tw-text-opacity,1))}.comment-mention,[data-mention-id]{border-radius:9999px;--tw-bg-opacity:1;background-color:rgba(var(--primary-50),var(--tw-bg-opacity,1));padding:.25rem .5rem;--tw-text-opacity:1!important;color:rgba(var(--primary-500),var(--tw-text-opacity,1))!important}.comment-mention:before,[data-mention-id]:before{--tw-content:"@";content:var(--tw-content)}.comment-mention:is(.dark *),[data-mention-id]:is(.dark *){--tw-border-opacity:1;border-color:rgba(var(--primary-500),var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgba(var(--gray-700),var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgba(var(--primary-50),var(--tw-text-opacity,1))} -------------------------------------------------------------------------------- /resources/dist/nested-comments.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolsam726/nested-comments/53b075a07303dda3e1e93b8bc70b0ca77516964a/resources/dist/nested-comments.js -------------------------------------------------------------------------------- /resources/js/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolsam726/nested-comments/53b075a07303dda3e1e93b8bc70b0ca77516964a/resources/js/index.js -------------------------------------------------------------------------------- /resources/lang/en/nested-comments.php: -------------------------------------------------------------------------------- 1 | null, 3 | ]) 4 | @if ($comment?->getKey()) 5 | 9 | @else 10 |

11 | {{ __('No comment provided.') }} 12 |

13 | @endif -------------------------------------------------------------------------------- /resources/views/components/comments.blade.php: -------------------------------------------------------------------------------- 1 | @if(isset($record)) 2 | 3 | @else 4 |

No Commentable record set.

5 | @endif -------------------------------------------------------------------------------- /resources/views/components/reactions.blade.php: -------------------------------------------------------------------------------- 1 | @props([ 2 | 'record' 3 | ]) 4 | @if(isset($record)) 5 | @if(app(\Coolsam\NestedComments\NestedComments::class)->classHasTrait($record, \Coolsam\NestedComments\Concerns\HasReactions::class)) 6 | 7 | @else 8 |

__('The current record is not configured for reactions. Please include the `HasReactions` trait to the model.')

9 | @endif 10 | @endif -------------------------------------------------------------------------------- /resources/views/filament/infolists/comment-card-entry.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /resources/views/filament/infolists/comments-entry.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/views/filament/infolists/reactions-entry.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/views/filament/widgets/comments-widget.blade.php: -------------------------------------------------------------------------------- 1 | 2 | @if($this->record) 3 | 4 | @else 5 | 6 | No Commentable record found. Please pass a record to the widget. 7 | 8 | @endif 9 | 10 | -------------------------------------------------------------------------------- /resources/views/livewire/add-comment.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @if($this->addingComment) 3 |
4 | {{ $this->form }} 5 | 6 | Submit 7 | 8 | 9 | Cancel 10 | 11 |
12 | @else 13 | 16 | 22 | 23 | @endif 24 | 25 |
26 | -------------------------------------------------------------------------------- /resources/views/livewire/comment-card.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 12 |
13 |

14 | {{ $this->getCommentator() }} 15 |

16 |

18 | {{ $this->comment->created_at?->diffForHumans() }} 19 |

20 |

{{ $this->comment->created_at->format('F j Y h:i:s A') }}

23 |
24 |
25 |
26 |
27 | {!! e(new \Illuminate\Support\HtmlString($this->comment?->body)) !!} 28 |
29 |
30 | 35 | @if($this->comment->replies_count > 0) 36 | 37 | {{\Illuminate\Support\Number::forHumans($this->comment->replies_count, maxPrecision: 3, abbreviate: true)}} {{ str('Reply')->plural($this->comment->replies_count) }} 38 | 39 | @else 40 | 41 | Reply 42 | 43 | @endif 44 | 45 | 46 |
47 |
48 | @if($showReplies) 49 |
50 | @foreach($this->comment->children as $reply) 51 | 54 | @endforeach 55 | 62 | 71 |
72 | @endif 73 |
74 | 75 | @script 76 | 82 | @endscript -------------------------------------------------------------------------------- /resources/views/livewire/comments.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | {{ __('Comments') }} 6 |
7 |
8 | 9 | {{\Illuminate\Support\Number::forHumans($this->comments->count(),maxPrecision: 3, abbreviate: true)}} 10 | 11 |
12 |
13 |
14 | 15 | Refresh 16 | 17 | @foreach($this->comments as $comment) 18 | 22 | @endforeach 23 | 24 |
25 | -------------------------------------------------------------------------------- /resources/views/livewire/reaction-panel.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @foreach($this->record->reactions_map->filter(fn($reaction) => collect($reaction)->get('reactions') > 0) as $reaction => $attribs) 3 | true, 7 | ]) 8 | title="{{$reaction}} {{collect($attribs)->get('reactions')}} {{str(collect($attribs)->get('name'))->plural(collect($attribs)->get('reactions'))}}" 9 | :outlined="true" 10 | :color="collect($attribs)->get('meToo') ? 'primary' : 'gray'" size="xs" 11 | > 12 | {{$reaction}} {{\Illuminate\Support\Number::forHumans(collect($attribs)->get('reactions'), maxPrecision: 2)}} 13 | 14 | @endforeach 15 | 16 | 17 | 18 | 20 | 22 | 23 | 24 | 25 |
26 | @foreach($this->record->reactions_map as $reaction => $attribs) 27 | true, 31 | ]) 32 | :outlined="true" :color="collect($attribs)->get('meToo') ? 'primary' : 'gray'" size="md" title="{{collect($attribs)->get('name')}}" 33 | > 34 | {{$reaction}} 35 | 36 | @endforeach 37 |
38 |
39 |
40 | @script 41 | 46 | @endscript -------------------------------------------------------------------------------- /src/Commands/NestedCommentsCommand.php: -------------------------------------------------------------------------------- 1 | comment('All done'); 16 | 17 | return self::SUCCESS; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Concerns/HasComments.php: -------------------------------------------------------------------------------- 1 | morphMany(config('nested-comments.models.comment'), 'commentable'); 25 | } 26 | 27 | public function getCommentsCountAttribute(): int 28 | { 29 | return $this->comments() 30 | ->where('parent_id', '=', null) 31 | ->count(); 32 | } 33 | 34 | public function getCommentsTree($offset = null, $limit = null, $columns = ['*']): Collection 35 | { 36 | $query = $this->comments() 37 | ->getQuery() 38 | ->where('parent_id', '=', null); 39 | if (filled($offset)) { 40 | $query->offset($offset); 41 | } 42 | if (filled($limit)) { 43 | $query->limit($limit); 44 | } 45 | 46 | if (filled($columns) && $columns[0] !== '*') { 47 | $columns = ['id', 'parent_id', '_lft', '_rgt', ...$columns]; 48 | } 49 | 50 | return $query->get($columns)->map(function (Comment $comment) use ($columns) { 51 | $descendants = $comment->getDescendants($columns); 52 | $comment->setAttribute('replies', $descendants); 53 | 54 | return $comment; 55 | }); 56 | } 57 | 58 | /** 59 | * @throws Exception 60 | */ 61 | public function comment(string $comment, mixed $parentId = null, ?string $name = null) 62 | { 63 | $allowGuest = config('nested-comments.allow-guest-comments', false); 64 | if (! $allowGuest && ! auth()->check()) { 65 | throw new Exception('You must be logged in to comment.'); 66 | } 67 | if ($name) { 68 | app(NestedComments::class)->setGuestName($name); 69 | } 70 | $guestId = app(NestedComments::class)->getGuestId(); 71 | $guestName = app(NestedComments::class)->getGuestName(); 72 | if ($allowGuest && ! auth()->check()) { 73 | $userId = null; 74 | } else { 75 | $userId = auth()->id(); 76 | } 77 | 78 | return $this->comments()->create([ 79 | 'user_id' => $userId, 80 | 'body' => $comment, 81 | 'commentable_id' => $this->getKey(), 82 | 'commentable_type' => $this->getMorphClass(), 83 | 'parent_id' => $parentId, 84 | 'guest_id' => $guestId, 85 | 'guest_name' => $guestName, 86 | 'ip_address' => request()->ip(), 87 | ]); 88 | } 89 | 90 | /** 91 | * @throws Exception 92 | */ 93 | public function editComment(Comment $comment, string $body, ?string $name = null): ?bool 94 | { 95 | $allowGuest = config('nested-comments.allow-guest-comments', false); 96 | 97 | if (! auth()->check() && ! $allowGuest) { 98 | throw new Exception('You must be logged in to edit your comment.'); 99 | } 100 | 101 | if ($name) { 102 | app(NestedComments::class)->setGuestName($name); 103 | } 104 | 105 | if (auth()->check() && $comment->getAttribute('user_id') !== auth()->id()) { 106 | throw new Exception('You are not authorized to edit this comment.'); 107 | } 108 | 109 | if ($allowGuest && ! auth()->check()) { 110 | $guestId = app(NestedComments::class)->getGuestId(); 111 | if ($comment->getAttribute('guest_id') !== $guestId) { 112 | throw new Exception('You are not authorized to edit this comment.'); 113 | } 114 | } 115 | $guestName = app(NestedComments::class)->getGuestName(); 116 | 117 | return $comment->update(['body' => $body, 'guest_name' => $guestName, 'ip_address' => request()->ip()]); 118 | } 119 | 120 | /** 121 | * @throws Exception 122 | */ 123 | public function deleteComment(Comment $comment): ?bool 124 | { 125 | $allowGuest = config('nested-comments.allow-guest-comments', false); 126 | 127 | if (! auth()->check() && ! $allowGuest) { 128 | throw new Exception('You must be logged in to edit your comment.'); 129 | } 130 | 131 | if (auth()->check() && $comment->getAttribute('user_id') !== auth()->id()) { 132 | throw new Exception('You are not authorized to edit this comment.'); 133 | } 134 | 135 | if ($allowGuest && ! auth()->check()) { 136 | $guestId = app(NestedComments::class)->getGuestId(); 137 | if ($comment->getAttribute('guest_id') !== $guestId) { 138 | throw new Exception('You are not authorized to edit this comment.'); 139 | } 140 | } 141 | 142 | return $comment->delete(); 143 | } 144 | 145 | final public function getUserNameUsing(Comment $comment): string 146 | { 147 | return $this->getUserName($comment->getAttribute('user')); 148 | } 149 | 150 | final public function getUserAvatarUsing(Comment $comment): ?string 151 | { 152 | $user = $comment->user ?? $comment->guest_name ?? 'Guest'; 153 | 154 | return $this->getUserAvatar($user); 155 | } 156 | 157 | public function getUserName(Model | Authenticatable | null $user): string 158 | { 159 | return app(NestedComments::class)->getUserName($user); 160 | } 161 | 162 | public function getUserAvatar(Model | Authenticatable | string | null $user): ?string 163 | { 164 | return app(NestedComments::class)->getDefaultUserAvatar($user); 165 | } 166 | 167 | /** 168 | * @return array 169 | */ 170 | public function getMentionsUsing(string $query): array 171 | { 172 | return $this->getMentionsQuery($query) 173 | // ->where('username', 'like', "%{$query}%") 174 | ->take(50) 175 | ->get() 176 | ->map(function ($user) { 177 | return new MentionItem( 178 | id: $user->getKey(), 179 | label: $this->getUserName($user), 180 | image: $this->getUserAvatar($user), 181 | roundedImage: true, 182 | ); 183 | })->toArray(); 184 | } 185 | 186 | public function getMentionsQuery(string $query): Builder 187 | { 188 | return app(NestedComments::class)->getUserMentionsQuery($query); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Concerns/HasReactions.php: -------------------------------------------------------------------------------- 1 | morphMany(config('nested-comments.models.reaction'), 'reactable'); 21 | } 22 | 23 | public function getTotalReactionsAttribute(): int 24 | { 25 | return $this->reactions()->count(); 26 | } 27 | 28 | public function getReactionsCountsAttribute() 29 | { 30 | return $this->reactions()->get(['id', 'emoji'])->groupBy('emoji')->map(function ($item) { 31 | return count($item); 32 | }); 33 | } 34 | 35 | public function getEmojiReactorsAttribute() 36 | { 37 | return $this->reactions()->get(['id', 'emoji', 'user_id', 'guest_id', 'guest_name']) 38 | ->groupBy('emoji') 39 | ->map(function (Collection $item) { 40 | return $item->map(function ($reaction) { 41 | return [ 42 | 'id' => $reaction->getKey(), 43 | 'user_id' => $reaction->getAttribute('user_id'), 44 | 'guest_id' => $reaction->getAttribute('guest_id'), 45 | 'name' => $reaction->getAttribute('user_id') ? call_user_func(config('nested-comments.closures.getUserNameUsing'), $reaction->getAttribute('user')) : $reaction->getAttribute('guest_name'), 46 | ]; 47 | }); 48 | }); 49 | } 50 | 51 | public function getMyReactionsAttribute(): array 52 | { 53 | $allowGuest = config('nested-comments.allow-guest-reactions', false); 54 | 55 | if ($allowGuest && ! Auth::check()) { 56 | $guestId = app(NestedComments::class)->getGuestId(); 57 | if (! $guestId) { 58 | return []; 59 | } 60 | 61 | return $this->reactions() 62 | ->where('guest_id', '=', $guestId) 63 | ->pluck('emoji')->values()->toArray(); 64 | } 65 | 66 | return $this->reactions() 67 | ->where('user_id', '=', Auth::id()) 68 | ->pluck('emoji')->values()->toArray(); 69 | } 70 | 71 | public function iHaveReacted(string $emoji): bool 72 | { 73 | $myReactions = $this->getMyReactionsAttribute(); 74 | if (empty($myReactions)) { 75 | return false; 76 | } 77 | 78 | return in_array($emoji, $myReactions); 79 | } 80 | 81 | /** 82 | * @throws Throwable 83 | */ 84 | public function react(string $emoji): Reaction | Model | int 85 | { 86 | $existing = $this->getExistingReaction($emoji); 87 | if ($existing) { 88 | $id = $existing->getKey(); 89 | $existing->deleteOrFail(); 90 | 91 | return $id; 92 | } 93 | if (! $this->isAllowed($emoji)) { 94 | throw new Exception('This reaction is not allowed.'); 95 | } 96 | 97 | return $this->reactions()->create([ 98 | 'user_id' => Auth::check() ? Auth::id() : null, 99 | 'emoji' => $emoji, 100 | 'guest_id' => app(NestedComments::class)->getGuestId(), 101 | 'guest_name' => app(NestedComments::class)->getGuestName(), 102 | 'ip_address' => request()->ip(), 103 | ]); 104 | } 105 | 106 | /** 107 | * @throws Exception 108 | */ 109 | protected function getExistingReaction(string $emoji): Reaction | Model | null 110 | { 111 | $allowMultiple = config('nested-comments.allow-multiple-reactions', false); 112 | $allowGuest = config('nested-comments.allow-guest-reactions', false); 113 | 114 | if (! $allowGuest && ! Auth::check()) { 115 | throw new Exception('You must be logged in to react.'); 116 | } 117 | 118 | if ($allowGuest && ! Auth::check()) { 119 | $guestId = app(NestedComments::class)->getGuestId(); 120 | if (! $guestId) { 121 | throw new Exception('Sorry, your guest session has not bee setup.'); 122 | } 123 | $existingQuery = $this->reactions() 124 | ->where('guest_id', '=', $guestId); 125 | 126 | } else { 127 | $existingQuery = $this->reactions() 128 | ->where('user_id', '=', Auth::id()); 129 | } 130 | 131 | if ($allowMultiple) { 132 | $existingQuery->where('emoji', '=', $emoji); 133 | } 134 | 135 | return $existingQuery->first(); 136 | } 137 | 138 | public function isAllowed(string $emoji): bool 139 | { 140 | $allowOthers = config('nested-comments.allow-all-reactions', false); 141 | $allowed = config('nested-comments.allowed-reactions', []); 142 | if (empty($allowed)) { 143 | return true; 144 | } 145 | 146 | return $allowOthers || collect($allowed)->has($emoji); 147 | } 148 | 149 | public function getReactionsMapAttribute(): Collection 150 | { 151 | $reactions = collect($this->getReactionsCountsAttribute()); 152 | $myReactions = collect($this->getMyReactionsAttribute())->mapWithKeys(fn ($value) => [$value => $value]); 153 | 154 | return collect(config('nested-comments.allowed-reactions', []))->mapWithKeys(function ($value, $emoji) use ($reactions, $myReactions) { 155 | $name = $value; 156 | 157 | return [$emoji => [ 158 | 'name' => $name, 159 | 'emoji' => $emoji, 160 | 'reactions' => $reactions->get($emoji), 161 | 'meToo' => $myReactions->has($emoji), 162 | ]]; 163 | }); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Facades/NestedComments.php: -------------------------------------------------------------------------------- 1 | modalWidth('4xl') 16 | ->slideOver() 17 | ->modalHeading(fn (): string => __('View Comments')); 18 | $this->modalSubmitAction(false); 19 | $this->modalCancelActionLabel(__('Close')); 20 | $this->icon('heroicon-o-chat-bubble-left-right'); 21 | $this->modalIcon('heroicon-o-chat-bubble-left-right'); 22 | } 23 | 24 | public static function getDefaultName(): ?string 25 | { 26 | return 'comments'; 27 | } 28 | 29 | public function getModalContent(): View | Htmlable | null 30 | { 31 | $record = $this->getRecord(); 32 | 33 | return app(NestedComments::class)->renderCommentsComponent($record); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Filament/Infolists/CommentCardEntry.php: -------------------------------------------------------------------------------- 1 | 'full']; 23 | 24 | public static function make(?string $name = null): static 25 | { 26 | $name = $name ?? 'body'; 27 | 28 | return parent::make($name); // TODO: Change the autogenerated stub 29 | } 30 | 31 | public function getUserNameUsing(Closure | string | null $callback = null): static 32 | { 33 | $this->userNameUsingClosure = $callback; 34 | 35 | return $this; 36 | } 37 | 38 | public function getUserAvatarUsing(Closure | string | null $callback = null): static 39 | { 40 | $this->userAvatarUsingClosure = $callback; 41 | 42 | return $this; 43 | } 44 | 45 | public function evaluateUserAvatar(): ?string 46 | { 47 | return $this->evaluate($this->userAvatarUsingClosure 48 | ?? fn (Comment | Model $record) => app(NestedComments::class) 49 | ->getDefaultUserAvatar($record->getAttribute('user') 50 | ?? $record->getAttribute('guest_name') ?? 'Guest')); 51 | } 52 | 53 | public function evaluateUserName(): ?string 54 | { 55 | return $this->evaluate($this->userNameUsingClosure 56 | ?? fn (Comment | Model | null $record) => $record?->getAttribute('user')?->getAttribute('name') 57 | ?? $record->getAttribute('guest_name')); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Filament/Infolists/CommentsEntry.php: -------------------------------------------------------------------------------- 1 | 'full']; 16 | } 17 | -------------------------------------------------------------------------------- /src/Filament/Infolists/ReactionsEntry.php: -------------------------------------------------------------------------------- 1 | modalWidth('4xl') 16 | ->slideOver() 17 | ->modalHeading(fn (): string => __('Comments')); 18 | $this->modalSubmitAction(false); 19 | $this->modalCancelActionLabel(__('Close')); 20 | $this->icon('heroicon-o-chat-bubble-left-right'); 21 | $this->modalIcon('heroicon-o-chat-bubble-left-right'); 22 | } 23 | 24 | public static function getDefaultName(): ?string 25 | { 26 | return 'comments'; 27 | } 28 | 29 | public function getModalContent(): View | Htmlable | null 30 | { 31 | $record = $this->getRecord(); 32 | 33 | return app(NestedComments::class)->renderCommentsComponent($record); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Filament/Widgets/CommentsWidget.php: -------------------------------------------------------------------------------- 1 | setOrGetGuestId(); 20 | app(NestedComments::class)->setOrGetGuestName(); 21 | 22 | return $next($request); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Livewire/AddComment.php: -------------------------------------------------------------------------------- 1 | commentable = $commentable; 41 | $this->replyTo = $replyTo; 42 | $this->form->fill(); 43 | } 44 | 45 | public function getCommentable(): Model 46 | { 47 | if (! $this->commentable) { 48 | throw new Error('The $commentable property is required.'); 49 | } 50 | 51 | return $this->commentable; 52 | } 53 | 54 | public function form(Form $form): Form 55 | { 56 | /** 57 | * @var Model|HasComments $commentable 58 | * 59 | * @phpstan-ignore-next-line 60 | */ 61 | $commentable = $this->getCommentable(); 62 | 63 | return $form 64 | ->schema([ 65 | TiptapEditor::make('body') 66 | ->label(__('Your comment')) 67 | ->profile('minimal') 68 | ->extraInputAttributes(['style' => 'min-height: 12rem;']) 69 | ->mentionItemsPlaceholder(config('nested-comments.mentions.items-placeholder', __('Search users by name or email address'))) 70 | ->emptyMentionItemsMessage(config('nested-comments.mentions.empty-items-message', __('No users found'))) 71 | /** 72 | * @phpstan-ignore-next-line 73 | */ 74 | ->getMentionItemsUsing(fn (string $query) => $commentable->getMentionsUsing($query)) 75 | ->maxContentWidth('full') 76 | ->required() 77 | ->autofocus(), 78 | ]) 79 | ->statePath('data') 80 | ->model(config('nested-comments.models.comment', Comment::class)); 81 | } 82 | 83 | /** 84 | * @throws Exception 85 | */ 86 | public function create(): void 87 | { 88 | $data = $this->form->getState(); 89 | 90 | /** 91 | * @var Model|HasComments $commentable 92 | * 93 | * @phpstan-ignore-next-line 94 | */ 95 | $commentable = $this->getCommentable(); 96 | 97 | /** 98 | * @phpstan-ignore-next-line 99 | */ 100 | $record = $commentable->comment(comment: $data['body'], parentId: $this->replyTo?->getKey()); 101 | $this->dispatch('refresh'); 102 | $this->showForm(false); 103 | } 104 | 105 | public function render(): View 106 | { 107 | $namespace = NestedCommentsServiceProvider::$viewNamespace; 108 | 109 | return view("$namespace::livewire.add-comment"); 110 | } 111 | 112 | public function showForm(bool $adding): void 113 | { 114 | $this->form->fill(); 115 | $this->addingComment = $adding; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Livewire/CommentCard.php: -------------------------------------------------------------------------------- 1 | 'refreshReplies', 25 | ]; 26 | 27 | public function mount(?Comment $comment = null): void 28 | { 29 | if (! $comment) { 30 | throw new Error('The $comment property is required.'); 31 | } 32 | 33 | $this->comment = $comment; 34 | } 35 | 36 | public function render() 37 | { 38 | $namespace = NestedCommentsServiceProvider::$viewNamespace; 39 | 40 | return view("$namespace::livewire.comment-card"); 41 | } 42 | 43 | public function refreshReplies(): void 44 | { 45 | $this->comment = $this->comment?->refresh(); 46 | } 47 | 48 | public function toggleReplies(): void 49 | { 50 | $this->showReplies = ! $this->showReplies; 51 | } 52 | 53 | public function getAvatar() 54 | { 55 | if (! $this->comment) { 56 | return ''; 57 | } 58 | 59 | /** 60 | * @phpstan-ignore-next-line 61 | */ 62 | return $this->comment->commentable?->getUserAvatarUsing($this->comment); 63 | } 64 | 65 | public function getCommentator(): string 66 | { 67 | if (! $this->comment) { 68 | return ''; 69 | } 70 | 71 | /** 72 | * @phpstan-ignore-next-line 73 | */ 74 | return $this->comment->commentable?->getUserNameUsing($this->comment); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Livewire/Comments.php: -------------------------------------------------------------------------------- 1 | 'refreshComments', 22 | ]; 23 | 24 | /** 25 | * @var Collection 26 | */ 27 | public Collection $comments; 28 | 29 | public function mount(): void 30 | { 31 | $this->comments = collect(); 32 | 33 | if (! $this->record) { 34 | throw new \Error('Record model (Commentable) is required'); 35 | } 36 | 37 | if (! app(NestedComments::class)->classHasTrait($this->record, HasComments::class)) { 38 | throw new \Error('Record model must use the HasComments trait'); 39 | } 40 | 41 | $this->refreshComments(); 42 | } 43 | 44 | public function refreshComments(): void 45 | { 46 | $this->record = $this->record->refresh(); 47 | if (method_exists($this->record, 'getCommentsTree')) { 48 | $this->comments = $this->record->getCommentsTree(); 49 | } 50 | } 51 | 52 | public function render() 53 | { 54 | $namespace = NestedCommentsServiceProvider::$viewNamespace; 55 | 56 | return view($namespace . '::livewire.comments'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Livewire/ReactionPanel.php: -------------------------------------------------------------------------------- 1 | '$refresh', 12 | ]; 13 | 14 | public array $allReactions = []; 15 | 16 | public Model $record; 17 | 18 | public function mount(mixed $record = null): void 19 | { 20 | if (! $record?->getKey()) { 21 | throw new \Error('The Reactable $record property is required.'); 22 | } 23 | $this->record = $record; 24 | } 25 | 26 | public function render() 27 | { 28 | return view('nested-comments::livewire.reaction-panel'); 29 | } 30 | 31 | public function react($emoji): void 32 | { 33 | if (method_exists($this->record, 'react')) { 34 | $this->record->react($emoji); 35 | $this->dispatch('refresh')->self(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Models/Comment.php: -------------------------------------------------------------------------------- 1 | morphTo('commentable'); 21 | } 22 | 23 | public function user(): BelongsTo 24 | { 25 | return $this->belongsTo(config('nested-comments.models.user', config('auth.providers.users.model', 'App\\Models\\User')), 'user_id'); 26 | } 27 | 28 | public function getCommentatorAttribute() 29 | { 30 | return $this->getAttribute('guest_name'); 31 | } 32 | 33 | public function getRepliesCountAttribute(): int 34 | { 35 | return $this->children()->count(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Models/Reaction.php: -------------------------------------------------------------------------------- 1 | getAttribute('name') ?? $user?->getAttribute('guest_name') ?? 'Guest'; 32 | } 33 | 34 | public function getGuestName(): string 35 | { 36 | if (Auth::check()) { 37 | return $this->getUserName(Auth::user()); 38 | } 39 | 40 | return session(self::GUEST_NAME_FIELD, 'Guest'); 41 | } 42 | 43 | public function setGuestName(string $name): void 44 | { 45 | session([self::GUEST_NAME_FIELD => $name]); 46 | } 47 | 48 | public function setOrGetGuestId() 49 | { 50 | $id = Str::ulid()->toString(); 51 | if (! session()->has(self::GUEST_ID_FIELD)) { 52 | session([self::GUEST_ID_FIELD => $id]); 53 | } 54 | 55 | return session(self::GUEST_ID_FIELD); 56 | } 57 | 58 | public function setOrGetGuestName() 59 | { 60 | if (! session()->has(self::GUEST_NAME_FIELD)) { 61 | session([self::GUEST_NAME_FIELD => 'Guest']); 62 | } 63 | 64 | return session(self::GUEST_NAME_FIELD); 65 | } 66 | 67 | public function classHasTrait(object | string $classInstance, string $trait): bool 68 | { 69 | return in_array($trait, class_uses_recursive($classInstance)); 70 | } 71 | 72 | public function getDefaultUserAvatar(Authenticatable | Model | string $user) 73 | { 74 | if (is_string($user)) { 75 | $name = str($user) 76 | ->trim() 77 | ->explode(' ') 78 | ->map(fn (string $segment): string => filled($segment) ? mb_substr($segment, 0, 1) : '') 79 | ->join(' '); 80 | 81 | $backgroundColor = Rgb::fromString('rgb(' . FilamentColor::getColors()['gray'][950] . ')')->toHex(); 82 | 83 | return 'https://ui-avatars.com/api/?name=' . urlencode($name) . '&color=FFFFFF&background=' . str($backgroundColor)->after('#'); 84 | } else { 85 | return app(UiAvatarsProvider::class)->get($user); 86 | } 87 | } 88 | 89 | public function getUserMentions(string $query): array 90 | { 91 | return $this->getUserMentionsQuery($query) 92 | ->take(20) 93 | ->get() 94 | ->map(function ($user) { 95 | return new MentionItem( 96 | id: $user->getKey(), 97 | label: $this->getUserName($user), 98 | image: $this->getDefaultUserAvatar($user), 99 | roundedImage: true, 100 | ); 101 | })->toArray(); 102 | } 103 | 104 | public function getUserMentionsQuery(string $query): Builder 105 | { 106 | $userModel = config('nested-comments.models.user', config('auth.providers.users.model', 'App\\Models\\User')); 107 | 108 | return $userModel::query() 109 | ->where('name', 'like', "%{$query}%") 110 | ->orWhere('email', 'like', "%{$query}%"); 111 | } 112 | 113 | public function getCurrentThreadUsers(string $searchQuery, $commentable): array 114 | { 115 | return $this->getCurrentThreadUsersQuery($searchQuery, $commentable) 116 | ->take(20) 117 | ->get() 118 | ->map(function ($user) { 119 | return new MentionItem( 120 | id: $user->getKey(), 121 | label: $this->getUserName($user), 122 | image: $this->getDefaultUserAvatar($user), 123 | roundedImage: true, 124 | ); 125 | })->toArray(); 126 | } 127 | 128 | public function getCurrentThreadUsersQuery(string $searchQuery, $commentable): Builder 129 | { 130 | $userModel = config('nested-comments.models.user', config('auth.providers.users.model', 'App\\Models\\User')); 131 | $ids = []; 132 | if (method_exists($commentable, 'comments')) { 133 | $ids = $commentable->comments()->pluck('user_id')->filter()->unique()->toArray(); 134 | } 135 | 136 | return $userModel::query() 137 | ->whereIn('id', $ids) 138 | ->where( 139 | fn ($q) => $q 140 | ->where('name', 'like', "%{$searchQuery}%") 141 | ->orWhere('email', 'like', "%{$searchQuery}%") 142 | ); 143 | } 144 | 145 | public function renderCommentsComponent(Model $record): \Illuminate\Contracts\View\View | Application | Factory | View 146 | { 147 | return view('nested-comments::components.comments', [ 148 | 'record' => $record, 149 | ]); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/NestedCommentsPlugin.php: -------------------------------------------------------------------------------- 1 | getId()); 34 | 35 | return $plugin; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/NestedCommentsServiceProvider.php: -------------------------------------------------------------------------------- 1 | name(static::$name) 44 | ->hasCommands($this->getCommands()) 45 | ->hasInstallCommand(function (InstallCommand $command) { 46 | $command 47 | ->startWith(function (Command $command) { 48 | $command->comment('Publishing config file...'); 49 | if (confirm(__('Do you want to publish and overwrite the config file? (The existing file will be backed up to .bak)'))) { 50 | // check if the config file exists and back it up by copying to .bak 51 | if (file_exists(config_path('nested-comments.php'))) { 52 | $command->info('Backing up existing config to .bak file'); 53 | // copy the config file to .bak 54 | copy(config_path('nested-comments.php'), config_path('nested-comments.php.bak')); 55 | } 56 | $command->call('vendor:publish', [ 57 | '--tag' => 'nested-comments-config', 58 | '--force' => true, 59 | ]); 60 | } 61 | 62 | $forceAssets = confirm(__('Do you want to override existing assets with new assets? (important if you are doing an upgrade)'), true); 63 | if ($forceAssets) { 64 | // Delete the existing assets in public/css/coolsam and public/js/coolsam 65 | $filesystem = app(Filesystem::class); 66 | $filesystem->deleteDirectory(public_path('css/coolsam/nested-comments')); 67 | $filesystem->deleteDirectory(public_path('js/coolsam/nested-comments')); 68 | Artisan::call('filament:assets'); 69 | } 70 | }) 71 | ->publishConfigFile() 72 | ->publishAssets() 73 | ->publishMigrations() 74 | ->askToRunMigrations() 75 | ->askToStarRepoOnGitHub('coolsam/nested-comments'); 76 | }); 77 | 78 | $configFileName = $package->shortName(); 79 | 80 | if (file_exists($package->basePath("/../config/{$configFileName}.php"))) { 81 | $package->hasConfigFile(); 82 | } 83 | 84 | if (file_exists($package->basePath('/../database/migrations'))) { 85 | $package->hasMigrations($this->getMigrations()); 86 | } 87 | 88 | if (file_exists($package->basePath('/../resources/lang'))) { 89 | $package->hasTranslations(); 90 | } 91 | 92 | if (file_exists($package->basePath('/../resources/views'))) { 93 | $package->hasViews(static::$viewNamespace); 94 | } 95 | if (file_exists($package->basePath('/../resources/views/components'))) { 96 | $package->hasViewComponents(static::$viewNamespace); 97 | } 98 | } 99 | 100 | public function packageRegistered(): void {} 101 | 102 | public function bootingPackage(): void 103 | { 104 | $this->app['router']->pushMiddlewareToGroup('web', GuestCommentatorMiddleware::class); 105 | } 106 | 107 | public function packageBooted(): void 108 | { 109 | $this->registerPolicies(); 110 | 111 | // Livewire components 112 | $this->registerLivewireComponents(); 113 | 114 | // Asset Registration 115 | FilamentAsset::register( 116 | $this->getAssets(), 117 | $this->getAssetPackageName() 118 | ); 119 | 120 | FilamentAsset::registerScriptData( 121 | $this->getScriptData(), 122 | $this->getAssetPackageName() 123 | ); 124 | 125 | // Icon Registration 126 | FilamentIcon::register($this->getIcons()); 127 | 128 | // Handle Stubs 129 | if (app()->runningInConsole()) { 130 | foreach (app(Filesystem::class)->files(__DIR__ . '/../stubs/') as $file) { 131 | $this->publishes([ 132 | $file->getRealPath() => base_path("stubs/nested-comments/{$file->getFilename()}"), 133 | ], 'nested-comments-stubs'); 134 | } 135 | } 136 | 137 | // Testing 138 | Testable::mixin(new TestsNestedComments); 139 | } 140 | 141 | protected function getAssetPackageName(): ?string 142 | { 143 | return 'coolsam/nested-comments'; 144 | } 145 | 146 | /** 147 | * @return array 148 | */ 149 | protected function getAssets(): array 150 | { 151 | return [ 152 | // AlpineComponent::make('nested-comments', __DIR__ . '/../resources/dist/components/nested-comments.js'), 153 | Css::make('nested-comments-styles', __DIR__ . '/../resources/dist/nested-comments.css'), 154 | Js::make('nested-comments-scripts', __DIR__ . '/../resources/dist/nested-comments.js'), 155 | ]; 156 | } 157 | 158 | /** 159 | * @return array 160 | */ 161 | protected function getCommands(): array 162 | { 163 | return [ 164 | NestedCommentsCommand::class, 165 | ]; 166 | } 167 | 168 | /** 169 | * @return array 170 | */ 171 | protected function getIcons(): array 172 | { 173 | return []; 174 | } 175 | 176 | /** 177 | * @return array 178 | */ 179 | protected function getRoutes(): array 180 | { 181 | return []; 182 | } 183 | 184 | /** 185 | * @return array 186 | */ 187 | protected function getScriptData(): array 188 | { 189 | return []; 190 | } 191 | 192 | /** 193 | * @return array 194 | */ 195 | protected function getMigrations(): array 196 | { 197 | return [ 198 | 'create_nested_comments_table', 199 | ]; 200 | } 201 | 202 | protected function registerPolicies(): void 203 | { 204 | $policies = config('nested-comments.policies'); 205 | 206 | // register policies 207 | foreach ($policies as $model => $policy) { 208 | if (! $policy) { 209 | continue; 210 | } 211 | $modelClass = config("nested-comments.models.{$model}"); 212 | if (! $modelClass) { 213 | continue; 214 | } 215 | \Gate::policy($modelClass, $policy); 216 | } 217 | } 218 | 219 | protected function registerLivewireComponents() 220 | { 221 | $namespace = static::$viewNamespace; 222 | $components = $this->getLivewireComponents(); 223 | if (empty($components)) { 224 | return; 225 | } 226 | foreach ($components as $name => $component) { 227 | Livewire::component("$namespace::$name", $component); 228 | } 229 | } 230 | 231 | protected function getLivewireComponents(): array 232 | { 233 | return [ 234 | 'comments' => Comments::class, 235 | 'comment-card' => CommentCard::class, 236 | 'add-comment' => AddComment::class, 237 | 'reaction-panel' => ReactionPanel::class, 238 | 'filament.widgets.comments-widget' => CommentsWidget::class, 239 | ]; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/Testing/TestsNestedComments.php: -------------------------------------------------------------------------------- 1 |