diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml index 58537e4..9332cf3 100644 --- a/.github/workflows/fix-php-code-style-issues.yml +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -37,5 +37,11 @@ jobs: branch: php-style-fixes title: Fix PHP Code Styling labels: code-style - auto-merge: true delete-branch: true + + - name: Auto-merge Pull Request + uses: pascalgn/automerge-action@v0.16.4 + with: + merge-method: squash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/composer.json b/composer.json index 5b23cc1..d521422 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "require": { "php": "^8.2", "filament/filament": "^3", + "kalnoy/nestedset": "^6.0", "spatie/laravel-package-tools": "^1.15.0" }, "require-dev": { @@ -71,4 +72,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/config/nested-comments.php b/config/nested-comments.php index d1059ef..b62bcce 100644 --- a/config/nested-comments.php +++ b/config/nested-comments.php @@ -1,6 +1,35 @@ [ + 'comments' => 'comments', + 'reactions' => 'reactions', + 'users' => 'users', // The table that will be used to get the authenticated user + ], + 'models' => [ + 'comment' => \Coolsam\NestedComments\Models\Comment::class, + 'reaction' => \Coolsam\NestedComments\Models\Reaction::class, + 'user' => env( 'AUTH_MODEL', 'App\Models\User'), // The model that will be used to get the authenticated user + ], + + 'policies' => [ + 'comment' => null, + 'reaction' => null, + ], + 'allowed-reactions' => [ + '👍', // thumbs up + '👎', // thumbs down + '❤️', // heart + '😂', // laughing + '😮', // surprised + '😢', // crying + '😡', // angry + '🔥', // fire + '🎉', // party popper + '🚀', // rocket + ], + 'allow-multiple-reactions' => env('ALLOW_MULTIPLE_REACTIONS', false), // Allow multiple reactions from the same user + 'allow-guest-reactions' => env('ALLOW_GUEST_REACTIONS', false), // Allow guest users to react + 'allow-guest-comments' => env('ALLOW_GUEST_COMMENTS', false), // Allow guest users to comment ]; diff --git a/database/migrations/create_nested_comments_table.php.stub b/database/migrations/create_nested_comments_table.php.stub index 931091f..2340b08 100644 --- a/database/migrations/create_nested_comments_table.php.stub +++ b/database/migrations/create_nested_comments_table.php.stub @@ -2,18 +2,37 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up() { - Schema::create('nested_comments_table', function (Blueprint $table) { + $users = Config::get('nested-comments.tables.users', 'users'); + Schema::create(Config::get('nested-comments.tables.comments'), function (Blueprint $table) use ($users) { $table->id(); + $table->nestedSet(); + $table->foreignId('user_id')->nullable()->constrained($users)->cascadeOnDelete(); + $table->text('body'); + $table->morphs('commentable'); + $table->ipAddress()->nullable(); + $table->timestamps(); + }); - // add fields - + Schema::create(Config::get('nested-comments.tables.reactions'), function (Blueprint $table) use ($users) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained($users)->cascadeOnDelete(); + $table->morphs('reactable'); + $table->string('emoji'); + $table->ipAddress()->nullable(); $table->timestamps(); }); } + + public function down() + { + Schema::dropIfExists(Config::get('nested-comments.tables.reactions')); + Schema::dropIfExists(Config::get('nested-comments.tables.comments')); + } }; diff --git a/package-lock.json b/package-lock.json index 4fde350..3225c11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,13 +4,13 @@ "requires": true, "packages": { "": { - "name": "nested-comments", "devDependencies": { "@awcodes/filament-plugin-purge": "^1.1.1", "@tailwindcss/forms": "^0.5.4", "@tailwindcss/typography": "^0.5.9", "autoprefixer": "^10.4.14", "esbuild": "^0.25.2", + "flowbite": "^3.1.2", "npm-run-all": "^4.1.5", "postcss": "^8.4.26", "postcss-import": "^15.1.0", @@ -556,6 +556,74 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", @@ -583,6 +651,18 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -1168,6 +1248,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -1461,6 +1550,12 @@ "node": ">=0.8.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1510,6 +1605,39 @@ "node": ">=8" } }, + "node_modules/flowbite": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-3.1.2.tgz", + "integrity": "sha512-MkwSgbbybCYgMC+go6Da5idEKUFfMqc/AmSjm/2ZbdmvoKf5frLPq/eIhXc9P+rC8t9boZtUXzHDgt5whZ6A/Q==", + "dev": true, + "dependencies": { + "@popperjs/core": "^2.9.3", + "flowbite-datepicker": "^1.3.1", + "mini-svg-data-uri": "^1.4.3", + "postcss": "^8.5.1" + } + }, + "node_modules/flowbite-datepicker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.3.2.tgz", + "integrity": "sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==", + "dev": true, + "dependencies": { + "@rollup/plugin-node-resolve": "^15.2.3", + "flowbite": "^2.0.0" + } + }, + "node_modules/flowbite-datepicker/node_modules/flowbite": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-2.5.2.tgz", + "integrity": "sha512-kwFD3n8/YW4EG8GlY3Od9IoKND97kitO+/ejISHSqpn3vw2i5K/+ZI8Jm2V+KC4fGdnfi0XZ+TzYqQb4Q1LshA==", + "dev": true, + "dependencies": { + "@popperjs/core": "^2.9.3", + "flowbite-datepicker": "^1.3.0", + "mini-svg-data-uri": "^1.4.3" + } + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -2221,6 +2349,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", diff --git a/package.json b/package.json index 7ece2fa..8869e4f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tailwindcss/typography": "^0.5.9", "autoprefixer": "^10.4.14", "esbuild": "^0.25.2", + "flowbite": "^3.1.2", "npm-run-all": "^4.1.5", "postcss": "^8.4.26", "postcss-import": "^15.1.0", diff --git a/src/Concerns/HasComments.php b/src/Concerns/HasComments.php new file mode 100644 index 0000000..6efb8e9 --- /dev/null +++ b/src/Concerns/HasComments.php @@ -0,0 +1,85 @@ +morphMany(config('nested-comments.models.comment'), 'commentable'); + } + + public function getCommentsCountAttribute(): int + { + return $this->comments()->count(); + } + + public function getCommentsTree($offset = null, $limit = null, $columns = ['*']) + { + $query = $this->comments() + ->getQuery() + ->where('parent_id', '=', null); + if (filled($offset)) { + $query->offset($offset); + } + if (filled($limit)) { + $query->limit($limit); + } + + $columns = ['id', 'parent_id', '_lft', '_rgt', ...$columns]; + + return collect($query->get($columns)->map(function (Comment $comment) use ($columns) { + $descendants = $comment->getDescendants($columns); + return collect($comment->toArray())->put('descendants', $descendants->toArray()); + })->toArray()); + } + + /** + * @throws \Exception + */ + public function addComment(string $comment, mixed $parentId = null) + { + $allowGuest = config('nested-comments.allow-guest-comments', false); + if (! $allowGuest && ! auth()->check()) { + throw new \Exception('You must be logged in to comment.'); + } + + if ($allowGuest && ! auth()->check()) { + $userId = null; + } else { + $userId = auth()->id(); + } + + return $this->comments()->create([ + 'user_id' => $userId, + 'body' => $comment, + 'commentable_id' => $this->getKey(), + 'commentable_type' => $this->getMorphClass(), + 'parent_id' => $parentId, + 'ip_address' => request()->ip(), + ]); + } + + /** + * @throws \Exception + */ + public function deleteComment(Comment $comment): ?bool + { + if (! auth()->check()) { + throw new \Exception('You must be logged in to delete your comment.'); + } + + if ($comment->getAttribute('user_id') !== auth()->id()) { + throw new \Exception('You are not authorized to delete this comment.'); + } + + return $comment->delete(); + } +} diff --git a/src/Concerns/HasReactions.php b/src/Concerns/HasReactions.php new file mode 100644 index 0000000..e37f429 --- /dev/null +++ b/src/Concerns/HasReactions.php @@ -0,0 +1,79 @@ +morphMany(config('nested-comments.models.reaction'), 'reactable'); + } + + public function getReactionsCountAttribute(): int + { + return $this->reactions()->count(); + } + + /** + * @throws \Throwable + */ + public function toggleReaction(string $emoji): Reaction | int + { + $existing = $this->getExistingReaction($emoji); + if ($existing) { + $id = $existing->getKey(); + $existing->deleteOrFail(); + + return $id; + } + if (! $this->isAllowed($emoji)) { + throw new \Exception('This reaction is not allowed.'); + } + return $this->reactions()->create([ + 'user_id' => Auth::check() ? Auth::id() : null, + 'emoji' => $emoji, + 'ip_address' => request()->ip(), + ]); + } + + /** + * @throws \Exception + */ + protected function getExistingReaction(string $emoji): ?Reaction + { + $allowMultiple = \config('nested-comments.allow-multiple-reactions', false); + $allowGuest = \config('nested-comments.allow-guest-reactions', false); + + if (! $allowGuest && ! Auth::check()) { + throw new \Exception('You must be logged in to react.'); + } + + if ($allowGuest && ! Auth::check()) { + $existingQuery = $this->reactions() + ->where('ip_address', '=', request()->ip()); + + } else { + $existingQuery = $this->reactions() + ->where('user_id', '=', Auth::id()); + + } + if ($allowMultiple) { + $existingQuery->where('emoji', '=', $emoji); + } + + return $existingQuery->first(); + } + + public function isAllowed(string $emoji): bool + { + $allowed = config('nested-comments.allowed-reactions', []); + if (empty($allowed)) { + return true; + } + return in_array($emoji, $allowed); + } +} diff --git a/src/Models/Comment.php b/src/Models/Comment.php new file mode 100644 index 0000000..fb2ac74 --- /dev/null +++ b/src/Models/Comment.php @@ -0,0 +1,21 @@ +morphTo('commentable'); + } +} diff --git a/src/Models/Reaction.php b/src/Models/Reaction.php new file mode 100644 index 0000000..6793ab3 --- /dev/null +++ b/src/Models/Reaction.php @@ -0,0 +1,10 @@ +registerPolicies(); // Asset Registration FilamentAsset::register( $this->getAssets(), @@ -146,7 +147,24 @@ protected function getScriptData(): array protected function getMigrations(): array { return [ - 'create_nested-comments_table', + 'create_nested_comments_table', ]; } + + protected function registerPolicies(): void + { + $policies = config('nested-comments.policies'); + + // register policies + foreach ($policies as $model => $policy) { + if (! $policy) { + continue; + } + $modelClass = config("nested-comments.models.{$model}"); + if (! $modelClass) { + continue; + } + \Gate::policy($modelClass, $policy); + } + } }