diff --git a/.env.example b/.env.example index 668bb0a..d049a24 100644 --- a/.env.example +++ b/.env.example @@ -38,7 +38,15 @@ SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null -BROADCAST_CONNECTION=log +BROADCAST_CONNECTION=pusher +PUSHER_APP_ID= +PUSHER_APP_KEY= +PUSHER_APP_SECRET= +PUSHER_APP_CLUSTER=ap2 + +VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" +VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" + FILESYSTEM_DISK=s3 QUEUE_CONNECTION=database diff --git a/app/Events/ChatMessageDeleted.php b/app/Events/ChatMessageDeleted.php new file mode 100644 index 0000000..f7ba4b8 --- /dev/null +++ b/app/Events/ChatMessageDeleted.php @@ -0,0 +1,41 @@ +messageId = $messageId; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + new Channel('global-chat'), + ]; + } + + public function broadcastAs(): string + { + return 'message.deleted'; + } +} diff --git a/app/Events/ChatMessageSent.php b/app/Events/ChatMessageSent.php new file mode 100644 index 0000000..ad7f18c --- /dev/null +++ b/app/Events/ChatMessageSent.php @@ -0,0 +1,57 @@ +loadMissing(['user:id,name,username,image_path,institution', 'user.roles:id,name']); + + $this->message = [ + 'id' => $chatMessage->id, + 'content' => $chatMessage->content, + 'created_at' => $chatMessage->created_at->toIso8601String(), + 'user' => [ + 'id' => $chatMessage->user->id, + 'name' => $chatMessage->user->name, + 'username' => $chatMessage->user->username, + 'image_url' => $chatMessage->user->image_url, + 'institution' => $chatMessage->user->institution, + 'is_verified' => $chatMessage->user->is_verified, + 'roles' => $chatMessage->user->roles->pluck('name')->toArray(), + ], + ]; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + new Channel('global-chat'), + ]; + } + + public function broadcastAs(): string + { + return 'message.sent'; + } +} diff --git a/app/Http/Controllers/Admin/ChatSettingsController.php b/app/Http/Controllers/Admin/ChatSettingsController.php new file mode 100644 index 0000000..4a92065 --- /dev/null +++ b/app/Http/Controllers/Admin/ChatSettingsController.php @@ -0,0 +1,47 @@ + [ + 'enabled' => (bool) AppSetting::get('global_chat_enabled', true), + 'audience' => AppSetting::get('global_chat_audience', 'verified_members'), + 'cooldown_seconds' => (int) AppSetting::get('global_chat_cooldown_seconds', 30), + ], + 'totalMessages' => ChatMessage::count(), + 'recentMessagesCount' => ChatMessage::where('created_at', '>=', now()->subHours(24))->count(), + ]); + } + + public function update(Request $request) + { + $validated = $request->validate([ + 'enabled' => ['required', 'boolean'], + 'audience' => ['required', 'string', 'in:verified_members,all,disabled'], + 'cooldown_seconds' => ['required', 'integer', 'min:0', 'max:3600'], + ]); + + AppSetting::set('global_chat_enabled', $validated['enabled'], 'boolean'); + AppSetting::set('global_chat_audience', $validated['audience'], 'string'); + AppSetting::set('global_chat_cooldown_seconds', $validated['cooldown_seconds'], 'integer'); + + return back()->with('success', 'Global chat settings updated successfully.'); + } + + public function clearMessages() + { + ChatMessage::truncate(); + + return back()->with('success', 'All chat messages have been cleared.'); + } +} diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index 0d19664..a941f73 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Models\AppSetting; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -13,7 +14,12 @@ class DashboardController extends Controller { public function index() { - return Inertia::render('admin/Dashboard'); + return Inertia::render('admin/Dashboard', [ + 'chatSettings' => [ + 'enabled' => AppSetting::get('global_chat_enabled', true), + 'audience' => AppSetting::get('global_chat_audience', 'verified_members'), + ], + ]); } public function analytics(Request $request) diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php new file mode 100644 index 0000000..58f8829 --- /dev/null +++ b/app/Http/Controllers/ChatController.php @@ -0,0 +1,188 @@ +user(); + + // Get Chat Config Status + $isEnabled = AppSetting::get('global_chat_enabled', true); + $audience = AppSetting::get('global_chat_audience', 'verified_members'); // 'verified_members', 'all', 'disabled' + $cooldownSeconds = (int) AppSetting::get('global_chat_cooldown_seconds', 30); + + // Determine if user can post + $canPost = false; + $reason = null; + + if (! $isEnabled || $audience === 'disabled') { + $reason = 'Global chat is currently disabled for maintenance.'; + } elseif (! $user) { + $reason = 'Please sign in to join the conversation.'; + } elseif ($audience === 'verified_members') { + if ($user->is_verified || $user->can('view admin')) { + $canPost = true; + } else { + $reason = 'Global chat is currently in beta for verified members and contributors.'; + } + } elseif ($audience === 'all') { + $canPost = true; + } + + // Fetch last 200 messages in chronological order + $messages = ChatMessage::with(['user:id,name,username,image_path,institution', 'user.roles:id,name']) + ->latest('id') + ->take(200) + ->get() + ->reverse() + ->values() + ->map(fn (ChatMessage $msg) => [ + 'id' => $msg->id, + 'content' => $msg->content, + 'created_at' => $msg->created_at->toIso8601String(), + 'user' => [ + 'id' => $msg->user->id, + 'name' => $msg->user->name, + 'username' => $msg->user->username, + 'image_url' => $msg->user->image_url, + 'institution' => $msg->user->institution, + 'is_verified' => $msg->user->is_verified, + 'roles' => $msg->user->roles->pluck('name')->toArray(), + ], + ]); + + if (! $request->wantsJson() && ! $request->is('api/*')) { + return Inertia::render('Chat/Index', [ + 'chatState' => [ + 'enabled' => (bool) $isEnabled, + 'audience' => $audience, + 'cooldown_seconds' => $cooldownSeconds, + 'can_post' => $canPost, + 'reason' => $reason, + 'can_delete' => (bool) $user?->can('manage chat'), + 'messages' => $messages, + 'pusher_key' => config('broadcasting.connections.pusher.key'), + 'pusher_cluster' => config('broadcasting.connections.pusher.options.cluster', 'ap2'), + ], + ]); + } + + return response()->json([ + 'enabled' => (bool) $isEnabled, + 'audience' => $audience, + 'cooldown_seconds' => $cooldownSeconds, + 'can_post' => $canPost, + 'reason' => $reason, + 'can_delete' => (bool) $user?->can('manage chat'), + 'messages' => $messages, + 'pusher_key' => config('broadcasting.connections.pusher.key'), + 'pusher_cluster' => config('broadcasting.connections.pusher.options.cluster', 'ap2'), + ]); + } + + public function store(Request $request) + { + $user = $request->user(); + abort_unless($user, 401, 'Unauthenticated'); + + $isEnabled = AppSetting::get('global_chat_enabled', true); + $audience = AppSetting::get('global_chat_audience', 'verified_members'); + $cooldownSeconds = (int) AppSetting::get('global_chat_cooldown_seconds', 30); + + // Check if chat is enabled + if (! $isEnabled || $audience === 'disabled') { + return response()->json(['message' => 'Global chat is currently disabled.'], 403); + } + + // Check audience permission + if ($audience === 'verified_members' && ! $user->is_verified && ! $user->can('view admin')) { + return response()->json(['message' => 'Global chat is currently restricted to verified members.'], 403); + } + + // Configurable rate limiter per user (bypass for admins/staff) + $rateLimitKey = "chat-send:{$user->id}"; + if (! $user->can('view admin') && $cooldownSeconds > 0 && RateLimiter::tooManyAttempts($rateLimitKey, 1)) { + $seconds = RateLimiter::availableIn($rateLimitKey); + + return response()->json([ + 'message' => "Please wait {$seconds} seconds before sending another message.", + 'retry_after' => $seconds, + ], 429); + } + + $validated = $request->validate([ + 'content' => ['required', 'string', 'max:280'], + ]); + + $content = trim($validated['content']); + + $message = ChatMessage::create([ + 'user_id' => $user->id, + 'content' => $content, + ]); + + // Record rate limit for configured cooldown seconds + if ($cooldownSeconds > 0) { + RateLimiter::hit($rateLimitKey, $cooldownSeconds); + } + + // Keep rolling buffer of latest 200 messages in DB + ChatMessage::pruneOldMessages(200); + + // Broadcast to Pusher Channels + try { + broadcast(new ChatMessageSent($message))->toOthers(); + } catch (\Throwable $e) { + // Log without failing response + } + + $message->loadMissing(['user:id,name,username,image_path,institution', 'user.roles:id,name']); + + return response()->json([ + 'id' => $message->id, + 'content' => $message->content, + 'created_at' => $message->created_at->toIso8601String(), + 'user' => [ + 'id' => $message->user->id, + 'name' => $message->user->name, + 'username' => $message->user->username, + 'image_url' => $message->user->image_url, + 'institution' => $message->user->institution, + 'is_verified' => $message->user->is_verified, + 'roles' => $message->user->roles->pluck('name')->toArray(), + ], + ], 201); + } + + public function destroy(Request $request, ChatMessage $message) + { + $user = $request->user(); + abort_unless($user, 401); + + // Can delete if own message or has manage chat permission + if ($message->user_id !== $user->id && ! $user->can('manage chat')) { + abort(403, 'Unauthorized'); + } + + $messageId = $message->id; + $message->delete(); + + try { + broadcast(new ChatMessageDeleted($messageId))->toOthers(); + } catch (\Throwable $e) { + // Ignore + } + + return response()->json(['success' => true]); + } +} diff --git a/app/Models/AppSetting.php b/app/Models/AppSetting.php new file mode 100644 index 0000000..becc4db --- /dev/null +++ b/app/Models/AppSetting.php @@ -0,0 +1,50 @@ +first(); + if (! $setting) { + return $default; + } + + return match ($setting->type) { + 'boolean' => filter_var($setting->value, FILTER_VALIDATE_BOOLEAN), + 'integer' => (int) $setting->value, + 'json' => json_decode($setting->value, true), + default => $setting->value, + }; + }); + } + + public static function set(string $key, mixed $value, string $type = 'string'): static + { + $stringValue = match ($type) { + 'boolean' => $value ? '1' : '0', + 'json' => json_encode($value), + default => (string) $value, + }; + + $setting = static::updateOrCreate( + ['key' => $key], + ['value' => $stringValue, 'type' => $type] + ); + + Cache::forget("app_setting:{$key}"); + + return $setting; + } +} diff --git a/app/Models/ChatMessage.php b/app/Models/ChatMessage.php new file mode 100644 index 0000000..9f2f71f --- /dev/null +++ b/app/Models/ChatMessage.php @@ -0,0 +1,30 @@ +belongsTo(User::class); + } + + /** + * Keep only the latest N messages in the database. + */ + public static function pruneOldMessages(int $keepCount = 200): void + { + $cutoffId = static::latest('id')->skip($keepCount)->value('id'); + if ($cutoffId) { + static::where('id', '<=', $cutoffId)->delete(); + } + } +} diff --git a/composer.json b/composer.json index 7c5596c..08dc7c4 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "laravel/tinker": "^3.0", "laravel/wayfinder": "^0.1.14", "league/flysystem-aws-s3-v3": "^3.35", + "pusher/pusher-php-server": "^7.2", "spatie/laravel-permission": "^8.0", "spatie/laravel-sitemap": "^8.0" }, diff --git a/composer.lock b/composer.lock index 3e710f7..ee4e61e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "369efb10767cd56d1d38211be139f830", + "content-hash": "2f1c67214230a1f69d9b6ee549d832df", "packages": [ { "name": "aws/aws-crt-php", @@ -4352,6 +4352,69 @@ }, "time": "2026-05-23T13:41:31+00:00" }, + { + "name": "pusher/pusher-php-server", + "version": "7.3.0", + "source": { + "type": "git", + "url": "https://github.com/pusher/pusher-http-php.git", + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/058d8464246118110a341fc2e6e70c7c8b6a7f2c", + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "php": "^7.3|^8.0", + "psr/http-client": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pusher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Library for interacting with the Pusher REST API", + "keywords": [ + "events", + "messaging", + "php-pusher-server", + "publish", + "push", + "pusher", + "real time", + "real-time", + "realtime", + "rest", + "trigger" + ], + "support": { + "issues": "https://github.com/pusher/pusher-http-php/issues", + "source": "https://github.com/pusher/pusher-http-php/tree/7.3.0" + }, + "time": "2026-08-07T12:47:11+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 0000000..afb5eb9 --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,61 @@ + env('BROADCAST_CONNECTION', 'pusher'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over WebSockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER', 'ap2'), + 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'ap2').'.pusher.com', + 'port' => env('PUSHER_PORT', 443), + 'scheme' => env('PUSHER_SCHEME', 'https'), + 'encrypted' => true, + 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: + ], + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + + ], + +]; diff --git a/database/migrations/2026_08_27_124000_create_app_settings_table.php b/database/migrations/2026_08_27_124000_create_app_settings_table.php new file mode 100644 index 0000000..2301aca --- /dev/null +++ b/database/migrations/2026_08_27_124000_create_app_settings_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->string('type')->default('string'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('app_settings'); + } +}; diff --git a/database/migrations/2026_08_27_124018_create_chat_messages_table.php b/database/migrations/2026_08_27_124018_create_chat_messages_table.php new file mode 100644 index 0000000..01078d1 --- /dev/null +++ b/database/migrations/2026_08_27_124018_create_chat_messages_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('content', 280); + $table->timestamps(); + + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('chat_messages'); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index de050a7..b46a289 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -80,6 +80,11 @@ public function run(): void */ Permission::findOrCreate('clear cache'); + /* + * Global Chat management + */ + Permission::findOrCreate('manage chat'); + $admin->syncPermissions(Permission::all()); // Administrators have unrestricted access to all features. diff --git a/package-lock.json b/package-lock.json index c80633a..efed936 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "@vitejs/plugin-vue": "^6.0.0", "clsx": "^2.1.1", "concurrently": "^9.0.1", + "laravel-echo": "^2.4.0", "laravel-vite-plugin": "^3.1", "lucide-vue-next": "^1.0.0", "plyr": "^3.8.4", + "pusher-js": "^8.6.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", "typescript": "^5.2.2", @@ -6958,6 +6960,27 @@ "json-buffer": "3.0.1" } }, + "node_modules/laravel-echo": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz", + "integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "pusher-js": "*", + "socket.io-client": "*" + }, + "peerDependenciesMeta": { + "pusher-js": { + "optional": true + }, + "socket.io-client": { + "optional": true + } + } + }, "node_modules/laravel-precognition": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz", @@ -8017,6 +8040,15 @@ "node": ">=6" } }, + "node_modules/pusher-js": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz", + "integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9020,6 +9052,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 20bbcef..19db3aa 100644 --- a/package.json +++ b/package.json @@ -36,9 +36,11 @@ "@vitejs/plugin-vue": "^6.0.0", "clsx": "^2.1.1", "concurrently": "^9.0.1", + "laravel-echo": "^2.4.0", "laravel-vite-plugin": "^3.1", "lucide-vue-next": "^1.0.0", "plyr": "^3.8.4", + "pusher-js": "^8.6.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", "typescript": "^5.2.2", diff --git a/resources/js/components/NavBar.vue b/resources/js/components/NavBar.vue index a1529b9..567f574 100644 --- a/resources/js/components/NavBar.vue +++ b/resources/js/components/NavBar.vue @@ -18,6 +18,7 @@ import { Users, HeartHandshake, Search, + MessageCircle, } from 'lucide-vue-next'; import { computed, @@ -47,6 +48,7 @@ const canAccessAdmin = computed(() => page.props.auth?.can_access_admin); const currentUrl = computed(() => page.url); const isBlogsActive = computed(() => currentUrl.value.startsWith('/blogs')); +const isChatActive = computed(() => currentUrl.value.startsWith('/chat')); const isHomeActive = computed( () => currentUrl.value === '/' || @@ -293,6 +295,18 @@ onBeforeUnmount(() => { > Blogs + + + Global Chat + @@ -561,7 +575,11 @@ onBeforeUnmount(() => { v-else class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-black text-white dark:bg-indigo-500" > - {{ user.name.charAt(0).toUpperCase() }} + {{ + user.name + .charAt(0) + .toUpperCase() + }}

{ Blogs + + + + Global Chat +

@@ -824,8 +856,8 @@ onBeforeUnmount(() => { Sign out of your account?

- Are you sure you want to log out of HSCStack? You will need - to sign in again to access your account features. + Are you sure you want to log out of HSCStack? You will + need to sign in again to access your account features.

diff --git a/resources/js/layouts/AdminLayout.vue b/resources/js/layouts/AdminLayout.vue index c23de2c..9bf2d01 100644 --- a/resources/js/layouts/AdminLayout.vue +++ b/resources/js/layouts/AdminLayout.vue @@ -6,6 +6,7 @@ import { Bell, Book, Mail, + MessageCircle, } from 'lucide-vue-next'; import { computed, ref } from 'vue'; import DesktopSidebar from '@/components/admin/DesktopSidebar.vue'; @@ -29,6 +30,12 @@ const allNavigation = [ icon: Bell, permission: 'edit notice', }, + { + name: 'Global Chat', + to: '/admin/chat', + icon: MessageCircle, + permission: 'manage chat', + }, { name: 'Users', to: '/admin/users', diff --git a/resources/js/lib/echo.ts b/resources/js/lib/echo.ts new file mode 100644 index 0000000..195f150 --- /dev/null +++ b/resources/js/lib/echo.ts @@ -0,0 +1,38 @@ +import Echo from 'laravel-echo'; +import Pusher from 'pusher-js'; + +declare global { + interface Window { + Pusher: typeof Pusher; + Echo: Echo<'pusher'>; + } +} + +window.Pusher = Pusher; + +let echoInstance: Echo<'pusher'> | null = null; + +export function getEcho(key?: string, cluster?: string): Echo<'pusher'> | null { + if (echoInstance) { + return echoInstance; + } + + const pusherKey = key || import.meta.env.VITE_PUSHER_APP_KEY; + const pusherCluster = + cluster || import.meta.env.VITE_PUSHER_APP_CLUSTER || 'ap2'; + + if (!pusherKey) { + return null; + } + + echoInstance = new Echo({ + broadcaster: 'pusher', + key: pusherKey, + cluster: pusherCluster, + forceTLS: true, + }); + + window.Echo = echoInstance; + + return echoInstance; +} diff --git a/resources/js/pages/Chat/Index.vue b/resources/js/pages/Chat/Index.vue new file mode 100644 index 0000000..aad8c49 --- /dev/null +++ b/resources/js/pages/Chat/Index.vue @@ -0,0 +1,570 @@ + + + diff --git a/resources/js/pages/admin/ChatSettings.vue b/resources/js/pages/admin/ChatSettings.vue new file mode 100644 index 0000000..1d45157 --- /dev/null +++ b/resources/js/pages/admin/ChatSettings.vue @@ -0,0 +1,414 @@ + + + diff --git a/routes/admin.php b/routes/admin.php index 3ec263a..ab9d605 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1,6 +1,7 @@ name('emails.create'); Route::post('/emails/send', [AdminEmailController::class, 'store'])->name('emails.store'); }); + +// Chat Management & Settings +Route::middleware('permission:manage chat')->group(function () { + Route::get('/chat', [ChatSettingsController::class, 'edit'])->name('chat.edit'); + Route::post('/chat/settings', [ChatSettingsController::class, 'update'])->name('chat.settings.update'); + Route::post('/chat/clear', [ChatSettingsController::class, 'clearMessages'])->name('chat.clear'); +}); diff --git a/routes/web.php b/routes/web.php index 55a3d07..81af179 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\AboutUsController; use App\Http\Controllers\Admin\AuthController; use App\Http\Controllers\BlogController; +use App\Http\Controllers\ChatController; use App\Http\Controllers\NodeController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ResourceController; @@ -29,13 +30,19 @@ Route::post('/resources/{resource}/complete', [ResourceController::class, 'toggleComplete'])->name('resources.complete'); Route::post('/nodes/{node}/vote', [NodeController::class, 'vote'])->name('nodes.vote'); Route::post('/u/{user}/appreciate', [UserProfileController::class, 'toggleAppreciate'])->name('user.appreciate'); - - Route::get('/me', function (Request $request){ + + Route::get('/me', function (Request $request) { return redirect()->route('user.profile', ['username' => $request->user()->username]); })->name('me'); - + + // Global Chat Actions + Route::post('/api/chat/messages', [ChatController::class, 'store'])->name('chat.messages.store'); + Route::delete('/api/chat/messages/{message}', [ChatController::class, 'destroy'])->name('chat.messages.destroy'); }); +// Global Chat Messages List (Public Read) +Route::middleware('throttle:60,1')->get('/api/chat/messages', [ChatController::class, 'index'])->name('chat.messages.index'); + Route::prefix('admin') ->middleware(['throttle:45,1', 'auth', 'verified', 'permission:view admin']) ->name('admin.') @@ -66,6 +73,7 @@ Route::get('/blogs', [BlogController::class, 'index']); Route::get('/blogs/{blog}', [BlogController::class, 'show']); + Route::get('/chat', [ChatController::class, 'index'])->name('chat.index'); Route::get('/u/{username}', [UserProfileController::class, 'show'])->name('user.profile'); Route::get('/', [SubjectController::class, 'index'])