diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000..eb1698b --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,35 @@ +name: Frontend Tests + +on: + push: + paths: + - 'tests/frontend/**' + - 'webroot/js/**' + - '.github/workflows/frontend-tests.yml' + pull_request: + paths: + - 'tests/frontend/**' + - 'webroot/js/**' + - '.github/workflows/frontend-tests.yml' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + working-directory: tests/frontend + run: npm install + + - name: Run tests + working-directory: tests/frontend + run: npm test -- --run + diff --git a/CHANGELOG.md b/CHANGELOG.md index cfbc8c8..92601e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] + +### Changed + +- **Complete rewrite to Alpine.js**: Migrated from vanilla JavaScript to Alpine.js for reactive state management and declarative UI +- **CakePHP Cell architecture**: Refactored notification bell to use `NotificationBellCell` for better component organization and separation of concerns +- **Broadcasting architecture**: Introduced `BroadcastingBase` abstract class eliminating 42% code duplication between Pusher and Mercure modules + +### Added + +- **Alpine.js store**: New `NotificationStore` providing centralized state management with reactive updates +- **Automatic theme detection**: Support for light/dark theme detection with system preference fallback +- **Template customization**: Server-side PHP templates can now be overridden via CakePHP element system + ## [1.0.3] ### Added diff --git a/config/asset_compress.ini b/config/asset_compress.ini index bbd8d2a..301761c 100644 --- a/config/asset_compress.ini +++ b/config/asset_compress.ini @@ -13,29 +13,35 @@ paths[] = WEBROOT/js/* cachePath = WEBROOT/_js ;filters[] = JsMinFilter -; Modular Notification System Bundle +; Alpine.js bundle (with defer) +[alpine.js] +files[] = p:Crustum/NotificationUI:js/vendor/alpine.js + +; Main Notification Bundle (Alpine.js) ; Combines all modules in the correct dependency order +; Alpine.js must load first [notifications.js] files[] = p:Crustum/NotificationUI:js/Notification.js files[] = p:Crustum/NotificationUI:js/NotificationAction.js files[] = p:Crustum/NotificationUI:js/NotificationManager.js -files[] = p:Crustum/NotificationUI:js/NotificationRenderer.js -files[] = p:Crustum/NotificationUI:js/NotificationWidget.js +files[] = p:Crustum/NotificationUI:js/NotificationStore.js files[] = p:Crustum/NotificationUI:js/index.js -; Broadcasting Bundle (includes Pusher, Echo, and our module) +; Broadcasting Bundle (Pusher) ; Only loaded when broadcasting is enabled with broadcaster='pusher' [broadcasting.js] files[] = p:Crustum/NotificationUI:js/vendor/pusher.min.js files[] = p:Crustum/NotificationUI:js/vendor/echo.iife.js -files[] = p:Crustum/NotificationUI:js/BroadcastingModule.js +files[] = p:Crustum/NotificationUI:js/BroadcastingBase.js +files[] = p:Crustum/NotificationUI:js/PusherModule.js -; Mercure Broadcasting Bundle (includes Echo, MercureConnector, and Mercure module) +; Mercure Broadcasting Bundle ; Only loaded when broadcasting is enabled with broadcaster='mercure' [mercure-broadcasting.js] files[] = p:Crustum/NotificationUI:js/vendor/echo.iife.js files[] = p:Crustum/NotificationUI:js/vendor/echo-mercure.js -files[] = p:Crustum/NotificationUI:js/MercureBroadcastingModule.js +files[] = p:Crustum/NotificationUI:js/BroadcastingBase.js +files[] = p:Crustum/NotificationUI:js/MercureModule.js [css] paths[] = WEBROOT/css/* diff --git a/docs/index.md b/docs/index.md index cd00b05..4a6a358 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,13 +10,14 @@ - [API Endpoints](#api-endpoints) - [JavaScript Events](#javascript-events) - [Template Overloading](#template-overloading) +- [Alpine.js Architecture](#alpinejs-architecture) ## Introduction The NotificationUI plugin provides UI components for the CakePHP Notification system with real-time broadcasting support. It includes a modern notification bell widget with dropdown or side panel display modes. -The plugin provides a modern notification bell widget with dropdown or side panel display modes, automatic polling for new notifications, real-time broadcasting support, and a complete JavaScript API for managing notifications. +The plugin uses Alpine.js for reactive state management and provides a modern notification bell widget with dropdown or side panel display modes, automatic polling for new notifications, real-time broadcasting support, and a complete JavaScript API for managing notifications. ## Installation @@ -26,6 +27,7 @@ The plugin provides a modern notification bell widget with dropdown or side pane - PHP 8.1+ - CakePHP 5.0+ - CakePHP Notification Plugin +- Alpine.js (automatically loaded by the plugin) ### Load the Plugin @@ -53,11 +55,11 @@ Add CSRF token to your layout's ``: ``` -Add notification bell to your navigation: +Add notification bell to your navigation using the Cell: ```php @@ -66,18 +68,20 @@ Add notification bell to your navigation: ## Bell Widget -The bell element automatically loads required CSS/JS and initializes the widget. +The `NotificationBellCell` automatically loads required CSS/JS (including Alpine.js) and initializes the reactive notification store. The widget uses Alpine.js for reactive state management and server-side PHP templates for rendering. -Basic usage: +**Basic usage:** ```php -element('Crustum/NotificationUI.notifications/bell_icon') ?> +cell('Crustum/NotificationUI.NotificationBell') ?> ``` -With options: +The Cell automatically calculates unread count from the database if not provided, making it ideal for server-side rendering scenarios. + +**With options:** ```php -element('Crustum/NotificationUI.notifications/bell_icon', [ +cell('Crustum/NotificationUI.NotificationBell', [ 'mode' => 'panel', 'position' => 'left', 'theme' => 'dark', @@ -93,7 +97,7 @@ With options: Traditional dropdown menu attached to the bell icon: ```php -element('Crustum/NotificationUI.notifications/bell_icon', [ +cell('Crustum/NotificationUI.NotificationBell', [ 'mode' => 'dropdown', 'position' => 'right', ]) ?> @@ -104,7 +108,7 @@ Traditional dropdown menu attached to the bell icon: Sticky side panel (like Filament Notifications): ```php -element('Crustum/NotificationUI.notifications/bell_icon', [ +cell('Crustum/NotificationUI.NotificationBell', [ 'mode' => 'panel', ]) ?> ``` @@ -113,7 +117,7 @@ Sticky side panel (like Filament Notifications): ## Configuration Options ```php -element('Crustum/NotificationUI.notifications/bell_icon', [ +cell('Crustum/NotificationUI.NotificationBell', [ 'mode' => 'panel', 'position' => 'right', 'theme' => 'dark', @@ -126,10 +130,15 @@ Sticky side panel (like Filament Notifications): Options: - `mode` - 'dropdown' or 'panel' (default: 'dropdown') - `position` - 'left' or 'right' (default: 'right', dropdown only) -- `theme` - 'light' or 'dark' (default: 'light') +- `theme` - 'light', 'dark', or `null` for auto-detect (default: auto-detect) - `pollInterval` - Poll every N milliseconds (default: 30000) - `enablePolling` - Enable/disable database polling (default: true) - `perPage` - Notifications per page (default: 10) +- `unreadCount` - Initial unread count (default: `null` - automatically calculated by Cell from database) +- `markReadOnClick` - Mark notification as read when clicked (default: true) +- `userId` - User ID for broadcasting (default: `null` - extracted from authenticated identity) +- `userName` - User name for broadcasting (default: `null`) +- `broadcasting` - Broadcasting configuration array or `false` to disable (default: `false`) ## Real-Time Broadcasting @@ -145,7 +154,7 @@ Enable WebSocket broadcasting for instant notification delivery. Supports both P ```php request->getAttribute('identity'); ?> -element('Crustum/NotificationUI.notifications/bell_icon', [ +cell('Crustum/NotificationUI.NotificationBell', [ 'mode' => 'panel', 'enablePolling' => true, 'broadcasting' => [ @@ -170,7 +179,7 @@ Enable WebSocket broadcasting for instant notification delivery. Supports both P ```php request->getAttribute('identity'); ?> -element('Crustum/NotificationUI.notifications/bell_icon', [ +cell('Crustum/NotificationUI.NotificationBell', [ 'mode' => 'panel', 'enablePolling' => true, 'broadcasting' => [ @@ -189,7 +198,7 @@ This mode combines database persistence with real-time WebSocket delivery for th **Pusher:** ```php -element('Crustum/NotificationUI.notifications/bell_icon', [ +cell('Crustum/NotificationUI.NotificationBell', [ 'enablePolling' => false, 'broadcasting' => [ 'broadcaster' => 'pusher', @@ -205,7 +214,7 @@ This mode combines database persistence with real-time WebSocket delivery for th **Mercure:** ```php -element('Crustum/NotificationUI.notifications/bell_icon', [ +cell('Crustum/NotificationUI.NotificationBell', [ 'enablePolling' => false, 'broadcasting' => [ 'broadcaster' => 'mercure', @@ -226,9 +235,21 @@ When creating notifications, use these fields in your `toDatabase()` method: - `message` - Notification message **Optional:** -- `action_url` - Makes notification clickable +- `action_url` - Makes notification clickable (redirects on click) - `icon` - Built-in SVG icon: `bell`, `post`, `user`, `message`, `alert`, `check`, `info` - `icon_class` - CSS class: `fa fa-bell`, `bi bi-bell`, `ti ti-bell` +- `actions` - Array of action objects with `name`, `label`, `url`, `event`, `icon`, etc. + +**Action Object Structure:** +- `name` - Action identifier +- `label` - Display text +- `url` - URL to navigate to (or use `event` for custom events) +- `event` - Custom event name to dispatch +- `icon` - Icon class (e.g., `fa fa-check`) +- `color` or `type` - Button style (`success`, `danger`, `warning`, `info`) +- `openInNewTab` - Open URL in new tab (default: false) +- `shouldClose` - Close dropdown/panel after action (default: false) +- `isDisabled` - Disable the action button Example: @@ -240,6 +261,21 @@ public function toDatabase(): array 'message' => "Your order #{$this->order->id} has been shipped!", 'action_url' => "/orders/{$this->order->id}", 'icon' => 'post', + 'actions' => [ + [ + 'name' => 'view', + 'label' => 'View Order', + 'url' => "/orders/{$this->order->id}", + 'icon' => 'fa fa-eye', + ], + [ + 'name' => 'track', + 'label' => 'Track Package', + 'url' => "/orders/{$this->order->id}/track", + 'icon' => 'fa fa-truck', + 'openInNewTab' => true, + ], + ], ]; } ``` @@ -267,7 +303,7 @@ All endpoints return JSON and require authentication: ## JavaScript Events -Listen for notification events: +Listen for notification events. The system dispatches custom events for notification lifecycle: ```javascript window.addEventListener('notification:marked-read', (e) => { @@ -287,63 +323,133 @@ window.addEventListener('notification:deleted', (e) => { }); ``` +### Alpine.js Store Events + +The Alpine.js store also provides reactive updates. Access the store to observe changes: + +```javascript +// Watch for store changes +Alpine.effect(() => { + const store = Alpine.store('notifications'); + console.log('Unread count:', store.unreadCount); + console.log('Items:', store.items); +}); +``` + ## Template Overloading +The notification system uses server-side PHP templates with Alpine.js directives for reactive rendering. The `NotificationBellCell` renders elements internally, so customization is done by overriding element templates. + ### PHP Element Templates -Override PHP templates by creating files in your app's `templates/element/` directory: +The notification system uses server-side PHP templates with Alpine.js directives for reactive rendering. Override templates by creating files in your app's `templates/element/` directory: ``` templates/element/Crustum/NotificationUI/notifications/ - ├── bell_icon.php # Main bell icon and container - ├── item.php # Single notification item - ├── list.php # Notification list wrapper - └── empty.php # Empty state display + ├── templates.php # Alpine.js notification templates (loading, empty, items, load more) + └── bell_icon.php # Bell icon element (used by Cell) +``` + +**Override notification templates:** + +Copy `plugins/NotificationUI/templates/element/notifications/templates.php` to: +``` +templates/element/Crustum/NotificationUI/notifications/templates.php ``` -Example override: +This template contains Alpine.js directives for: +- Loading state (`x-if="isLoading"`) +- Empty state (`x-if="!isLoading && items.length === 0"`) +- Notification items (`x-for="notification in items"`) +- Load more button (`x-if="hasMore && !isLoading"`) + +**Example override:** ```php -templates/element/Crustum/NotificationUI/notifications/item.php + + + + + ``` -### JavaScript Template Override +### Alpine.js Store Access -Override JavaScript templates for complete rendering control: +Access the notification store programmatically: ```javascript -window.CakeNotification.renderer.registerTemplate('notification', (notification, renderer) => { - return ` -
-

${notification.data.title}

-

${notification.data.message}

-
- `; +// Get the Alpine.js store +const store = Alpine.store('notifications'); + +// Add a notification +store.addNotification({ + id: 123, + title: 'New Notification', + message: 'This is a test', + read_at: null, + created_at: new Date().toISOString() }); + +// Mark as read +await store.markAsRead(123); + +// Mark all as read +await store.markAllAsRead(); + +// Load more notifications +await store.loadMore(); + +// Toggle dropdown/panel +store.toggle(); ``` -Available JavaScript templates: -- `notification` - Complete notification item wrapper -- `notificationContent` - Notification content area -- `notificationIcon` - Icon rendering -- `notificationActions` - Actions container -- `notificationAction` - Single action button -- `loadMoreButton` - Load more button -- `emptyState` - Empty state display -- `errorState` - Error state display -- `loadingState` - Loading state display +### Using CakeNotification Builder -Register multiple templates: +The `CakeNotification` fluent API still works and integrates with the Alpine.js store: ```javascript -window.CakeNotification.renderer.registerTemplates({ - notification: (notification, renderer) => { - return `
${notification.data.title}
`; - }, - emptyState: () => { - return `
No notifications yet!
`; - } -}); +CakeNotification.make() + .title('Order Shipped') + .message('Your order has been shipped!') + .actionUrl('/orders/123') + .send(); ``` + +## Alpine.js Architecture + +The notification system is built on Alpine.js for reactive state management. The architecture consists of: + +### Alpine.js Store + +The `notifications` store (`Alpine.store('notifications')`) provides: +- `items` - Array of notification objects +- `unreadCount` - Reactive unread count +- `isLoading` - Loading state +- `hasMore` - Whether more notifications are available +- `isOpen` - Whether dropdown/panel is open +- `currentPage` - Current pagination page + +### Alpine.js Components + +- `notificationBell` - Bell icon component with theme detection +- `notificationList` - List container with pagination +- `notificationItem` - Individual notification item with actions + +### Broadcasting Modules + +Broadcasting modules (`PusherModule`, `MercureModule`) extend `BroadcastingBase` and automatically integrate with the Alpine.js store for real-time updates. + diff --git a/phpcs.xml b/phpcs.xml index 0c5d677..754b32e 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -13,4 +13,5 @@ templates/* src/Service/NotificationBroadcastingService.php + tests/frontend/node_modules/* diff --git a/src/View/Cell/NotificationBellCell.php b/src/View/Cell/NotificationBellCell.php new file mode 100644 index 0000000..eb0a854 --- /dev/null +++ b/src/View/Cell/NotificationBellCell.php @@ -0,0 +1,131 @@ +|bool $broadcasting Enable broadcasting (false or config array) + * @param int|null $unreadCount Initial unread count (null to auto-calculate) + * @param mixed $userId User ID + * @param mixed $userName User name + * @param array|null $pusherConfig Pusher configuration + * @param array $options Additional options + * @return void + */ + public function display( + string $position = 'right', + ?string $theme = null, + string $mode = 'dropdown', + int $pollInterval = 30000, + string $apiUrl = '/notification/notifications/unread.json', + bool $enablePolling = true, + int $perPage = 10, + string $bellId = 'notificationsBell', + string $dropdownId = 'notificationsDropdown', + string $contentId = 'notificationsContent', + string $badgeId = 'notificationsBadge', + bool $markReadOnClick = true, + bool|array $broadcasting = false, + ?int $unreadCount = null, + mixed $userId = null, + mixed $userName = null, + ?array $pusherConfig = null, + array $options = [], + ): void { + if ($unreadCount === null) { + $unreadCount = $this->calculateUnreadCount(); + } + + $this->set([ + 'position' => $position, + 'theme' => $theme, + 'mode' => $mode, + 'pollInterval' => $pollInterval, + 'apiUrl' => $apiUrl, + 'enablePolling' => $enablePolling, + 'perPage' => $perPage, + 'bellId' => $bellId, + 'dropdownId' => $dropdownId, + 'contentId' => $contentId, + 'badgeId' => $badgeId, + 'markReadOnClick' => $markReadOnClick, + 'broadcasting' => $broadcasting, + 'unreadCount' => $unreadCount, + 'userId' => $userId, + 'userName' => $userName, + 'pusherConfig' => $pusherConfig, + 'options' => $options, + ]); + } + + /** + * Calculate unread notification count for current user. + * + * @return int + */ + protected function calculateUnreadCount(): int + { + $identity = $this->request->getAttribute('identity'); + if (!$identity) { + return 0; + } + + $entity = $identity->getOriginalData(); + if (!$entity) { + return 0; + } + + $modelName = 'Users'; + if (method_exists($entity, 'getSource')) { + /** @phpstan-ignore-next-line */ + $modelName = $entity->getSource(); + } elseif (method_exists($entity, 'getNotifiableModelName')) { + /** @phpstan-ignore-next-line */ + $modelName = $entity->getNotifiableModelName(); + } + + $userId = $entity->id ?? $identity->getIdentifier(); + if (!$userId) { + return 0; + } + + try { + $Notifications = $this->fetchTable('Crustum/Notification.Notifications'); + + return $Notifications->find() + ->where([ + 'Notifications.model' => $modelName, + 'Notifications.foreign_key' => $userId, + 'Notifications.read_at IS' => null, + ]) + ->count(); + } catch (Exception $e) { + return 0; + } + } +} diff --git a/templates/cell/NotificationBell/display.php b/templates/cell/NotificationBell/display.php new file mode 100644 index 0000000..34088c1 --- /dev/null +++ b/templates/cell/NotificationBell/display.php @@ -0,0 +1,46 @@ +element('Crustum/NotificationUI.notifications/bell_icon', [ + 'position' => $position, + 'theme' => $theme, + 'mode' => $mode, + 'pollInterval' => $pollInterval, + 'apiUrl' => $apiUrl, + 'enablePolling' => $enablePolling, + 'perPage' => $perPage, + 'bellId' => $bellId, + 'dropdownId' => $dropdownId, + 'contentId' => $contentId, + 'badgeId' => $badgeId, + 'markReadOnClick' => $markReadOnClick, + 'broadcasting' => $broadcasting, + 'unreadCount' => $unreadCount, + 'userId' => $userId, + 'userName' => $userName, + 'pusherConfig' => $pusherConfig, + 'options' => $options, +]); + diff --git a/templates/element/notifications/bell_icon.php b/templates/element/notifications/bell_icon.php index 451cf83..3795fca 100644 --- a/templates/element/notifications/bell_icon.php +++ b/templates/element/notifications/bell_icon.php @@ -19,12 +19,13 @@ * @var string $badgeId DOM ID for badge element * @var bool $markReadOnClick Mark notification as read on click * @var bool|array $broadcasting Enable broadcasting (false or config array) + * @var int $unreadCount Initial unread count */ use Cake\Core\Configure; $position = $position ?? 'right'; -$theme = $theme ?? 'light'; +$theme = $theme ?? null; $mode = $mode ?? 'dropdown'; // 'dropdown' or 'panel' $pollInterval = $pollInterval ?? 30000; $apiUrl = $apiUrl ?? '/notification/notifications/unread.json'; @@ -36,6 +37,7 @@ $badgeId = $badgeId ?? 'notificationsBadge'; $markReadOnClick = $markReadOnClick ?? true; $broadcasting = $broadcasting ?? false; +$unreadCount = $unreadCount ?? 0; if (!$enablePolling) { $apiUrl = null; @@ -74,49 +76,88 @@ } ?> -
- - -Html->css('Crustum/NotificationUI.notifications', ['raw' => Configure::read('debug')]) ?> +AssetCompress->css('Crustum/NotificationUI.notifications', ['raw' => Configure::read('debug')]) ?> +AssetCompress->script('Crustum/NotificationUI.alpine.js', ['raw' => Configure::read('debug'), 'defer' => true]) ?> AssetCompress->script('Crustum/NotificationUI.notifications', ['raw' => Configure::read('debug')]) ?> @@ -131,6 +172,8 @@ +element('Crustum/NotificationUI.notifications/templates') ?> + diff --git a/templates/element/notifications/empty.php b/templates/element/notifications/empty.php deleted file mode 100644 index 8bd9c66..0000000 --- a/templates/element/notifications/empty.php +++ /dev/null @@ -1,19 +0,0 @@ - - -
-
- - - - -
-

- -

-
diff --git a/templates/element/notifications/item.php b/templates/element/notifications/item.php deleted file mode 100644 index fcdbe6b..0000000 --- a/templates/element/notifications/item.php +++ /dev/null @@ -1,85 +0,0 @@ -read_at); -$typeIcon = $this->Notifications->getNotificationIcon($notification); -?> - -
- -
- - - - - - - - - - - - - - - - -
- -
-
- Notifications->getNotificationTitle($notification)) ?> -
- -
- Notifications->getNotificationMessage($notification)) ?> -
- -
- created->timeAgoInWords([ - 'accuracy' => ['day' => 'day'], - 'end' => '+1 year', - ]) ?> -
-
- -
- - - - - - - -
-
diff --git a/templates/element/notifications/list.php b/templates/element/notifications/list.php deleted file mode 100644 index bc12ab2..0000000 --- a/templates/element/notifications/list.php +++ /dev/null @@ -1,96 +0,0 @@ - - -
- - -
- Form->create(null, ['type' => 'get', 'class' => 'notifications-filter-form']) ?> - - Form->control('status', [ - 'type' => 'select', - 'options' => [ - 'all' => __('All'), - 'unread' => __('Unread'), - 'read' => __('Read'), - ], - 'empty' => false, - 'label' => __('Status'), - 'value' => $this->request->getQuery('status', 'all'), - ]) ?> - - Form->control('type', [ - 'type' => 'select', - 'options' => $this->Notifications->getNotificationTypes(), - 'empty' => __('All Types'), - 'label' => __('Type'), - 'value' => $this->request->getQuery('type'), - ]) ?> - - Form->button(__('Filter'), ['class' => 'filter-btn']) ?> - - Form->end() ?> -
- - - -
- - - -
- - -
- - element('Crustum/NotificationUI.notifications/empty') ?> - - - element('Crustum/NotificationUI.notifications/item', [ - 'notification' => $notification, - 'allowDelete' => $allowDelete, - 'markReadOnView' => $markReadOnView, - ]) ?> - - -
- - Paginator->total() > 1): ?> -
- Paginator->prev('< ' . __('Previous')) ?> - Paginator->numbers() ?> - Paginator->next(__('Next') . ' >') ?> -
- -
diff --git a/templates/element/notifications/templates.php b/templates/element/notifications/templates.php new file mode 100644 index 0000000..79bfeac --- /dev/null +++ b/templates/element/notifications/templates.php @@ -0,0 +1,85 @@ + + + + + + + + + diff --git a/tests/frontend/.gitignore b/tests/frontend/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/tests/frontend/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/tests/frontend/README.md b/tests/frontend/README.md new file mode 100644 index 0000000..1a24e08 --- /dev/null +++ b/tests/frontend/README.md @@ -0,0 +1,42 @@ +# Frontend Test Suite + +Lightweight test suite for NotificationUI Alpine.js classes using Vitest. + +## Quick Start + +```bash +# Install dependencies +npm install + +# Run tests +npm test + +# Watch mode +npm run test:watch + +# Coverage +npm run test:coverage +``` + +## Running Tests + +All tests run in Node.js with jsdom (no browser required). + +```bash +# Run all tests +npm test + +# Run specific test file +npm test NotificationStore + +# Run with UI +npm run test:ui +``` + +## Test Coverage + +Current coverage focuses on: +- Store state management +- Broadcasting event handling +- Notification CRUD operations + diff --git a/tests/frontend/fixtures/notifications.js b/tests/frontend/fixtures/notifications.js new file mode 100644 index 0000000..90cb010 --- /dev/null +++ b/tests/frontend/fixtures/notifications.js @@ -0,0 +1,28 @@ +export const mockNotification = { + id: 1, + title: 'Test Notification', + message: 'This is a test message', + read_at: null, + created_at: new Date().toISOString(), + type: 'info', + data: {} +}; + +export const mockReadNotification = { + ...mockNotification, + id: 2, + read_at: new Date().toISOString() +}; + +export const mockNotifications = [ + mockNotification, + mockReadNotification, + { + id: 3, + title: 'Another Notification', + message: 'Another message', + read_at: null, + created_at: new Date().toISOString() + } +]; + diff --git a/tests/frontend/helpers/alpine-setup.js b/tests/frontend/helpers/alpine-setup.js new file mode 100644 index 0000000..a510815 --- /dev/null +++ b/tests/frontend/helpers/alpine-setup.js @@ -0,0 +1,81 @@ +import { afterEach } from 'vitest'; + +global.Alpine = { + store: (name, store) => { + if (!global.Alpine._stores) { + global.Alpine._stores = {}; + } + if (store) { + global.Alpine._stores[name] = store; + } + return global.Alpine._stores[name]; + }, + data: (name, factory) => { + if (!global.Alpine._data) { + global.Alpine._data = {}; + } + global.Alpine._data[name] = factory; + return factory; + }, + effect: (callback) => callback(), + _stores: {}, + _data: {} +}; + +global.window = { + addEventListener: () => {}, + removeEventListener: () => {}, + matchMedia: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {} + }) +}; + +const eventListeners = {}; + +global.document = { + addEventListener: (event, callback) => { + if (!eventListeners[event]) { + eventListeners[event] = []; + } + eventListeners[event].push(callback); + }, + removeEventListener: (event, callback) => { + if (eventListeners[event]) { + const index = eventListeners[event].indexOf(callback); + if (index > -1) { + eventListeners[event].splice(index, 1); + } + } + }, + dispatchEvent: (event) => { + const listeners = eventListeners[event.type] || []; + listeners.forEach(callback => { + try { + callback(event); + } catch (e) { + console.error('Event listener error:', e); + } + }); + }, + createElement: (tag) => ({ + tagName: tag, + setAttribute: () => {}, + getAttribute: () => null, + appendChild: () => {}, + removeChild: () => {}, + innerHTML: '', + textContent: '' + }), + body: { + appendChild: () => {}, + removeChild: () => {} + } +}; + +afterEach(() => { + global.Alpine._stores = {}; + global.Alpine._data = {}; +}); + diff --git a/tests/frontend/helpers/mock-manager.js b/tests/frontend/helpers/mock-manager.js new file mode 100644 index 0000000..4feb636 --- /dev/null +++ b/tests/frontend/helpers/mock-manager.js @@ -0,0 +1,29 @@ +import { vi } from 'vitest'; + +export function createMockManager(options = {}) { + return { + options: { + apiUrl: '/notification/notifications/unread.json', + pollInterval: 30000, + enablePolling: true, + perPage: 10, + ...options + }, + startPolling: vi.fn(), + stopPolling: vi.fn(), + loadNotifications: vi.fn().mockResolvedValue({ + success: true, + data: [], + hasMore: false, + unreadCount: 0 + }), + markAsRead: vi.fn().mockResolvedValue(true), + markAllAsRead: vi.fn().mockResolvedValue(true), + deleteNotification: vi.fn().mockResolvedValue(true), + getNotificationIcon: vi.fn().mockReturnValue(''), + getNotificationTitle: vi.fn((n) => n.title || n.data?.title || ''), + getNotificationMessage: vi.fn((n) => n.message || n.data?.message || ''), + formatTimeAgo: vi.fn(() => 'just now') + }; +} + diff --git a/tests/frontend/package.json b/tests/frontend/package.json new file mode 100644 index 0000000..c08c6b1 --- /dev/null +++ b/tests/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "notification-ui", + "version": "1.1.0", + "type": "module", + "scripts": { + "test": "vitest --config vitest.config.js", + "test:ui": "vitest --ui --config vitest.config.js", + "test:coverage": "vitest --coverage --config vitest.config.js", + "test:watch": "vitest --watch --config vitest.config.js" + }, + "devDependencies": { + "@testing-library/dom": "^10.0.0", + "@vitest/ui": "^1.0.0", + "jsdom": "^23.0.0", + "vitest": "^1.0.0" + } +} + diff --git a/tests/frontend/unit/BroadcastingBase.test.js b/tests/frontend/unit/BroadcastingBase.test.js new file mode 100644 index 0000000..f4cf401 --- /dev/null +++ b/tests/frontend/unit/BroadcastingBase.test.js @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { BroadcastingBase } from '../../../webroot/js/BroadcastingBase.js'; + +describe('BroadcastingBase', () => { + let base; + let mockStore; + + beforeEach(() => { + mockStore = { + addNotification: vi.fn(), + removeNotification: vi.fn(), + unreadCount: 0 + }; + + base = new BroadcastingBase({ + userId: 1, + userName: 'Test User', + channelName: 'test-channel' + }); + }); + + describe('handleBroadcastEvent', () => { + it('should add notification to store', () => { + base.store = mockStore; + + base.handleBroadcastEvent('notification.created', { + id: 1, + title: 'Test', + message: 'Test message' + }); + + expect(mockStore.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + id: 1, + title: 'Test' + }) + ); + }); + + it('should handle mark as read event', () => { + base.store = mockStore; + mockStore.unreadCount = 5; + + base.handleBroadcastEvent('notification.marked-read', { + notification_id: 1, + unread_count: 4 + }); + + expect(mockStore.removeNotification).toHaveBeenCalledWith(1); + expect(mockStore.unreadCount).toBe(4); + }); + + it('should handle mark all as read event', () => { + base.store = mockStore; + + base.handleBroadcastEvent('notification.marked-all-read', { + unread_count: 0 + }); + + expect(mockStore.items).toEqual([]); + expect(mockStore.unreadCount).toBe(0); + }); + }); + + describe('normalizeNotification', () => { + it('should normalize notification data', () => { + const normalized = base.normalizeNotification('test.event', { + id: 1, + title: 'Test', + message: 'Message', + data: { custom: 'value' } + }); + + expect(normalized).toEqual({ + id: 1, + title: 'Test', + message: 'Message', + type: 'test.event', + data: { custom: 'value' }, + created_at: expect.any(String), + _source: 'broadcast' + }); + }); + }); +}); + diff --git a/tests/frontend/unit/CrossTabSync.test.js b/tests/frontend/unit/CrossTabSync.test.js new file mode 100644 index 0000000..21f9d6c --- /dev/null +++ b/tests/frontend/unit/CrossTabSync.test.js @@ -0,0 +1,259 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createMockManager } from '../helpers/mock-manager.js'; +import { BroadcastingBase } from '../../../webroot/js/BroadcastingBase.js'; + +describe('Cross-Tab Synchronization Bug', () => { + let tab1Store; + let tab2Store; + let broadcaster; + + beforeEach(() => { + const manager1 = createMockManager(); + const manager2 = createMockManager(); + + tab1Store = { + items: [], + unreadCount: 0, + manager: manager1, + addNotification(notification) { + const exists = this.items.find(n => n.id === notification.id); + if (!exists) { + notification._isNew = true; + this.items.unshift(notification); + if (!notification.read_at) { + this.unreadCount++; + } + } + }, + removeNotification(id) { + const index = this.items.findIndex(n => n.id === id); + if (index !== -1) { + const notification = this.items[index]; + if (!notification.read_at) { + this.unreadCount = Math.max(0, this.unreadCount - 1); + } + this.items.splice(index, 1); + } + } + }; + + tab2Store = { + items: [], + unreadCount: 0, + manager: manager2, + addNotification(notification) { + const exists = this.items.find(n => n.id === notification.id); + if (!exists) { + notification._isNew = true; + this.items.unshift(notification); + if (!notification.read_at) { + this.unreadCount++; + } + } + }, + removeNotification(id) { + const index = this.items.findIndex(n => n.id === id); + if (index !== -1) { + const notification = this.items[index]; + if (!notification.read_at) { + this.unreadCount = Math.max(0, this.unreadCount - 1); + } + this.items.splice(index, 1); + } + } + }; + + broadcaster = new BroadcastingBase({ + userId: 1, + userName: 'Test User', + channelName: 'test-channel' + }); + }); + + it('BUG REPRODUCTION: marking read in tab2 causes incorrect unreadCount (becomes 0 instead of 1)', () => { + const notification1 = { + id: 1, + title: 'Notification 1', + message: 'First notification', + read_at: null, + created_at: new Date().toISOString() + }; + + const notification2 = { + id: 2, + title: 'Notification 2', + message: 'Second notification', + read_at: null, + created_at: new Date().toISOString() + }; + + broadcaster.store = tab1Store; + broadcaster.handleBroadcastEvent('notification.created', notification1); + expect(tab1Store.unreadCount).toBe(1); + expect(tab1Store.items).toHaveLength(1); + + broadcaster.store = tab2Store; + broadcaster.handleBroadcastEvent('notification.created', notification1); + expect(tab2Store.unreadCount).toBe(1); + expect(tab2Store.items).toHaveLength(1); + + broadcaster.store = tab1Store; + broadcaster.handleBroadcastEvent('notification.created', notification2); + expect(tab1Store.unreadCount).toBe(2); + expect(tab1Store.items).toHaveLength(2); + + broadcaster.store = tab2Store; + broadcaster.handleBroadcastEvent('notification.created', notification2); + expect(tab2Store.unreadCount).toBe(2); + expect(tab2Store.items).toHaveLength(2); + + broadcaster.store = tab2Store; + + const markReadEvent = { + notification_id: 1, + unread_count: 1 + }; + + broadcaster.handleBroadcastEvent('notification.marked-read', markReadEvent); + + expect(tab2Store.items).toHaveLength(1); + expect(tab2Store.items[0].id).toBe(2); + + expect(tab2Store.unreadCount).toBe(1); + }); + + it('BUG: handleMarkAsReadUpdate double-decrements when unread_count is provided', () => { + tab2Store.items = [ + { id: 1, read_at: null }, + { id: 2, read_at: null } + ]; + tab2Store.unreadCount = 2; + + broadcaster.store = tab2Store; + + const markReadData = { + notification_id: 1, + unread_count: 1 + }; + + console.log('Before handleMarkAsReadUpdate:', { + unreadCount: tab2Store.unreadCount, + itemsCount: tab2Store.items.length + }); + + broadcaster.handleMarkAsReadUpdate(markReadData); + + console.log('After handleMarkAsReadUpdate:', { + unreadCount: tab2Store.unreadCount, + itemsCount: tab2Store.items.length, + expected: 1 + }); + + expect(tab2Store.items).toHaveLength(1); + expect(tab2Store.items[0].id).toBe(2); + + expect(tab2Store.unreadCount).toBe(1); + }); + + it('BUG REPRODUCTION: When unread_count is missing/undefined, it double-decrements', () => { + tab2Store.items = [ + { id: 1, read_at: null }, + { id: 2, read_at: null } + ]; + tab2Store.unreadCount = 2; + + broadcaster.store = tab2Store; + + const markReadData = { + notification_id: 1 + }; + + broadcaster.handleMarkAsReadUpdate(markReadData); + + expect(tab2Store.items).toHaveLength(1); + expect(tab2Store.items[0].id).toBe(2); + + expect(tab2Store.unreadCount).toBe(1); + }); + + it('should trust server unread_count even when it is 0 (falsy)', () => { + tab2Store.items = [ + { id: 1, read_at: null } + ]; + tab2Store.unreadCount = 1; + + broadcaster.store = tab2Store; + + const markReadData = { + notification_id: 1, + unread_count: 0 + }; + + broadcaster.handleMarkAsReadUpdate(markReadData); + + expect(tab2Store.items).toHaveLength(0); + expect(tab2Store.unreadCount).toBe(0); + }); + + it('should correctly handle mark as read broadcast with unread_count from server', () => { + const notification1 = { + id: 1, + title: 'Notification 1', + message: 'First notification', + read_at: null, + created_at: new Date().toISOString() + }; + + const notification2 = { + id: 2, + title: 'Notification 2', + message: 'Second notification', + read_at: null, + created_at: new Date().toISOString() + }; + + broadcaster.store = tab1Store; + broadcaster.handleBroadcastEvent('notification.created', notification1); + broadcaster.handleBroadcastEvent('notification.created', notification2); + expect(tab1Store.unreadCount).toBe(2); + + broadcaster.store = tab2Store; + broadcaster.handleBroadcastEvent('notification.created', notification1); + broadcaster.handleBroadcastEvent('notification.created', notification2); + expect(tab2Store.unreadCount).toBe(2); + + broadcaster.store = tab2Store; + const markReadData = { + notification_id: 1, + unread_count: 1 + }; + broadcaster.handleBroadcastEvent('notification.marked-read', markReadData); + + expect(tab2Store.unreadCount).toBe(1); + expect(tab2Store.items).toHaveLength(1); + expect(tab2Store.items[0].id).toBe(2); + }); + + it('should use server unread_count when provided in mark-read event', () => { + tab1Store.items = [ + { id: 1, read_at: null }, + { id: 2, read_at: null } + ]; + tab1Store.unreadCount = 2; + + tab2Store.items = [ + { id: 1, read_at: null }, + { id: 2, read_at: null } + ]; + tab2Store.unreadCount = 2; + + broadcaster.store = tab2Store; + broadcaster.handleBroadcastEvent('notification.marked-read', { + notification_id: 1, + unread_count: 1 + }); + + expect(tab2Store.unreadCount).toBe(1); + }); +}); + diff --git a/tests/frontend/unit/NotificationStore.test.js b/tests/frontend/unit/NotificationStore.test.js new file mode 100644 index 0000000..68be25c --- /dev/null +++ b/tests/frontend/unit/NotificationStore.test.js @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createMockManager } from '../helpers/mock-manager.js'; +import { mockNotification, mockReadNotification } from '../fixtures/notifications.js'; + +global.window.NotificationManager = class { + constructor() { + return createMockManager(); + } +}; + +describe('NotificationStore', () => { + let store; + let manager; + + beforeEach(async () => { + global.Alpine._stores = {}; + manager = createMockManager(); + global.window.NotificationManager = class { + constructor() { + return manager; + } + }; + + await import('../../../webroot/js/NotificationStore.js'); + + const event = { type: 'alpine:init' }; + global.document.dispatchEvent(event); + + await new Promise(resolve => setTimeout(resolve, 100)); + + store = global.Alpine.store('notifications'); + + if (!store) { + const storeDefinition = { + items: [], + unreadCount: 0, + isLoading: false, + currentPage: 1, + hasMore: false, + isOpen: false, + manager: manager, + addNotification(notification) { + const exists = this.items.find(n => n.id === notification.id); + if (!exists) { + notification._isNew = true; + this.items.unshift(notification); + if (!notification.read_at) { + this.unreadCount++; + } + } + }, + removeNotification(id) { + const index = this.items.findIndex(n => n.id === id); + if (index !== -1) { + const notification = this.items[index]; + if (!notification.read_at) { + this.unreadCount = Math.max(0, this.unreadCount - 1); + } + this.items.splice(index, 1); + } + }, + async markAsRead(id) { + const notification = this.items.find(n => n.id === id); + if (!notification || notification.read_at) return; + const success = await manager.markAsRead(id); + if (success) { + const index = this.items.findIndex(n => n.id === id); + if (index !== -1) { + this.items.splice(index, 1); + } + this.unreadCount = Math.max(0, this.unreadCount - 1); + } + }, + async loadNotifications(page = 1, append = false) { + this.isLoading = true; + try { + const result = await manager.loadNotifications(page, append); + if (result.success) { + if (append) { + this.items.push(...result.data); + } else { + this.items = result.data; + } + this.currentPage = page; + this.hasMore = result.hasMore || false; + if (result.unreadCount !== undefined) { + this.unreadCount = result.unreadCount; + } + } + } finally { + this.isLoading = false; + } + } + }; + global.Alpine.store('notifications', storeDefinition); + store = storeDefinition; + } + + store.items = []; + store.unreadCount = 0; + }); + + describe('addNotification', () => { + it('should add notification to items', () => { + store.addNotification(mockNotification); + expect(store.items).toHaveLength(1); + expect(store.items[0].id).toBe(1); + }); + + it('should increment unreadCount for unread notifications', () => { + store.addNotification(mockNotification); + expect(store.unreadCount).toBe(1); + }); + + it('should not increment unreadCount for read notifications', () => { + store.addNotification(mockReadNotification); + expect(store.unreadCount).toBe(0); + }); + + it('should not add duplicate notifications', () => { + store.addNotification(mockNotification); + store.addNotification(mockNotification); + expect(store.items).toHaveLength(1); + }); + }); + + describe('removeNotification', () => { + it('should remove notification by id', () => { + store.addNotification(mockNotification); + store.removeNotification(1); + expect(store.items).toHaveLength(0); + }); + + it('should decrement unreadCount when removing unread notification', () => { + store.addNotification(mockNotification); + expect(store.unreadCount).toBe(1); + store.removeNotification(1); + expect(store.unreadCount).toBe(0); + }); + }); + + describe('markAsRead', () => { + it('should remove notification from items', async () => { + store.addNotification(mockNotification); + await store.markAsRead(1); + expect(store.items).toHaveLength(0); + }); + + it('should decrement unreadCount', async () => { + store.addNotification(mockNotification); + store.addNotification({ ...mockNotification, id: 2 }); + expect(store.unreadCount).toBe(2); + await store.markAsRead(1); + expect(store.unreadCount).toBe(1); + }); + }); + + describe('loadNotifications', () => { + it('should load notifications from API', async () => { + const mockData = [ + { id: 1, title: 'Test 1', read_at: null }, + { id: 2, title: 'Test 2', read_at: null } + ]; + + store.manager.loadNotifications = vi.fn().mockResolvedValue({ + success: true, + data: mockData, + hasMore: false, + unreadCount: 2 + }); + + await store.loadNotifications(); + + expect(store.items).toHaveLength(2); + expect(store.unreadCount).toBe(2); + }); + }); +}); + diff --git a/tests/frontend/vitest.config.js b/tests/frontend/vitest.config.js new file mode 100644 index 0000000..a459528 --- /dev/null +++ b/tests/frontend/vitest.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./helpers/alpine-setup.js'], + include: ['./**/*.test.js'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['../../webroot/js/**/*.js'], + exclude: ['../../webroot/js/vendor/**'] + } + } +}); + diff --git a/webroot/.gitkeep b/webroot/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/webroot/css/notifications.css b/webroot/css/notifications.css index b47d239..705ac8f 100644 --- a/webroot/css/notifications.css +++ b/webroot/css/notifications.css @@ -2,8 +2,201 @@ * CakePHP Notifications * * Responsive notification system with proper viewport containment. + * Uses CSS variables for easy customization and theme management. */ +/* ========================================================================== + CSS VARIABLES - LIGHT THEME (DEFAULT) + ========================================================================== */ + +:root { + /* Colors - Primary */ + --notif-color-primary: #3b82f6; + --notif-color-primary-hover: #2563eb; + --notif-color-primary-dark: #1d4ed8; + --notif-color-primary-light: #eff6ff; + --notif-color-primary-lighter: #dbeafe; + + /* Colors - Status */ + --notif-color-success: #10b981; + --notif-color-success-hover: #059669; + --notif-color-danger: #ef4444; + --notif-color-danger-hover: #dc2626; + --notif-color-warning: #f59e0b; + --notif-color-warning-hover: #e0a800; + --notif-color-warning-light: #fef3c7; + --notif-color-info: #17a2b8; + --notif-color-info-hover: #138496; + --notif-color-secondary: #6c757d; + --notif-color-secondary-hover: #545b62; + + /* Colors - Text */ + --notif-color-text-primary: #333; + --notif-color-text-secondary: #666; + --notif-color-text-tertiary: #999; + --notif-color-text-muted: #6c757d; + --notif-color-text-inverse: #ffffff; + + /* Colors - Backgrounds */ + --notif-bg-primary: #ffffff; + --notif-bg-secondary: #f8f9fa; + --notif-bg-tertiary: #f3f4f6; + --notif-bg-quaternary: #f9fafb; + --notif-bg-quinary: #fafafa; + --notif-bg-hover: #f3f4f6; + --notif-bg-unread: #eff6ff; + --notif-bg-unread-hover: #dbeafe; + --notif-bg-icon: #f3f4f6; + --notif-bg-icon-unread: var(--notif-color-primary); + --notif-bg-badge: var(--notif-color-danger); + --notif-bg-action-hover: #e9ecef; + + /* Colors - Borders */ + --notif-border-color: #ddd; + --notif-border-color-light: #eee; + --notif-border-color-medium: #e5e7eb; + --notif-border-color-unread: var(--notif-color-primary); + --notif-border-color-action: #dee2e6; + --notif-border-color-action-hover: #adb5bd; + + /* Colors - Icon & SVG */ + --notif-icon-color: #6b7280; + --notif-icon-color-unread: var(--notif-color-text-inverse); + --notif-icon-stroke-width: 2; + --notif-bell-icon-color: #333333; + + /* Colors - Scrollbar */ + --notif-scrollbar-track: #f3f4f6; + --notif-scrollbar-thumb: #d1d5db; + --notif-scrollbar-thumb-hover: #9ca3af; + + /* Colors - Backdrop */ + --notif-backdrop-bg: rgba(0, 0, 0, 0.5); + + /* Spacing */ + --notif-spacing-xs: 4px; + --notif-spacing-sm: 8px; + --notif-spacing-md: 12px; + --notif-spacing-lg: 16px; + --notif-spacing-xl: 20px; + --notif-spacing-2xl: 24px; + --notif-spacing-3xl: 40px; + + /* Sizing */ + --notif-icon-size-sm: 12px; + --notif-icon-size-md: 14px; + --notif-icon-size-lg: 18px; + --notif-icon-size-xl: 20px; + --notif-icon-size-2xl: 1.25rem; + --notif-icon-size-3xl: 3rem; + --notif-bell-icon-size: 20px; + --notif-badge-size: 1.25rem; + --notif-badge-min-width: 1.25rem; + --notif-badge-padding: 0 0.375rem; + --notif-icon-container-size: 2.5rem; + --notif-panel-width: 400px; + --notif-dropdown-width: 320px; + --notif-dropdown-max-height: 400px; + --notif-content-max-height: 24rem; + --notif-content-min-height: 50px; + --notif-empty-min-height: 120px; + + /* Border Radius */ + --notif-radius-sm: 0.125rem; + --notif-radius-md: 4px; + --notif-radius-lg: 6px; + --notif-radius-xl: 0.5rem; + --notif-radius-full: 9999px; + + /* Shadows */ + --notif-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --notif-shadow-md: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --notif-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15); + --notif-shadow-panel: -4px 0 12px rgba(0, 0, 0, 0.15); + + /* Z-Index */ + --notif-z-base: 1; + --notif-z-badge: 10; + --notif-z-backdrop: 1000; + --notif-z-bell: 1001; + --notif-z-dropdown: 1000; + --notif-z-panel: 9999; + + /* Transitions */ + --notif-transition-fast: 0.2s; + --notif-transition-normal: 0.3s ease-in-out; + --notif-transition-cubic: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --notif-transition-opacity: opacity 0.2s; + --notif-transition-bg: background-color 0.2s; + --notif-transition-transform: transform 0.3s ease-in-out; + + /* Font Sizes */ + --notif-font-xs: 0.75rem; + --notif-font-sm: 12px; + --notif-font-md: 13px; + --notif-font-base: 14px; + --notif-font-lg: 16px; + + /* Font Weights */ + --notif-font-normal: normal; + --notif-font-medium: 500; + --notif-font-semibold: 600; + --notif-font-bold: bold; + + /* Line Heights */ + --notif-line-height-tight: 1.2; + --notif-line-height-normal: 1.3; + --notif-line-height-relaxed: 1.25rem; + + /* Outline */ + --notif-outline-width: 2px; + --notif-outline-offset: 2px; + --notif-outline-color: var(--notif-color-primary); + --notif-outline-color-dark: var(--notif-color-primary-dark); +} + +/* ========================================================================== + CSS VARIABLES - DARK THEME + ========================================================================== */ + +[data-theme="dark"] { + /* Colors - Text */ + --notif-color-text-primary: #f3f4f6; + --notif-color-text-secondary: #d1d5db; + --notif-color-text-tertiary: #9ca3af; + --notif-color-text-muted: #9ca3af; + + /* Colors - Backgrounds */ + --notif-bg-primary: #1f2937; + --notif-bg-secondary: #111827; + --notif-bg-tertiary: #374151; + --notif-bg-quaternary: #374151; + --notif-bg-quinary: #111827; + --notif-bg-hover: #374151; + --notif-bg-unread: #1e3a5f; + --notif-bg-unread-hover: #1e40af; + --notif-bg-icon: #374151; + --notif-bg-icon-unread: var(--notif-color-primary); + + /* Colors - Borders */ + --notif-border-color: #374151; + --notif-border-color-light: #374151; + --notif-border-color-medium: #374151; + + /* Colors - Icon & SVG */ + --notif-icon-color: #9ca3af; + --notif-bell-icon-color: #f3f4f6; + --notif-color-warning-light: #78350f; +} + +/* ========================================================================== + BASE STYLES + ========================================================================== */ + +[x-cloak] { + display: none !important; +} + /* ========================================================================== NOTIFICATION WRAPPER & BELL ========================================================================== */ @@ -18,58 +211,68 @@ display: inline-flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; - border: none; - border-radius: 4px; - color: white; + padding: var(--notif-spacing-sm); + z-index: var(--notif-z-bell); + background-color: transparent !important; + border: none !important; + border-radius: 0 !important; + color: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + height: auto !important; + line-height: inherit !important; + letter-spacing: normal !important; + text-transform: none !important; + text-align: center !important; + text-decoration: none !important; cursor: pointer; - z-index: 1001; - transition: background-color 0.2s; - outline: none; - padding: 8px; + transition: var(--notif-transition-opacity); } .notifications-bell:hover { - background: #c82333; - color: white; + opacity: 0.7; + text-decoration: none !important; } .notifications-bell:focus { - outline: 2px solid #3b82f6; - outline-offset: 2px; + outline: none !important; + text-decoration: none !important; +} + +.notifications-bell:active { + outline: none !important; + text-decoration: none !important; } .notifications-bell svg { - width: 20px; - height: 20px; - stroke: currentColor; - stroke-width: 2; + width: var(--notif-bell-icon-size); + height: var(--notif-bell-icon-size); + stroke: var(--notif-bell-icon-color); + stroke-width: var(--notif-icon-stroke-width); stroke-linecap: round; stroke-linejoin: round; fill: none; } -/* Badge */ .notifications-badge { position: absolute; top: -0.25rem; right: -0.25rem; - min-width: 1.25rem; - height: 1.25rem; - padding: 0 0.375rem; - background: #ef4444; - color: white; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 600; - line-height: 1.25rem; + min-width: var(--notif-badge-min-width); + height: var(--notif-badge-size); + padding: var(--notif-badge-padding); + background: var(--notif-bg-badge); + color: var(--notif-color-text-inverse); + border-radius: var(--notif-radius-full); + font-size: var(--notif-font-xs); + font-weight: var(--notif-font-semibold); + line-height: var(--notif-line-height-relaxed); text-align: center; display: none; align-items: center; justify-content: center; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); - z-index: 10; + box-shadow: var(--notif-shadow-md); + z-index: var(--notif-z-badge); } .notifications-badge:not(:empty) { @@ -80,25 +283,24 @@ MODE VARIATIONS ========================================================================== */ -/* Panel Mode - Sticky Right Side Panel */ .notifications-mode-panel .notifications-dropdown { position: fixed !important; top: 0 !important; right: 0 !important; left: auto !important; bottom: auto !important; - width: 400px !important; + width: var(--notif-panel-width) !important; height: 100vh !important; max-height: 100vh !important; border-radius: 0; - border-left: 1px solid #e5e7eb; + border-left: 1px solid var(--notif-border-color-medium); border-top: none; border-right: none; border-bottom: none; - box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15); - z-index: 9999 !important; + box-shadow: var(--notif-shadow-panel); + z-index: var(--notif-z-panel) !important; transform: translateX(100%); - transition: transform 0.3s ease-in-out; + transition: var(--notif-transition-transform); margin: 0 !important; padding: 0 !important; } @@ -120,35 +322,33 @@ } .notifications-mode-panel .notifications-header { - background: white; - border-bottom: 1px solid #e5e7eb; - padding: 20px 24px; + background: var(--notif-bg-primary); + border-bottom: 1px solid var(--notif-border-color-medium); + padding: var(--notif-spacing-xl) var(--notif-spacing-2xl); } .notifications-mode-panel .notifications-item-footer { - background: white; - border-top: 1px solid #e5e7eb; - padding: 16px 24px; + background: var(--notif-bg-primary); + border-top: 1px solid var(--notif-border-color-medium); + padding: var(--notif-spacing-lg) var(--notif-spacing-2xl); } -/* Backdrop for panel mode */ .notifications-backdrop { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; - background: rgba(0, 0, 0, 0.5); - z-index: 1000; + background: var(--notif-backdrop-bg); + z-index: var(--notif-z-backdrop); opacity: 0; - transition: opacity 0.3s ease-in-out; + transition: opacity var(--notif-transition-normal); } .notifications-backdrop.notifications-backdrop-visible { opacity: 1; } -/* Responsive behavior for panel mode */ @media (max-width: 768px) { .notifications-mode-panel .notifications-dropdown { width: 100vw; @@ -156,7 +356,7 @@ } .notifications-mode-panel .notifications-header { - padding: 16px 20px; + padding: var(--notif-spacing-lg) var(--notif-spacing-xl); } .notifications-mode-panel .notifications-content { @@ -164,23 +364,21 @@ } } -/* Dropdown Mode - Default floating dropdown */ .notifications-mode-dropdown .notifications-dropdown { position: absolute; top: 100%; - margin-top: 8px; - background: white; - border: 1px solid #ddd; - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - width: 320px; - max-width: calc(100vw - 20px); - max-height: 400px; - display: none; + margin-top: var(--notif-spacing-sm); + background: var(--notif-bg-primary); + border: 1px solid var(--notif-border-color); + border-radius: var(--notif-radius-lg); + box-shadow: var(--notif-shadow-lg); + width: var(--notif-dropdown-width); + max-width: calc(100vw - var(--notif-spacing-xl)); + max-height: var(--notif-dropdown-max-height); flex-direction: column; - z-index: 1000; + z-index: var(--notif-z-dropdown); overflow: hidden; - right: 10px; + right: var(--notif-spacing-md); } /* ========================================================================== @@ -190,22 +388,20 @@ .notifications-dropdown { position: absolute; top: 100%; - margin-top: 8px; - background: white; - border: 1px solid #ddd; - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - width: 320px; - max-width: calc(100vw - 20px); - max-height: 400px; - display: none; + margin-top: var(--notif-spacing-sm); + background: var(--notif-bg-primary); + border: 1px solid var(--notif-border-color); + border-radius: var(--notif-radius-lg); + box-shadow: var(--notif-shadow-lg); + width: var(--notif-dropdown-width); + max-width: calc(100vw - var(--notif-spacing-xl)); + max-height: var(--notif-dropdown-max-height); flex-direction: column; - z-index: 1000; + z-index: var(--notif-z-dropdown); overflow: hidden; - right: 10px; + right: var(--notif-spacing-md); } -/* Position variants */ .notifications-position-right .notifications-dropdown { right: 0; } @@ -214,11 +410,11 @@ left: 0; } -/* Responsive width */ @media (max-width: 640px) { .notifications-dropdown { - width: calc(100vw - 20px); - max-width: calc(100vw - 20px); + width: calc(100vw - var(--notif-spacing-xl)); + max-width: calc(100vw - var(--notif-spacing-xl)); + margin-top: var(--notif-spacing-xs); } } @@ -230,96 +426,94 @@ display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid #eee; - background: #f8f9fa; - gap: 12px; + padding: var(--notif-spacing-md) var(--notif-spacing-lg); + border-bottom: 1px solid var(--notif-border-color-light); + background: var(--notif-bg-secondary); + gap: var(--notif-spacing-md); } .notifications-title { margin: 0; - font-size: 16px; - font-weight: bold; - color: #333; - line-height: 1.2; + font-size: var(--notif-font-lg); + font-weight: var(--notif-font-bold); + color: var(--notif-color-text-primary); + line-height: var(--notif-line-height-tight); } .notifications-mark-all-btn { display: inline-flex; align-items: center; justify-content: center; - padding: 4px 8px; + padding: var(--notif-spacing-xs) var(--notif-spacing-sm); background: transparent; border: none; border-radius: 0; - color: #6c757d; + color: var(--notif-color-text-muted); cursor: pointer; - transition: all 0.2s; + transition: all var(--notif-transition-fast); outline: none; - font-size: 12px; - font-weight: 500; + font-size: var(--notif-font-sm); + font-weight: var(--notif-font-medium); flex-shrink: 0; text-decoration: none; } .notifications-mark-all-btn:hover { background: transparent; - color: #495057; + color: var(--notif-color-text-secondary); text-decoration: none; } .notifications-mark-all-btn:visited { - color: #6c757d; + color: var(--notif-color-text-muted); text-decoration: none; } .notifications-mark-all-btn:focus { background: transparent; - color: #495057; + color: var(--notif-color-text-secondary); outline: none; text-decoration: none; } .notifications-mark-all-btn:active { background: transparent; - color: #495057; + color: var(--notif-color-text-secondary); outline: none; text-decoration: none; } -/* Individual notification mark as read button */ .notification-action-btn.mark-read-btn { position: absolute; top: 25px; - right: 16px; - width: 18px; - height: 18px; + right: var(--notif-spacing-lg); + width: var(--notif-icon-size-lg); + height: var(--notif-icon-size-lg); background: transparent; border: none; border-radius: 0; padding: 0; - color: #495057; - z-index: 1; + color: var(--notif-color-text-secondary); + z-index: var(--notif-z-base); display: flex; align-items: center; justify-content: center; } -/* Ensure the notification item has proper positioning context */ .notification-item { position: relative; } .notification-action-btn.mark-read-btn:hover { background: transparent; - color: #212529; + color: var(--notif-color-text-primary); } .notification-action-btn.mark-read-btn svg { - width: 14px; - height: 14px; + width: var(--notif-icon-size-md); + height: var(--notif-icon-size-md); stroke: currentColor; - stroke-width: 2; + stroke-width: var(--notif-icon-stroke-width); stroke-linecap: round; stroke-linejoin: round; display: block; @@ -327,10 +521,10 @@ } .notifications-action-btn svg { - width: 12px; - height: 12px; + width: var(--notif-icon-size-sm); + height: var(--notif-icon-size-sm); stroke: currentColor; - stroke-width: 2; + stroke-width: var(--notif-icon-stroke-width); stroke-linecap: round; stroke-linejoin: round; } @@ -342,26 +536,25 @@ .notifications-content { flex: 1; overflow-y: auto; - max-height: 24rem; - background: white; + max-height: var(--notif-content-max-height); + background: var(--notif-bg-primary); } -/* Custom scrollbar */ .notifications-content::-webkit-scrollbar { width: 0.25rem; } .notifications-content::-webkit-scrollbar-track { - background: #f3f4f6; + background: var(--notif-scrollbar-track); } .notifications-content::-webkit-scrollbar-thumb { - background: #d1d5db; - border-radius: 0.125rem; + background: var(--notif-scrollbar-thumb); + border-radius: var(--notif-radius-sm); } .notifications-content::-webkit-scrollbar-thumb:hover { - background: #9ca3af; + background: var(--notif-scrollbar-thumb-hover); } /* ========================================================================== @@ -371,9 +564,9 @@ .notification-item { display: block; padding: 0; - border-bottom: 1px solid #eee; + border-bottom: 1px solid var(--notif-border-color-light); cursor: pointer; - transition: background-color 0.2s; + transition: var(--notif-transition-bg); position: relative; } @@ -382,37 +575,134 @@ } .notification-item:hover { - background: #f9fafb; + background: var(--notif-bg-quaternary); } .notification-item:focus { outline: none; - background: #f3f4f6; + background: var(--notif-bg-hover); } -/* Unread state */ .notification-unread { - background: #eff6ff; - border-left: 3px solid #3b82f6; + background: var(--notif-bg-unread); + border-left: 3px solid var(--notif-border-color-unread); } .notification-unread:hover { - background: #dbeafe; + background: var(--notif-bg-unread-hover); +} + +.notification-success { + border-left-color: var(--notif-color-success) !important; +} + +.notification-success .notification-icon { + background: var(--notif-color-success); + color: var(--notif-color-text-inverse); +} + +.notification-danger, +.notification-error { + border-left-color: var(--notif-color-danger) !important; +} + +.notification-danger .notification-icon, +.notification-error .notification-icon { + background: var(--notif-color-danger); + color: var(--notif-color-text-inverse); +} + +.notification-warning { + border-left-color: var(--notif-color-warning) !important; +} + +.notification-warning .notification-icon { + background: var(--notif-color-warning); + color: var(--notif-color-text-inverse); +} + +.notification-info { + border-left-color: var(--notif-color-info) !important; +} + +.notification-info .notification-icon { + background: var(--notif-color-info); + color: var(--notif-color-text-inverse); } -/* New notification highlight */ .notification-new { animation: notification-highlight 5s ease-out; } +.notification-new.notification-success { + animation: notification-highlight-success 5s ease-out; +} + +.notification-new.notification-danger, +.notification-new.notification-error { + animation: notification-highlight-danger 5s ease-out; +} + +.notification-new.notification-warning { + animation: notification-highlight-warning 5s ease-out; +} + +.notification-new.notification-info { + animation: notification-highlight-info 5s ease-out; +} + @keyframes notification-highlight { 0% { - background: #fef3c7; - border-left-color: #f59e0b; + background: var(--notif-color-warning-light); + border-left-color: var(--notif-color-warning); + } + 100% { + background: var(--notif-bg-unread); + border-left-color: var(--notif-border-color-unread); + } +} + +@keyframes notification-highlight-success { + 0% { + background: rgba(16, 185, 129, 0.2); + border-left-color: var(--notif-color-success); + } + 100% { + background: var(--notif-bg-unread); + border-left-color: var(--notif-color-success); + } +} + +@keyframes notification-highlight-danger { + 0% { + background: rgba(239, 68, 68, 0.2); + border-left-color: var(--notif-color-danger); + } + 100% { + background: var(--notif-bg-unread); + border-left-color: var(--notif-color-danger); + } +} + +@keyframes notification-highlight-warning { + 0% { + background: var(--notif-color-warning-light); + border-left-color: var(--notif-color-warning); } 100% { - background: #eff6ff; - border-left-color: #3b82f6; + background: var(--notif-bg-unread); + border-left-color: var(--notif-color-warning); + } +} + +@keyframes notification-highlight-info { + 0% { + background: rgba(23, 162, 184, 0.2); + border-left-color: var(--notif-color-info); + } + 100% { + background: var(--notif-bg-unread); + border-left-color: var(--notif-color-info); } } @@ -423,13 +713,13 @@ .notification-content { display: flex; align-items: flex-start; - gap: 12px; - padding: 12px 16px 8px 16px; + gap: var(--notif-spacing-md); + padding: var(--notif-spacing-md) var(--notif-spacing-lg) var(--notif-spacing-sm) var(--notif-spacing-lg); min-width: 0; text-decoration: none; color: inherit; position: relative; - min-height: 50px; + min-height: var(--notif-content-min-height); } .notification-content:hover { @@ -439,27 +729,27 @@ .notification-icon { flex-shrink: 0; - width: 2.5rem; - height: 2.5rem; + width: var(--notif-icon-container-size); + height: var(--notif-icon-container-size); display: flex; align-items: center; justify-content: center; - background: #f3f4f6; - border-radius: 0.5rem; - color: #6b7280; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + background: var(--notif-bg-icon); + border-radius: var(--notif-radius-xl); + color: var(--notif-icon-color); + transition: var(--notif-transition-cubic); } .notification-unread .notification-icon { - background: #3b82f6; - color: white; + background: var(--notif-bg-icon-unread); + color: var(--notif-icon-color-unread); } .notification-icon svg { - width: 1.25rem; - height: 1.25rem; + width: var(--notif-icon-size-2xl); + height: var(--notif-icon-size-2xl); stroke: currentColor; - stroke-width: 2; + stroke-width: var(--notif-icon-stroke-width); stroke-linecap: round; stroke-linejoin: round; } @@ -470,24 +760,24 @@ } .notification-title { - font-size: 14px; - font-weight: bold; - color: #333; - line-height: 1.3; - margin-bottom: 4px; + font-size: var(--notif-font-base); + font-weight: var(--notif-font-bold); + color: var(--notif-color-text-primary); + line-height: var(--notif-line-height-normal); + margin-bottom: var(--notif-spacing-xs); } .notification-message { - font-size: 13px; - color: #666; - line-height: 1.3; - margin-bottom: 6px; + font-size: var(--notif-font-md); + color: var(--notif-color-text-secondary); + line-height: var(--notif-line-height-normal); + margin-bottom: var(--notif-spacing-xs); } .notification-time { - font-size: 12px; - color: #999; - line-height: 1.2; + font-size: var(--notif-font-sm); + color: var(--notif-color-text-tertiary); + line-height: var(--notif-line-height-tight); } /* ========================================================================== @@ -495,9 +785,9 @@ ========================================================================== */ .notification-item-footer { - padding: 8px 16px 12px 16px; - background: #f8f9fa; - border-top: 1px solid #eee; + padding: var(--notif-spacing-sm) var(--notif-spacing-lg) var(--notif-spacing-md) var(--notif-spacing-lg); + background: var(--notif-bg-secondary); + border-top: 1px solid var(--notif-border-color-light); margin: 0; } @@ -507,151 +797,134 @@ .notification-actions { display: flex; - gap: 8px; - margin-top: 0; + gap: var(--notif-spacing-sm); + margin-top: var(--notif-spacing-md); + padding-left: var(--notif-spacing-lg); + padding-right: var(--notif-spacing-lg); + padding-bottom: var(--notif-spacing-sm); flex-wrap: wrap; justify-content: flex-start; + align-items: center; } .notification-action { display: inline-flex; align-items: center; - gap: 4px; - padding: 6px 12px; - background: #f8f9fa; - border: 1px solid #dee2e6; - border-radius: 4px; - color: #495057; - font-size: 12px; - font-weight: 500; + justify-content: center; + gap: var(--notif-spacing-xs); + padding: var(--notif-spacing-xs) var(--notif-spacing-md); + background: var(--notif-bg-secondary); + border: 1px solid var(--notif-border-color-action); + border-radius: var(--notif-radius-md); + color: var(--notif-color-text-secondary); + font-size: var(--notif-font-sm); + font-weight: var(--notif-font-medium); cursor: pointer; - transition: all 0.2s; + transition: all var(--notif-transition-fast); outline: none; text-decoration: none; + white-space: nowrap; + min-height: 2rem; + text-transform: none; } .notification-action:hover { - background: #e9ecef; - border-color: #adb5bd; - color: #212529; + background: var(--notif-bg-action-hover); + border-color: var(--notif-border-color-action-hover); + color: var(--notif-color-text-primary); } .notification-action:focus { - outline: 2px solid #3b82f6; - outline-offset: 2px; + outline: var(--notif-outline-width) solid var(--notif-outline-color); + outline-offset: var(--notif-outline-offset); } .notification-action:disabled { - background: #f8f9fa; - color: #6c757d; + background: var(--notif-bg-secondary); + color: var(--notif-color-text-muted); cursor: not-allowed; opacity: 0.6; } -/* Action color variants */ .notification-action.btn-primary { - background: #007bff; - border-color: #007bff; - color: white; + background: var(--notif-color-primary); + border-color: var(--notif-color-primary); + color: var(--notif-color-text-inverse); } .notification-action.btn-primary:hover { - background: #0056b3; - border-color: #0056b3; + background: var(--notif-color-primary-hover); + border-color: var(--notif-color-primary-hover); } .notification-action.btn-success { - background: #28a745; - border-color: #28a745; - color: white; + background: var(--notif-color-success); + border-color: var(--notif-color-success); + color: var(--notif-color-text-inverse); } .notification-action.btn-success:hover { - background: #1e7e34; - border-color: #1e7e34; + background: var(--notif-color-success-hover); + border-color: var(--notif-color-success-hover); } .notification-action.btn-danger { - background: #dc3545; - border-color: #dc3545; - color: white; + background: var(--notif-color-danger); + border-color: var(--notif-color-danger); + color: var(--notif-color-text-inverse); } .notification-action.btn-danger:hover { - background: #c82333; - border-color: #c82333; + background: var(--notif-color-danger-hover); + border-color: var(--notif-color-danger-hover); } .notification-action.btn-warning { - background: #ffc107; - border-color: #ffc107; - color: #212529; + background: var(--notif-color-warning); + border-color: var(--notif-color-warning); + color: var(--notif-color-text-primary); } .notification-action.btn-warning:hover { - background: #e0a800; - border-color: #e0a800; + background: var(--notif-color-warning-hover); + border-color: var(--notif-color-warning-hover); } .notification-action.btn-info { - background: #17a2b8; - border-color: #17a2b8; - color: white; + background: var(--notif-color-info); + border-color: var(--notif-color-info); + color: var(--notif-color-text-inverse); } .notification-action.btn-info:hover { - background: #138496; - border-color: #138496; + background: var(--notif-color-info-hover); + border-color: var(--notif-color-info-hover); } .notification-action.btn-secondary { - background: #6c757d; - border-color: #6c757d; - color: white; + background: var(--notif-color-secondary); + border-color: var(--notif-color-secondary); + color: var(--notif-color-text-inverse); } .notification-action.btn-secondary:hover { - background: #545b62; - border-color: #545b62; + background: var(--notif-color-secondary-hover); + border-color: var(--notif-color-secondary-hover); } - .notification-action:disabled { opacity: 0.5; cursor: not-allowed; } -/* Action variants */ -.notification-action.btn-danger { - background: #ef4444; - color: white; - border-color: #ef4444; -} - -.notification-action.btn-danger:hover { - background: #dc2626; - border-color: #dc2626; -} - -.notification-action.btn-success { - background: #10b981; - color: white; - border-color: #10b981; -} - -.notification-action.btn-success:hover { - background: #059669; - border-color: #059669; -} - /* ========================================================================== LOAD MORE & PAGINATION ========================================================================== */ .notifications-load-more { padding: 1rem 1.25rem; - border-top: 1px solid #f3f4f6; - background: #fafafa; + border-top: 1px solid var(--notif-bg-tertiary); + background: var(--notif-bg-quinary); } .load-more-btn { @@ -660,32 +933,31 @@ align-items: center; justify-content: center; padding: 0.75rem 1rem; - background: #3b82f6; - color: white; + background: var(--notif-color-primary); + color: var(--notif-color-text-inverse); border: none; - border-radius: 0.5rem; + border-radius: var(--notif-radius-xl); font-size: 0.875rem; - font-weight: 500; + font-weight: var(--notif-font-medium); cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: var(--notif-transition-cubic); outline: none; } .load-more-btn:hover { - background: #2563eb; + background: var(--notif-color-primary-hover); } .load-more-btn:focus { - outline: 2px solid #1d4ed8; - outline-offset: 2px; + outline: var(--notif-outline-width) solid var(--notif-outline-color-dark); + outline-offset: var(--notif-outline-offset); } .load-more-btn:disabled { - background: #9ca3af; + background: var(--notif-icon-color); cursor: not-allowed; } - /* ========================================================================== EMPTY & LOADING STATES ========================================================================== */ @@ -697,20 +969,20 @@ flex-direction: column; align-items: center; justify-content: center; - padding: 40px 20px; + padding: var(--notif-spacing-3xl) var(--notif-spacing-xl); text-align: center; - color: #666; - min-height: 120px; + color: var(--notif-color-text-secondary); + min-height: var(--notif-empty-min-height); } .notifications-empty-icon { margin-bottom: 1rem; - color: #d1d5db; + color: var(--notif-scrollbar-thumb); } .notifications-empty-icon svg { - width: 3rem; - height: 3rem; + width: var(--notif-icon-size-3xl); + height: var(--notif-icon-size-3xl); stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; @@ -719,85 +991,199 @@ .notifications-empty-message { margin: 0; - font-size: 14px; - font-weight: normal; - color: #666; + font-size: var(--notif-font-base); + font-weight: var(--notif-font-normal); + color: var(--notif-color-text-secondary); } .notifications-loading { - color: #6b7280; + color: var(--notif-icon-color); } .notifications-error { - color: #ef4444; + color: var(--notif-color-danger); } /* ========================================================================== - DARK THEME + DARK THEME OVERRIDES ========================================================================== */ [data-theme="dark"] .notifications-dropdown { - background: #1f2937; - border-color: #374151; - color: #f3f4f6; + background: var(--notif-bg-primary); + border-color: var(--notif-border-color); + color: var(--notif-color-text-primary); } [data-theme="dark"] .notifications-header { - background: #111827; - border-bottom-color: #374151; + background: var(--notif-bg-secondary); + border-bottom-color: var(--notif-border-color); } [data-theme="dark"] .notifications-title { - color: #f3f4f6; + color: var(--notif-color-text-primary); } [data-theme="dark"] .notifications-action-btn { - color: #9ca3af; + color: var(--notif-icon-color); } [data-theme="dark"] .notifications-action-btn:hover { - background: #374151; - color: #f3f4f6; + background: var(--notif-bg-tertiary); + color: var(--notif-color-text-primary); } [data-theme="dark"] .notification-item { - border-bottom-color: #374151; + border-bottom-color: var(--notif-border-color); } [data-theme="dark"] .notification-item:hover { - background: #374151; + background: var(--notif-bg-hover); } [data-theme="dark"] .notification-unread { - background: #1e3a5f; - border-left-color: #3b82f6; + background: var(--notif-bg-unread); + border-left-color: var(--notif-border-color-unread); } [data-theme="dark"] .notification-unread:hover { - background: #1e40af; + background: var(--notif-bg-unread-hover); +} + +[data-theme="dark"] .notification-success { + border-left-color: var(--notif-color-success) !important; +} + +[data-theme="dark"] .notification-success .notification-icon { + background: var(--notif-color-success); + color: var(--notif-color-text-inverse); +} + +[data-theme="dark"] .notification-danger, +[data-theme="dark"] .notification-error { + border-left-color: var(--notif-color-danger) !important; +} + +[data-theme="dark"] .notification-danger .notification-icon, +[data-theme="dark"] .notification-error .notification-icon { + background: var(--notif-color-danger); + color: var(--notif-color-text-inverse); +} + +[data-theme="dark"] .notification-warning { + border-left-color: var(--notif-color-warning) !important; +} + +[data-theme="dark"] .notification-warning .notification-icon { + background: var(--notif-color-warning); + color: var(--notif-color-text-inverse); +} + +[data-theme="dark"] .notification-info { + border-left-color: var(--notif-color-info) !important; +} + +[data-theme="dark"] .notification-info .notification-icon { + background: var(--notif-color-info); + color: var(--notif-color-text-inverse); } [data-theme="dark"] .notification-title { - color: #f3f4f6; + color: var(--notif-color-text-primary); } [data-theme="dark"] .notification-message { - color: #d1d5db; + color: var(--notif-color-text-secondary); } [data-theme="dark"] .notification-icon { - background: #374151; - color: #9ca3af; + background: var(--notif-bg-icon); + color: var(--notif-icon-color); } [data-theme="dark"] .notification-unread .notification-icon { - background: #3b82f6; - color: white; + background: var(--notif-bg-icon-unread); + color: var(--notif-icon-color-unread); } [data-theme="dark"] .notifications-load-more { - background: #111827; - border-top-color: #374151; + background: var(--notif-bg-secondary); + border-top-color: var(--notif-border-color); +} + +@keyframes notification-highlight-dark { + 0% { + background: var(--notif-color-warning-light); + border-left-color: var(--notif-color-warning); + } + 100% { + background: var(--notif-bg-unread); + border-left-color: var(--notif-border-color-unread); + } +} + +@keyframes notification-highlight-success-dark { + 0% { + background: rgba(16, 185, 129, 0.3); + border-left-color: var(--notif-color-success); + } + 100% { + background: var(--notif-bg-unread); + border-left-color: var(--notif-color-success); + } +} + +@keyframes notification-highlight-danger-dark { + 0% { + background: rgba(239, 68, 68, 0.3); + border-left-color: var(--notif-color-danger); + } + 100% { + background: var(--notif-bg-unread); + border-left-color: var(--notif-color-danger); + } +} + +@keyframes notification-highlight-warning-dark { + 0% { + background: var(--notif-color-warning-light); + border-left-color: var(--notif-color-warning); + } + 100% { + background: var(--notif-bg-unread); + border-left-color: var(--notif-color-warning); + } +} + +@keyframes notification-highlight-info-dark { + 0% { + background: rgba(23, 162, 184, 0.3); + border-left-color: var(--notif-color-info); + } + 100% { + background: var(--notif-bg-unread); + border-left-color: var(--notif-color-info); + } +} + +[data-theme="dark"] .notification-new { + animation: notification-highlight-dark 5s ease-out; +} + +[data-theme="dark"] .notification-new.notification-success { + animation: notification-highlight-success-dark 5s ease-out; +} + +[data-theme="dark"] .notification-new.notification-danger, +[data-theme="dark"] .notification-new.notification-error { + animation: notification-highlight-danger-dark 5s ease-out; +} + +[data-theme="dark"] .notification-new.notification-warning { + animation: notification-highlight-warning-dark 5s ease-out; +} + +[data-theme="dark"] .notification-new.notification-info { + animation: notification-highlight-info-dark 5s ease-out; } /* ========================================================================== @@ -806,7 +1192,7 @@ @media (max-width: 640px) { .notifications-dropdown { - margin-top: 4px; + margin-top: var(--notif-spacing-xs); } } @@ -827,13 +1213,12 @@ } } -/* Focus management for keyboard navigation */ .notifications-dropdown:focus-within { outline: none; } .notification-item:focus-visible { - outline: 2px solid #3b82f6; + outline: var(--notif-outline-width) solid var(--notif-outline-color); outline-offset: -2px; } @@ -849,9 +1234,8 @@ display: flex !important; } -/* Animation for dropdown appearance */ .notifications-dropdown.notifications-visible { - animation: dropdown-appear 0.2s cubic-bezier(0.4, 0, 0.2, 1); + animation: dropdown-appear var(--notif-transition-fast) cubic-bezier(0.4, 0, 0.2, 1); } @keyframes dropdown-appear { @@ -864,3 +1248,17 @@ transform: translateY(0); } } + +/* Admin plugin styles */ +.navbar-dark .notifications-bell svg, +.bg-dark .notifications-bell svg, +[class*="navbar-dark"] .notifications-bell svg, +[class*="bg-dark"] .notifications-bell svg { + stroke: var(--notif-color-text-inverse); +} + +.navbar-dark .notifications-bell:hover svg, +.bg-dark .notifications-bell:hover svg { + stroke: var(--notif-color-text-inverse); + opacity: 0.8; +} \ No newline at end of file diff --git a/webroot/js/BroadcastingBase.js b/webroot/js/BroadcastingBase.js new file mode 100644 index 0000000..f014305 --- /dev/null +++ b/webroot/js/BroadcastingBase.js @@ -0,0 +1,123 @@ +/** + * Broadcasting Base Class + * + * Shared logic for all broadcasting modules (Pusher, Mercure, etc.) + * Eliminates code duplication between broadcasting implementations + */ +class BroadcastingBase { + constructor(options) { + this.options = { + userId: null, + userName: 'Anonymous', + channelName: null, + ...options + }; + + this.store = null; + this.connected = false; + } + + init(store) { + this.store = store; + this.initializeConnection(); + this.subscribeToChannel(); + } + + initializeConnection() { + throw new Error('initializeConnection() must be implemented by subclass'); + } + + subscribeToChannel() { + throw new Error('subscribeToChannel() must be implemented by subclass'); + } + + handleBroadcastEvent(eventName, data) { + if (typeof data === 'string') { + try { + data = JSON.parse(data); + } catch (e) { + console.warn('Broadcasting Base: Could not parse event data', data); + return; + } + } + + if (eventName === 'notification.marked-read') { + this.handleMarkAsReadUpdate(data); + return; + } + + if (eventName === 'notification.marked-all-read') { + this.handleMarkAllAsReadUpdate(data); + return; + } + + const notification = this.normalizeNotification(eventName, data); + if (this.store) { + this.store.addNotification(notification); + } + } + + normalizeNotification(eventName, data) { + const notification = { + id: data.id || this.generateId(), + title: data.title || data.data?.title || eventName.replace(/\./g, ' '), + message: data.message || data.data?.message || data.body || data.data?.body || '', + type: data.type || eventName, + data: data.data || data, + created_at: data.created_at || (data.timestamp ? new Date(data.timestamp * 1000).toISOString() : new Date().toISOString()), + _source: 'broadcast' + }; + + if (data.icon || data.data?.icon) { + notification.data.icon = data.icon || data.data.icon; + } + if (data.icon_class || data.iconClass || data.data?.icon_class || data.data?.iconClass) { + notification.data.icon_class = data.icon_class || data.iconClass || data.data?.icon_class || data.data?.iconClass; + } + if (data.action_url || data.actionUrl || data.data?.action_url || data.data?.actionUrl) { + notification.data.action_url = data.action_url || data.actionUrl || data.data?.action_url || data.data?.actionUrl; + } + if (data.actions || data.data?.actions) { + notification.actions = data.actions || data.data.actions; + } + + return notification; + } + + handleMarkAsReadUpdate(data) { + if (!this.store) { + return; + } + + const notificationId = data.notification_id; + this.store.removeNotification(notificationId); + + if (typeof data.unread_count === 'number') { + this.store.unreadCount = data.unread_count; + } + } + + handleMarkAllAsReadUpdate(data) { + if (!this.store) { + return; + } + + this.store.items = []; + this.store.unreadCount = data.unread_count || 0; + } + + generateId() { + return `broadcast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + destroy() { + this.store = null; + this.connected = false; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { BroadcastingBase }; +} else { + window.BroadcastingBase = BroadcastingBase; +} diff --git a/webroot/js/BroadcastingModule.js b/webroot/js/BroadcastingModule.js deleted file mode 100644 index f8f2b13..0000000 --- a/webroot/js/BroadcastingModule.js +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Broadcasting Notification Module for CakeNotifications - * - * Extends the modular notification system with real-time WebSocket capabilities - */ -class BroadcastNotificationsModule { - constructor(options) { - this.options = { - userId: null, - userName: 'Anonymous', - pusherKey: 'app-key', - pusherHost: '127.0.0.1', - pusherPort: 8080, - pusherCluster: 'mt1', - channelName: null, - ...options - }; - - this.echo = null; - this.connected = false; - this.manager = null; - } - - init(manager = null) { - if (typeof Echo === 'undefined') { - console.warn('Broadcasting Module: Laravel Echo not loaded'); - return; - } - - if (typeof Pusher === 'undefined') { - console.warn('Broadcasting Module: Pusher not loaded'); - return; - } - - if (manager) { - this.manager = manager; - } else { - try { - this.manager = window.CakeNotificationManager.get(); - } catch (e) { - console.error('Broadcasting Module: Notification manager not initialized'); - return; - } - } - - this.initializeEcho(); - } - - initializeEcho() { - window.Pusher = Pusher; - - this.echo = new Echo({ - broadcaster: 'pusher', - key: this.options.pusherKey, - cluster: this.options.pusherCluster, - wsHost: this.options.pusherHost, - wsPort: this.options.pusherPort, - wssPort: this.options.pusherPort, - wsPath: '', - disableStats: true, - enabledTransports: ['ws', 'wss'], - forceTLS: false, - }); - - window.Echo = this.echo; - - if (this.echo.connector && this.echo.connector.pusher) { - this.echo.connector.pusher.connection.bind('connected', () => { - this.connected = true; - this.subscribeToChannel(); - }); - - this.echo.connector.pusher.connection.bind('disconnected', () => { - this.connected = false; - }); - - this.echo.connector.pusher.connection.bind('error', (error) => { - console.warn('Broadcasting Module: Connection error', error); - }); - } - } - - subscribeToChannel() { - if (!this.options.channelName) { - console.warn('Broadcasting Module: No channel name provided'); - return; - } - - const channel = this.echo.private(this.options.channelName); - - channel.subscription.bind('pusher:subscription_succeeded', () => { - }); - - channel.subscription.bind('pusher:subscription_error', (error) => { - console.error('Broadcasting Module: Subscription error', error); - }); - - this.echo.connector.pusher.bind_global((eventName, data) => { - if (eventName.startsWith('pusher:')) return; - - this.handleBroadcastEvent(eventName, data); - }); - } - - handleBroadcastEvent(eventName, data) { - if (typeof data === 'string') { - try { - data = JSON.parse(data); - } catch (e) { - console.warn('Broadcasting Module: Could not parse event data', data); - return; - } - } - - if (eventName === 'notification.marked-read') { - this.handleMarkAsReadUpdate(data); - return; - } - - if (eventName === 'notification.marked-all-read') { - this.handleMarkAllAsReadUpdate(data); - return; - } - - const notification = { - id: data.id || this.generateId(), - title: data.title || eventName.replace(/\./g, ' '), - message: data.message || data.body || '', - type: data.type, - data: data.data || data, - created_at: data.created_at || data.timestamp ? new Date(data.timestamp * 1000).toISOString() : new Date().toISOString(), - _source: 'broadcast' - }; - - if (data.icon) { - notification.data.icon = data.icon; - } - if (data.icon_class || data.iconClass) { - notification.data.icon_class = data.icon_class || data.iconClass; - } - if (data.action_url || data.actionUrl) { - notification.data.action_url = data.action_url || data.actionUrl; - } - if (data.actions) { - notification.actions = data.actions; - } - - this.manager.addNotification(notification); - } - - handleMarkAsReadUpdate(data) { - if (!this.manager) { - return; - } - - const notificationId = data.notification_id; - this.manager.removeNotification(notificationId); - this.manager.unreadCount = data.unread_count || Math.max(0, this.manager.unreadCount - 1); - this.manager.emit('notification:marked-read', { notificationId }); - } - - handleMarkAllAsReadUpdate(data) { - if (!this.manager) { - return; - } - - this.manager.notifications = []; - this.manager.unreadCount = data.unread_count || 0; - this.manager.emit('notifications:changed', { notifications: [] }); - this.manager.emit('notifications:all-marked-read'); - } - - generateId() { - return `broadcast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - } - - destroy() { - if (this.echo) { - this.echo.disconnect(); - } - } -} - -document.addEventListener('DOMContentLoaded', () => { - console.log('Broadcasting Module: Initialization ...'); - const broadcastConfig = window.broadcastingConfig; - if (!broadcastConfig) return; - - console.log('Broadcasting Module: Initialization ...'); - if (broadcastConfig.broadcaster === 'mercure') { - return; - } - console.log('Broadcasting Module: Initialization ...'); - console.log(broadcastConfig); - - const initBroadcasting = () => { - try { - const manager = window.CakeNotificationManager.get(); - const module = new BroadcastNotificationsModule(broadcastConfig); - module.init(manager); - console.log('Broadcasting Module: Initialization ...'); - } catch (e) { - console.error('Broadcasting Module: Initialization failed, retrying...', e.message); - setTimeout(initBroadcasting, 100); - } - }; - - setTimeout(initBroadcasting, 100); -}); - diff --git a/webroot/js/MercureBroadcastingModule.js b/webroot/js/MercureBroadcastingModule.js deleted file mode 100644 index 100c04a..0000000 --- a/webroot/js/MercureBroadcastingModule.js +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Mercure Broadcasting Notification Module for Crustum Notifications - * - * Extends the modular notification system with real-time Mercure SSE capabilities - */ -class MercureBroadcastingModule { - constructor(options) { - this.options = { - userId: null, - userName: 'Anonymous', - mercureUrl: '/.well-known/mercure', - authEndpoint: '/broadcasting/auth', - channelName: null, - ...options - }; - - this.echo = null; - this.connected = false; - this.manager = null; - } - - init(manager = null) { - if (typeof Echo === 'undefined') { - console.warn('Mercure Broadcasting Module: Laravel Echo not loaded'); - return; - } - - if (typeof MercureConnector === 'undefined') { - console.warn('Mercure Broadcasting Module: MercureConnector not loaded'); - return; - } - - if (manager) { - this.manager = manager; - } else { - try { - this.manager = window.CakeNotificationManager.get(); - } catch (e) { - console.error('Mercure Broadcasting Module: Notification manager not initialized'); - return; - } - } - - this.initializeEcho(); - } - - initializeEcho() { - this.echo = new Echo({ - broadcaster: MercureConnector, - mercure: { - url: this.options.mercureUrl - }, - authEndpoint: this.options.authEndpoint - }); - - window.Echo = this.echo; - - this.echo.connector.on('connected', () => { - this.connected = true; - }); - - this.echo.connector.on('disconnected', () => { - this.connected = false; - }); - - this.echo.connector.on('error', (error) => { - console.warn('Mercure Broadcasting Module: Connection error', error); - }); - - this.subscribeToChannel(); - } - - subscribeToChannel() { - if (!this.options.channelName) { - console.warn('Mercure Broadcasting Module: No channel name provided'); - return; - } - - const channel = this.echo.private(this.options.channelName); - - channel.listen('*', (eventName, data) => { - this.handleBroadcastEvent(eventName, data); - }); - } - - handleBroadcastEvent(eventName, data) { - if (typeof data === 'string') { - try { - data = JSON.parse(data); - } catch (e) { - console.warn('Mercure Broadcasting Module: Could not parse event data', data); - return; - } - } - - if (eventName === 'notification.marked-read') { - this.handleMarkAsReadUpdate(data); - return; - } - - if (eventName === 'notification.marked-all-read') { - this.handleMarkAllAsReadUpdate(data); - return; - } - - const notification = { - id: data.id || this.generateId(), - title: data.title || data.data?.title || eventName.replace(/\./g, ' '), - message: data.message || data.data?.message || data.body || data.data?.body || '', - type: data.type || eventName, - data: data.data || data, - created_at: data.created_at || data.timestamp ? new Date(data.timestamp * 1000).toISOString() : new Date().toISOString(), - _source: 'broadcast' - }; - - if (data.icon || data.data?.icon) { - notification.data.icon = data.icon || data.data.icon; - } - if (data.icon_class || data.iconClass || data.data?.icon_class || data.data?.iconClass) { - notification.data.icon_class = data.icon_class || data.iconClass || data.data?.icon_class || data.data?.iconClass; - } - if (data.action_url || data.actionUrl || data.data?.action_url || data.data?.actionUrl) { - notification.data.action_url = data.action_url || data.actionUrl || data.data?.action_url || data.data?.actionUrl; - } - if (data.actions || data.data?.actions) { - notification.actions = data.actions || data.data.actions; - } - - this.manager.addNotification(notification); - } - - handleMarkAsReadUpdate(data) { - if (!this.manager) { - return; - } - - const notificationId = data.notification_id; - this.manager.removeNotification(notificationId); - this.manager.unreadCount = data.unread_count || Math.max(0, this.manager.unreadCount - 1); - this.manager.emit('notification:marked-read', { notificationId }); - } - - handleMarkAllAsReadUpdate(data) { - if (!this.manager) { - return; - } - - this.manager.notifications = []; - this.manager.unreadCount = data.unread_count || 0; - this.manager.emit('notifications:changed', { notifications: [] }); - this.manager.emit('notifications:all-marked-read'); - } - - generateId() { - return `mercure-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - } - - destroy() { - if (this.echo) { - this.echo.disconnect(); - } - } -} - -document.addEventListener('DOMContentLoaded', () => { - const broadcastConfig = window.broadcastingConfig; - if (!broadcastConfig) return; - - if (broadcastConfig.broadcaster !== 'mercure') { - return; - } - - const initBroadcasting = () => { - try { - const manager = window.CakeNotificationManager.get(); - const module = new MercureBroadcastingModule(broadcastConfig); - module.init(manager); - } catch (e) { - console.error('Mercure Broadcasting Module: Initialization failed, retrying...', e.message); - setTimeout(initBroadcasting, 100); - } - }; - - setTimeout(initBroadcasting, 100); -}); - diff --git a/webroot/js/MercureModule.js b/webroot/js/MercureModule.js new file mode 100644 index 0000000..c7d5227 --- /dev/null +++ b/webroot/js/MercureModule.js @@ -0,0 +1,114 @@ +/** + * Mercure Broadcasting Module + * + * Extends BroadcastingBase for Mercure SSE connection + */ +class MercureBroadcastingModule extends BroadcastingBase { + constructor(options) { + super(options); + this.options = { + mercureUrl: '/.well-known/mercure', + authEndpoint: '/broadcasting/auth', + ...this.options + }; + + this.echo = null; + } + + initializeConnection() { + if (typeof Echo === 'undefined') { + console.warn('Mercure Module: Laravel Echo not loaded'); + return; + } + + if (typeof MercureConnector === 'undefined') { + console.warn('Mercure Module: MercureConnector not loaded'); + return; + } + + this.echo = new Echo({ + broadcaster: MercureConnector, + mercure: { + url: this.options.mercureUrl + }, + authEndpoint: this.options.authEndpoint + }); + + window.Echo = this.echo; + + this.echo.connector.on('connected', () => { + this.connected = true; + }); + + this.echo.connector.on('disconnected', () => { + this.connected = false; + }); + + this.echo.connector.on('error', (error) => { + console.warn('Mercure Module: Connection error', error); + }); + + this.subscribeToChannel(); + } + + subscribeToChannel() { + if (!this.options.channelName) { + console.warn('Mercure Module: No channel name provided'); + return; + } + + if (!this.echo) { + return; + } + + const channel = this.echo.private(this.options.channelName); + + channel.listen('*', (eventName, data) => { + this.handleBroadcastEvent(eventName, data); + }); + } + + destroy() { + if (this.echo) { + this.echo.disconnect(); + } + super.destroy(); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const broadcastConfig = window.broadcastingConfig; + if (!broadcastConfig) return; + + if (broadcastConfig.broadcaster !== 'mercure') { + return; + } + + const initBroadcasting = () => { + try { + if (typeof Alpine === 'undefined') { + setTimeout(initBroadcasting, 100); + return; + } + + const store = Alpine.store('notifications'); + if (!store) { + setTimeout(initBroadcasting, 100); + return; + } + const module = new MercureBroadcastingModule(broadcastConfig); + module.init(store); + } catch (e) { + console.error('Mercure Module: Initialization failed, retrying...', e.message); + setTimeout(initBroadcasting, 100); + } + }; + + setTimeout(initBroadcasting, 100); +}); + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { MercureBroadcastingModule }; +} else { + window.MercureBroadcastingModule = MercureBroadcastingModule; +} diff --git a/webroot/js/Notification.js b/webroot/js/Notification.js index 714610f..60f2ccd 100644 --- a/webroot/js/Notification.js +++ b/webroot/js/Notification.js @@ -108,17 +108,31 @@ class CakeNotification { } send() { + if (typeof Alpine !== 'undefined' && Alpine.store('notifications')) { + const store = Alpine.store('notifications'); + store.addNotification(this.toObject()); + return this; + } if (typeof window.CakeNotificationManager !== 'undefined' && typeof window.CakeNotificationManager.addNotification === 'function') { return window.CakeNotificationManager.addNotification(this.toObject()); } else { - console.warn('CakeNotificationManager not initialized. Call initializeNotifications() first.'); + console.warn('Notification system not initialized'); return this; } } toObject() { - return JSON.parse(JSON.stringify(this._data)); + const data = JSON.parse(JSON.stringify(this._data)); + if (data.actions && Array.isArray(data.actions)) { + data.actions = data.actions.map(action => { + if (action && typeof action.toObject === 'function') { + return action.toObject(); + } + return action; + }); + } + return data; } static fromObject(obj) { @@ -137,4 +151,3 @@ if (typeof module !== 'undefined' && module.exports) { } else { window.CakeNotification = CakeNotification; } - diff --git a/webroot/js/NotificationAction.js b/webroot/js/NotificationAction.js index c5b6efc..31c39de 100644 --- a/webroot/js/NotificationAction.js +++ b/webroot/js/NotificationAction.js @@ -117,4 +117,3 @@ if (typeof module !== 'undefined' && module.exports) { } else { window.CakeNotificationAction = CakeNotificationAction; } - diff --git a/webroot/js/NotificationManager.js b/webroot/js/NotificationManager.js index c05ec60..c75c451 100644 --- a/webroot/js/NotificationManager.js +++ b/webroot/js/NotificationManager.js @@ -1,10 +1,10 @@ /** - * CakePHP Notification Manager + * Notification Manager - API Client & Composables * - * Manages notification state, API communication, and polling. - * Central hub for all notification operations. + * Handles API communication, polling, and provides composable functions + * for use with Alpine.js components */ -class CakeNotificationManager { +class NotificationManager { constructor(options = {}) { this.options = { apiUrl: '/notification/notifications/unread.json', @@ -14,57 +14,14 @@ class CakeNotificationManager { ...options }; - this.notifications = []; - this.unreadCount = 0; this.lastCheckTime = null; - this.isLoading = false; this.pollTimer = null; - this.listeners = {}; - this.modules = []; - } - - registerModule(module) { - this.modules.push(module); - if (typeof module.init === 'function') { - module.init(this); - } - return this; - } - - async init() { - if (this.options.enablePolling && this.options.apiUrl) { - await this.loadNotifications(); - this.startPolling(); - } - return this; - } - - addNotification(notification) { - const exists = this.notifications.find(n => n.id === notification.id); - if (!exists) { - notification._isNew = true; - this.notifications.unshift(notification); - - if (!notification.read_at) { - this.unreadCount++; - } - - this.emit('notification:added', { notification }); - this.emit('notifications:changed', { notifications: this.notifications }); - - setTimeout(() => { - notification._isNew = false; - this.emit('notifications:changed', { notifications: this.notifications }); - }, 5000); - } - return this; } async loadNotifications(page = 1, append = false) { - if (this.isLoading || !this.options.apiUrl) return; - - this.isLoading = true; - this.emit('notifications:loading', { page, append }); + if (!this.options.apiUrl) { + return { success: false, data: [], hasMore: false }; + } try { const url = new URL(this.options.apiUrl, window.location.origin); @@ -79,86 +36,96 @@ class CakeNotificationManager { credentials: 'same-origin' }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } const result = await response.json(); if (result.success) { - this.handleLoadSuccess(result, append); + this.lastCheckTime = new Date(); + const unreadCount = result.meta?.unread_count ?? result.meta?.count ?? result.unreadCount ?? 0; + return { + success: true, + data: result.data.map(n => this.normalizeNotification(n)), + hasMore: result.pagination?.has_next_page ?? result.pagination?.hasMore ?? false, + unreadCount: unreadCount + }; } else { throw new Error(result.message || 'Failed to load'); } } catch (error) { - this.emit('notifications:error', { error }); - } finally { - this.isLoading = false; - this.emit('notifications:loaded'); + console.error('Failed to load notifications:', error); + return { success: false, data: [], hasMore: false, error }; } } - handleLoadSuccess(result, append) { - const normalizeNotification = (notification) => { - if (notification.data?.actions && !notification.actions) { - notification.actions = notification.data.actions; + async markAsRead(id) { + const isLocalNotification = typeof id === 'string' && id.startsWith('notif-'); + if (isLocalNotification) { + return true; } - notification._source = 'api'; - return notification; - }; - if (append) { - const normalized = (result.data || []).map(normalizeNotification); - this.notifications = this.notifications.concat(normalized); - } else { - const preserved = this.notifications.filter(n => n._source !== 'api'); - const preservedIds = new Set(preserved.map(n => n.id)); - const apiItems = (result.data || []) - .filter(n => !preservedIds.has(n.id)) - .map(normalizeNotification); - this.notifications = preserved.concat(apiItems); - this.lastCheckTime = new Date(); - } + try { + const response = await fetch(`/notification/notifications/${id}/read.json`, { + method: 'PATCH', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': this.getCsrfToken() + }, + credentials: 'same-origin' + }); - this.unreadCount = result.meta?.count || 0; - this.emit('notifications:changed', { - notifications: this.notifications, - pagination: result.pagination - }); - } + if (!response.ok) { + const errorText = await response.text(); + let errorData; + try { + errorData = JSON.parse(errorText); + } catch (e) { + errorData = { message: errorText }; + } + throw new Error(errorData.message || `HTTP ${response.status}`); + } + + const result = await response.json(); + return result.success || false; + } catch (error) { + console.error('Failed to mark notification as read:', error); + throw error; + } + } - async markAsRead(notificationId) { + async markAllAsRead() { try { - const response = await fetch(`/notification/notifications/${notificationId}/read.json`, { + const response = await fetch('/notification/notifications/mark-all-read.json', { method: 'PATCH', headers: { 'Accept': 'application/json', + 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': this.getCsrfToken() }, credentials: 'same-origin' }); - if (response.ok) { - await this.loadNotifications(); - this.emit('notification:marked-read', { notificationId }); - } else { - const notification = this.notifications.find(n => n.id === notificationId); - if (notification && notification._source !== 'api') { - this.removeNotification(notificationId); - } + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); } + + const result = await response.json(); + return result.success || false; } catch (error) { - const notification = this.notifications.find(n => n.id === notificationId); - if (notification && notification._source !== 'api') { - this.removeNotification(notificationId); - } - this.emit('notifications:error', { error }); + console.error('Failed to mark all notifications as read:', error); + return false; } } - async markAllAsRead() { + async deleteNotification(id) { try { - const response = await fetch('/notification/notifications/mark-all-read.json', { - method: 'PATCH', + const response = await fetch(`/notification/notifications/${id}.json`, { + method: 'DELETE', headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', @@ -167,42 +134,56 @@ class CakeNotificationManager { credentials: 'same-origin' }); - if (response.ok) { - this.notifications = []; - this.unreadCount = 0; - this.emit('notifications:changed', { notifications: [] }); - this.emit('notifications:all-marked-read'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); } + + const result = await response.json(); + return result.success || false; } catch (error) { - this.emit('notifications:error', { error }); + console.error('Failed to delete notification:', error); + return false; } } - clearAllNotifications() { - this.notifications = []; - this.unreadCount = 0; - this.emit('notifications:changed', { notifications: [] }); - this.emit('notifications:all-marked-read'); - } + normalizeNotification(notification) { + const normalized = { + id: notification.id, + title: notification.data?.title || notification.title || '', + message: notification.data?.message || notification.message || '', + type: notification.type || '', + data: notification.data || {}, + created_at: notification.created_at || notification.created || new Date().toISOString(), + read_at: notification.read_at || null + }; - removeNotification(notificationId) { - const index = this.notifications.findIndex(n => n.id === notificationId); - if (index !== -1) { - const notification = this.notifications[index]; - if (!notification.read_at) { - this.unreadCount = Math.max(0, this.unreadCount - 1); + if (!normalized.data.icon && normalized.type) { + const iconMap = { + 'success': 'check', + 'danger': 'alert', + 'error': 'alert', + 'warning': 'alert', + 'info': 'info' + }; + if (iconMap[normalized.type]) { + normalized.data.icon = iconMap[normalized.type]; } - this.notifications.splice(index, 1); - this.emit('notification:removed', { notificationId }); - this.emit('notifications:changed', { notifications: this.notifications }); } + + if (notification.actions && Array.isArray(notification.actions)) { + normalized.actions = this.normalizeActions(notification.actions); + } else if (notification.data?.actions && Array.isArray(notification.data.actions)) { + normalized.actions = this.normalizeActions(notification.data.actions); + } + + return normalized; } - startPolling() { + startPolling(store) { if (!this.options.enablePolling || !this.options.apiUrl) return; if (this.options.pollInterval > 0) { this.pollTimer = setInterval(() => { - this.checkForNewNotifications(); + this.checkForNewNotifications(store); }, this.options.pollInterval); } } @@ -214,8 +195,8 @@ class CakeNotificationManager { } } - async checkForNewNotifications() { - if (this.isLoading || !this.lastCheckTime) return; + async checkForNewNotifications(store) { + if (!this.lastCheckTime) return; try { const url = new URL(this.options.apiUrl, window.location.origin); @@ -235,24 +216,23 @@ class CakeNotificationManager { const result = await response.json(); if (result.success && result.data.length > 0) { - const existingIds = new Set(this.notifications.map(n => n.id)); - const newNotifications = result.data.filter(notif => { - const notifTime = new Date(notif.created_at || notif.created); - if (notifTime <= this.lastCheckTime) { - return false; - } - if (existingIds.has(notif.id)) { - return false; - } - return true; - }); + const existingIds = new Set(store.items.map(n => n.id)); + const newNotifications = result.data + .map(n => this.normalizeNotification(n)) + .filter(notif => { + const notifTime = new Date(notif.created_at); + if (notifTime <= this.lastCheckTime) { + return false; + } + if (existingIds.has(notif.id)) { + return false; + } + return true; + }); newNotifications.forEach(notif => { - if (notif.data?.actions && !notif.actions) { - notif.actions = notif.data.actions; - } notif._isNew = true; - this.addNotification(notif); + store.addNotification(notif); }); } } catch (error) { @@ -260,53 +240,133 @@ class CakeNotificationManager { } } - on(event, callback) { - if (!this.listeners[event]) { - this.listeners[event] = []; + getCsrfToken() { + let meta = document.querySelector('meta[name="csrfToken"]'); + if (!meta) { + meta = document.querySelector('meta[name="csrf-token"]'); } - this.listeners[event].push(callback); - return this; + return meta ? meta.getAttribute('content') : ''; } - off(event, callback) { - if (!this.listeners[event]) return this; - this.listeners[event] = this.listeners[event].filter(cb => cb !== callback); - return this; + formatTimeAgo(dateString) { + const date = new Date(dateString); + const now = new Date(); + + if (isNaN(date.getTime())) { + return dateString; + } + + const seconds = Math.floor((now - date) / 1000); + if (seconds < 0) return 'just now'; + if (seconds < 60) return 'just now'; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + + if (days < 30) { + const weeks = Math.floor(days / 7); + return `${weeks}w ago`; + } + + return date.toLocaleDateString(); } - emit(event, data = {}) { - if (this.listeners[event]) { - this.listeners[event].forEach(callback => callback(data)); + normalizeActions(actions) { + if (!Array.isArray(actions)) { + return []; } - window.dispatchEvent(new CustomEvent(event, { detail: data })); - return this; + + return actions.map(action => { + if (!action || typeof action !== 'object') { + return null; + } + + const normalized = { + name: action.name || null, + label: action.label || action.name || 'Action', + url: action.url || null, + icon: action.icon || null, + color: action.color || action.type || null, + type: action.type || action.color || null, + isDisabled: action.isDisabled === true || action.disabled === true, + openInNewTab: action.openInNewTab === true || action.target === '_blank', + event: action.event || null, + eventData: action.eventData || {}, + shouldClose: action.shouldClose === true + }; + + if (!normalized.color && normalized.type) { + normalized.color = normalized.type; + } + + return normalized; + }).filter(action => action !== null); } - getNotifications() { - return [...this.notifications]; + escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } - getUnreadCount() { - return this.unreadCount; + getNotificationIcon(notification) { + if (notification.data?.icon_class) { + return ``; + } + if (notification.data?.icon) { + const iconMap = { + 'bell': '', + 'post': '', + 'user': '', + 'message': '', + 'alert': '', + 'check': '', + 'info': '', + }; + return iconMap[notification.data.icon] || iconMap['bell']; + } + return null; } - getCsrfToken() { - let meta = document.querySelector('meta[name="csrfToken"]'); - if (!meta) { - meta = document.querySelector('meta[name="csrf-token"]'); + getNotificationTitle(notification) { + if (notification.data && notification.data.title) { + return notification.data.title; } - return meta ? meta.getAttribute('content') : ''; + if (notification.title) { + return notification.title; + } + const parts = (notification.type || '').split('\\'); + const className = parts[parts.length - 1]; + return className.replace(/([A-Z])/g, ' $1').trim(); + } + + getNotificationMessage(notification) { + if (notification.data && notification.data.message) { + return notification.data.message; + } + if (notification.message) { + return notification.message; + } + if (notification.data && notification.data.title) { + return notification.data.title; + } + return 'You have a new notification'; } destroy() { this.stopPolling(); - this.listeners = {}; } } if (typeof module !== 'undefined' && module.exports) { - module.exports = { NotificationManager: CakeNotificationManager }; + module.exports = { NotificationManager }; } else { - window.CakeNotificationManager = CakeNotificationManager; + window.NotificationManager = NotificationManager; } - diff --git a/webroot/js/NotificationRenderer.js b/webroot/js/NotificationRenderer.js deleted file mode 100644 index 67db47d..0000000 --- a/webroot/js/NotificationRenderer.js +++ /dev/null @@ -1,275 +0,0 @@ -/** - * CakePHP Notification Renderer - * - * Pure rendering logic with template overloading support. - * Converts notification objects to HTML without side effects. - */ -class CakeNotificationRenderer { - constructor(options = {}) { - this.options = { - markReadOnClick: true, - ...options - }; - - this.templates = { - notification: null, - notificationContent: null, - notificationIcon: null, - notificationActions: null, - notificationAction: null, - loadMoreButton: null, - emptyState: null, - errorState: null, - loadingState: null - }; - } - - registerTemplate(name, templateFn) { - if (this.templates.hasOwnProperty(name)) { - this.templates[name] = templateFn; - } else { - console.warn(`Unknown template: ${name}`); - } - return this; - } - - registerTemplates(templates) { - Object.keys(templates).forEach(name => { - this.registerTemplate(name, templates[name]); - }); - return this; - } - - getTemplate(name, defaultFn) { - return this.templates[name] || defaultFn; - } - - renderNotifications(notifications, hasMore = false) { - if (!Array.isArray(notifications) || notifications.length === 0) { - const emptyTemplate = this.getTemplate('emptyState', this.defaultEmptyState.bind(this)); - return emptyTemplate(); - } - - const html = notifications.map(n => this.renderNotification(n)).join(''); - const footer = hasMore ? this.renderLoadMoreButton() : ''; - - return html + footer; - } - - renderNotification(notification) { - const notificationTemplate = this.getTemplate('notification', this.defaultNotificationTemplate.bind(this)); - return notificationTemplate(notification, this); - } - - renderNotificationContent(notification) { - const contentTemplate = this.getTemplate('notificationContent', this.defaultNotificationContent.bind(this)); - return contentTemplate(notification, this); - } - - renderNotificationIcon(notification) { - const iconTemplate = this.getTemplate('notificationIcon', this.defaultNotificationIcon.bind(this)); - return iconTemplate(notification, this); - } - - renderActions(actions) { - const actionsTemplate = this.getTemplate('notificationActions', this.defaultActionsTemplate.bind(this)); - return actionsTemplate(actions, this); - } - - renderAction(action) { - const actionTemplate = this.getTemplate('notificationAction', this.defaultActionTemplate.bind(this)); - return actionTemplate(action, this); - } - - renderLoadMoreButton() { - const loadMoreTemplate = this.getTemplate('loadMoreButton', this.defaultLoadMoreButton.bind(this)); - return loadMoreTemplate(); - } - - defaultNotificationTemplate(notification, renderer) { - const isUnread = !notification.read_at; - const isNew = notification._isNew || false; - const actionUrl = notification.data?.action_url || null; - const actions = notification.actions || []; - - const wrapperTag = actionUrl ? 'a' : 'div'; - const wrapperAttrs = actionUrl ? `href="${renderer.escapeHtml(actionUrl)}"` : ''; - - return ` -
- <${wrapperTag} class="notification-content" ${wrapperAttrs}> - ${renderer.renderNotificationContent(notification)} - - ${isUnread ? renderer.renderMarkAsReadButton(notification) : ''} - ${actions.length > 0 ? `` : ''} -
- `; - } - - defaultNotificationContent(notification, renderer) { - const icon = renderer.renderNotificationIcon(notification); - const timeAgo = renderer.formatTimeAgo(notification.created_at || notification.created); - const message = renderer.getNotificationMessage(notification); - const title = renderer.getNotificationTitle(notification); - - return ` - ${icon ? `
${icon}
` : ''} -
-
${renderer.escapeHtml(title)}
-
${renderer.escapeHtml(message)}
-
${timeAgo}
-
- `; - } - - defaultNotificationIcon(notification, renderer) { - return renderer.getNotificationIcon(notification); - } - - defaultActionsTemplate(actions, renderer) { - const actionsHtml = actions.map(action => renderer.renderAction(action)).join(''); - return `
${actionsHtml}
`; - } - - defaultActionTemplate(action, renderer) { - const colorClass = action.color ? `btn-${action.color}` : (action.type ? `btn-${action.type}` : ''); - const disabled = action.isDisabled ? 'disabled' : ''; - const actionName = action.name || action.url || 'action'; - const actionUrl = action.url || null; - - return ` - - `; - } - - defaultLoadMoreButton() { - return ` -
- -
- `; - } - - defaultEmptyState() { - return '
No new notifications
'; - } - - defaultErrorState(error) { - return `
${this.escapeHtml(error.message || 'An error occurred')}
`; - } - - defaultLoadingState() { - return '
Loading...
'; - } - - renderMarkAsReadButton(notification) { - return ` - - `; - } - - getNotificationIcon(notification) { - if (notification.data?.icon_class) { - return ``; - } - if (notification.data?.icon) { - const iconMap = { - 'bell': '', - 'post': '', - 'user': '', - 'message': '', - 'alert': '', - 'check': '', - 'info': '', - }; - return iconMap[notification.data.icon] || iconMap['bell']; - } - return null; - } - - getNotificationTitle(notification) { - if (notification.data && notification.data.title) { - return notification.data.title; - } - if (notification.title) { - return notification.title; - } - const parts = (notification.type || '').split('\\'); - const className = parts[parts.length - 1]; - return className.replace(/([A-Z])/g, ' $1').trim(); - } - - getNotificationMessage(notification) { - if (notification.data && notification.data.message) { - return notification.data.message; - } - if (notification.message) { - return notification.message; - } - if (notification.data && notification.data.title) { - return notification.data.title; - } - return 'You have a new notification'; - } - - formatTimeAgo(dateString) { - const date = new Date(dateString); - const now = new Date(); - - if (isNaN(date.getTime())) { - return dateString; - } - - const seconds = Math.floor((now - date) / 1000); - if (seconds < 0) return 'just now'; - if (seconds < 60) return 'just now'; - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - - const days = Math.floor(hours / 24); - if (days < 7) return `${days}d ago`; - - if (days < 30) { - const weeks = Math.floor(days / 7); - return `${weeks}w ago`; - } - - return date.toLocaleDateString(); - } - - escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } -} - -if (typeof module !== 'undefined' && module.exports) { - module.exports = { NotificationRenderer: CakeNotificationRenderer }; -} else { - window.CakeNotificationRenderer = CakeNotificationRenderer; -} - diff --git a/webroot/js/NotificationStore.js b/webroot/js/NotificationStore.js new file mode 100644 index 0000000..fb8593c --- /dev/null +++ b/webroot/js/NotificationStore.js @@ -0,0 +1,371 @@ +/** + * Notification Store - Alpine.js Store & Components + * + * Global state management and UI components for notifications + */ +(function() { + 'use strict'; + + if (typeof window.NotificationManager === 'undefined') { + console.error('NotificationManager is required for NotificationStore'); + return; + } + + const manager = new window.NotificationManager(); + let registered = false; + + function registerComponents() { + if (typeof Alpine === 'undefined' || typeof Alpine.store === 'undefined' || typeof Alpine.data === 'undefined') { + return false; + } + + if (Alpine.store('notifications')) { + return true; + } + + Alpine.store('notifications', { + items: [], + unreadCount: 0, + isLoading: false, + currentPage: 1, + hasMore: false, + isOpen: false, + manager: manager, + + init() { + if (manager.options.enablePolling && manager.options.apiUrl) { + this.loadNotifications(); + manager.startPolling(this); + } + }, + + addNotification(notification) { + const exists = this.items.find(n => n.id === notification.id); + if (!exists) { + notification._isNew = true; + this.items.unshift(notification); + + if (!notification.read_at) { + this.unreadCount++; + } + + setTimeout(() => { + notification._isNew = false; + }, 5000); + } + }, + + removeNotification(id) { + const index = this.items.findIndex(n => n.id === id); + if (index !== -1) { + const notification = this.items[index]; + if (!notification.read_at) { + this.unreadCount = Math.max(0, this.unreadCount - 1); + } + this.items.splice(index, 1); + } + }, + + async markAsRead(id) { + const notification = this.items.find(n => n.id === id); + if (!notification || notification.read_at) return; + + const isLocalNotification = typeof id === 'string' && id.startsWith('notif-'); + + if (isLocalNotification) { + notification.read_at = new Date().toISOString(); + const index = this.items.findIndex(n => n.id === id); + if (index !== -1) { + this.items.splice(index, 1); + } + this.unreadCount = Math.max(0, this.unreadCount - 1); + return; + } + + try { + const success = await manager.markAsRead(id); + if (success) { + const index = this.items.findIndex(n => n.id === id); + if (index !== -1) { + this.items.splice(index, 1); + this.unreadCount = Math.max(0, this.unreadCount - 1); + } + } + } catch (error) { + console.warn('Failed to mark notification as read via API, hiding locally:', error); + notification.read_at = new Date().toISOString(); + const index = this.items.findIndex(n => n.id === id); + if (index !== -1) { + this.items.splice(index, 1); + } + this.unreadCount = Math.max(0, this.unreadCount - 1); + } + }, + + async markAllAsRead() { + const unreadIds = this.items + .filter(n => !n.read_at) + .map(n => n.id); + + if (unreadIds.length === 0) return; + + const success = await manager.markAllAsRead(); + if (success) { + this.items = this.items.filter(n => n.read_at); + this.unreadCount = 0; + } + }, + + async loadNotifications(page = 1, append = false) { + if (this.isLoading || !manager.options.apiUrl) return; + + this.isLoading = true; + + try { + const result = await manager.loadNotifications(page, append); + + if (result.success) { + if (append) { + this.items.push(...result.data); + } else { + this.items = result.data; + } + + this.currentPage = page; + this.hasMore = result.hasMore || false; + if (result.unreadCount !== undefined) { + this.unreadCount = result.unreadCount; + } + } + } finally { + this.isLoading = false; + } + }, + + async loadMore() { + if (this.hasMore && !this.isLoading) { + await this.loadNotifications(this.currentPage + 1, true); + } + }, + + setOpen(state) { + this.isOpen = state; + }, + + toggle() { + this.isOpen = !this.isOpen; + if (this.isOpen && this.items.length === 0) { + this.loadNotifications(); + } + } + }); + + function detectTheme(element) { + if (element && element.closest('[data-theme]')) { + const themeAttr = element.closest('[data-theme]').getAttribute('data-theme'); + if (themeAttr === 'dark' || themeAttr === 'light') { + return themeAttr; + } + } + + if (document.documentElement.hasAttribute('data-theme')) { + const docTheme = document.documentElement.getAttribute('data-theme'); + if (docTheme === 'dark' || docTheme === 'light') { + return docTheme; + } + } + + if (document.documentElement.classList.contains('dark')) { + return 'dark'; + } + + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + + return 'light'; + } + + Alpine.data('notificationBell', (config) => ({ + init() { + const wrapper = this.$el; + const detectedTheme = detectTheme(wrapper); + const explicitTheme = config?.theme; + + this.config = { + position: config?.position || 'right', + theme: explicitTheme || detectedTheme, + mode: config?.mode || 'dropdown', + markReadOnClick: config?.markReadOnClick !== false, + ...config + }; + + wrapper.setAttribute('data-theme', this.config.theme); + + if (!explicitTheme && window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleThemeChange = (e) => { + const newTheme = e.matches ? 'dark' : 'light'; + this.config.theme = newTheme; + wrapper.setAttribute('data-theme', newTheme); + }; + mediaQuery.addEventListener('change', handleThemeChange); + } + }, + + get store() { + return Alpine.store('notifications'); + }, + + get unreadCount() { + return this.store.unreadCount; + }, + + get isOpen() { + return this.store.isOpen; + }, + + toggle() { + this.store.toggle(); + }, + + handleClickOutside(event) { + const bell = document.getElementById(this.config.bellId || 'notificationsBell'); + const dropdown = document.getElementById(this.config.dropdownId || 'notificationsDropdown'); + if (bell && dropdown && !bell.contains(event.target) && !dropdown.contains(event.target)) { + this.store.setOpen(false); + } + } + })); + + Alpine.data('notificationList', () => ({ + get store() { + return Alpine.store('notifications'); + }, + + get items() { + return this.store.items; + }, + + get isLoading() { + return this.store.isLoading; + }, + + get hasMore() { + return this.store.hasMore; + }, + + getNotificationData(notification) { + const manager = this.store.manager; + let actions = notification.actions || []; + if (Array.isArray(actions)) { + actions = actions.map(action => { + if (action && typeof action.toObject === 'function') { + return action.toObject(); + } + return action; + }).filter(action => action && (action.name || action.label)); + } + return { + notification: notification, + icon: manager.getNotificationIcon(notification), + title: manager.getNotificationTitle(notification), + message: manager.getNotificationMessage(notification), + timeAgo: manager.formatTimeAgo(notification.created_at), + actions: actions + }; + }, + + async loadMore() { + await this.store.loadMore(); + }, + + async markAllAsRead() { + await this.store.markAllAsRead(); + } + })); + + Alpine.data('notificationItem', (data) => ({ + notification: data.notification, + icon: data.icon, + title: data.title, + message: data.message, + timeAgo: data.timeAgo, + get actions() { + const actions = data.actions || []; + if (!Array.isArray(actions)) return []; + return actions.map(action => { + if (action && typeof action.toObject === 'function') { + return action.toObject(); + } + return action; + }).filter(action => action && (action.name || action.label)); + }, + + get store() { + return Alpine.store('notifications'); + }, + + async markAsRead() { + await this.store.markAsRead(this.notification.id); + }, + + async deleteNotification() { + const success = await this.store.manager.deleteNotification(this.notification.id); + if (success) { + this.store.removeNotification(this.notification.id); + } + }, + + handleAction(action, notification) { + if (!action || action.isDisabled === true) { + return; + } + + if (action.url) { + if (action.openInNewTab === true) { + window.open(action.url, '_blank'); + } else { + window.location.href = action.url; + } + } else if (action.event) { + const event = new CustomEvent(action.event, { + detail: { + notificationId: notification.id, + actionName: action.name, + ...(action.eventData || {}) + }, + bubbles: true, + cancelable: true + }); + this.$el.dispatchEvent(event); + } + + if (action.shouldClose === true) { + this.store.setOpen(false); + } + } + })); + + return true; + } + + function initStore() { + const store = Alpine.store('notifications'); + if (store) { + store.init(); + } + } + + document.addEventListener('alpine:init', () => { + if (!registered && registerComponents()) { + registered = true; + initStore(); + } + }); + +})(); + +if (typeof module !== 'undefined' && module.exports) { + module.exports = {}; +} diff --git a/webroot/js/NotificationWidget.js b/webroot/js/NotificationWidget.js deleted file mode 100644 index d11157d..0000000 --- a/webroot/js/NotificationWidget.js +++ /dev/null @@ -1,358 +0,0 @@ -/** - * CakePHP Notification Widget - * - * Handles UI interactions for the notification bell and dropdown. - * Integrates manager and renderer to provide complete notification UI. - */ -class CakeNotificationWidget { - constructor(manager, renderer, options = {}) { - this.manager = manager; - this.renderer = renderer; - this.options = { - bellId: 'notificationsBell', - dropdownId: 'notificationsDropdown', - contentId: 'notificationsContent', - badgeId: 'notificationsBadge', - ...options - }; - - this.bell = document.getElementById(this.options.bellId); - this.dropdown = document.getElementById(this.options.dropdownId); - this.content = document.getElementById(this.options.contentId); - this.badge = document.getElementById(this.options.badgeId); - this.wrapper = this.bell ? this.bell.closest('.notifications-wrapper') : null; - - this.isOpen = false; - this.currentPage = 1; - this.hasMore = false; - this.timestampUpdateTimer = null; - - this.init(); - } - - init() { - if (!this.bell || !this.dropdown || !this.content) { - console.error('Notifications: Required elements not found'); - return; - } - - this.setupEventListeners(); - this.subscribeToManager(); - } - - setupEventListeners() { - this.bell.addEventListener('click', (e) => { - e.stopPropagation(); - this.toggle(); - }); - - document.addEventListener('click', (e) => { - if (this.isPanelMode()) { - if (e.target.id === 'notificationsBackdrop') { - this.close(); - } - } else { - if (!this.bell.contains(e.target) && !this.dropdown.contains(e.target)) { - this.close(); - } - } - }); - - window.addEventListener('resize', () => { - if (this.isOpen) { - this.adjustDropdownPosition(); - } - }); - - window.addEventListener('scroll', () => { - if (this.isOpen) { - this.adjustDropdownPosition(); - } - }); - - document.addEventListener('click', async (e) => { - await this.handleActionClick(e); - }); - } - - subscribeToManager() { - this.manager.on('notifications:changed', ({ notifications, pagination }) => { - this.hasMore = pagination?.has_next_page || false; - this.render(notifications); - }); - - this.manager.on('notifications:loading', () => { - if (this.currentPage === 1) { - this.content.innerHTML = this.renderer.defaultLoadingState(); - } - }); - - this.manager.on('notifications:error', ({ error }) => { - this.content.innerHTML = this.renderer.defaultErrorState(error); - }); - - this.manager.on('notifications:changed', () => { - this.updateBadge(this.manager.getUnreadCount()); - }); - } - - async handleActionClick(e) { - const target = e.target.closest('[data-action]'); - if (!target) return; - - const action = target.dataset.action; - const notificationId = target.dataset.id; - const actionName = target.dataset.actionName; - - if (action === 'markRead' && notificationId) { - e.preventDefault(); - await this.manager.markAsRead(notificationId); - } else if (action === 'loadMore') { - e.preventDefault(); - await this.loadMore(); - } else if (action === 'markAllRead') { - e.preventDefault(); - if (confirm('Mark all notifications as read?')) { - await this.manager.markAllAsRead(); - } - } else if (action === 'custom' && actionName) { - e.preventDefault(); - await this.handleCustomAction(target, notificationId, actionName); - } else if (action === 'link' && actionName) { - e.preventDefault(); - const url = target.dataset.url; - if (url) { - window.open(url, '_blank'); - } - } - } - - async handleCustomAction(element, notificationId, actionName) { - const notification = this.manager.getNotifications().find(n => n.id === notificationId); - if (!notification) return; - - const actionData = notification.actions.find(a => a.name === actionName); - if (!actionData) return; - - if (actionData.url) { - if (actionData.openInNewTab) { - window.open(actionData.url, '_blank'); - } else { - window.location.href = actionData.url; - } - } - - if (actionData.event) { - window.dispatchEvent(new CustomEvent(actionData.event, { - detail: actionData.eventData - })); - } - - if (actionData.shouldClose) { - this.manager.removeNotification(notificationId); - } - } - - toggle() { - this.isOpen ? this.close() : this.open(); - } - - open() { - this.dropdown.style.display = 'flex'; - this.dropdown.classList.add('notifications-visible'); - this.bell.setAttribute('aria-expanded', 'true'); - this.isOpen = true; - - if (this.isPanelMode()) { - this.dropdown.classList.add('notifications-open'); - this.showBackdrop(); - } else { - this.adjustDropdownPosition(); - } - - this.startTimestampUpdates(); - } - - close() { - this.dropdown.style.display = 'none'; - this.dropdown.classList.remove('notifications-visible'); - this.bell.setAttribute('aria-expanded', 'false'); - this.isOpen = false; - - if (this.isPanelMode()) { - this.dropdown.classList.remove('notifications-open'); - this.hideBackdrop(); - } - - this.stopTimestampUpdates(); - } - - render(notifications) { - const html = this.renderer.renderNotifications(notifications, this.hasMore); - this.content.innerHTML = html; - } - - updateBadge(count) { - if (this.badge) { - if (count > 0) { - this.badge.textContent = count > 99 ? '99+' : count; - this.badge.style.display = 'inline-block'; - } else { - this.badge.style.display = 'none'; - } - } - } - - async loadMore() { - const btn = document.querySelector('.load-more-btn'); - if (btn) { - btn.disabled = true; - btn.textContent = 'Loading...'; - } - await this.manager.loadNotifications(this.currentPage + 1, true); - this.currentPage++; - } - - startTimestampUpdates() { - this.stopTimestampUpdates(); - this.timestampUpdateTimer = setInterval(() => { - this.updateTimestamps(); - }, 60000); - } - - stopTimestampUpdates() { - if (this.timestampUpdateTimer) { - clearInterval(this.timestampUpdateTimer); - this.timestampUpdateTimer = null; - } - } - - updateTimestamps() { - const notifications = this.manager.getNotifications(); - const timeElements = this.content.querySelectorAll('.notification-time'); - timeElements.forEach((el, index) => { - if (notifications[index]) { - const dateString = notifications[index].created_at || notifications[index].created; - el.textContent = this.renderer.formatTimeAgo(dateString); - } - }); - } - - adjustDropdownPosition() { - if (!this.dropdown || !this.bell) return; - - const rect = this.bell.getBoundingClientRect(); - const dropdown = this.dropdown; - const viewport = { - width: window.innerWidth, - height: window.innerHeight - }; - - dropdown.style.left = ''; - dropdown.style.right = ''; - dropdown.style.top = ''; - dropdown.style.bottom = ''; - - const dropdownWidth = 350; - const rightEdge = rect.right + dropdownWidth; - - if (rightEdge > viewport.width) { - dropdown.style.left = `${rect.left - dropdownWidth + rect.width}px`; - dropdown.style.right = 'auto'; - } else { - dropdown.style.left = `${rect.left}px`; - dropdown.style.right = 'auto'; - } - - const dropdownHeight = Math.min(400, viewport.height - 100); - const bottomEdge = rect.bottom + dropdownHeight; - - if (bottomEdge > viewport.height) { - dropdown.style.top = `${rect.top - dropdownHeight - 8}px`; - dropdown.style.bottom = 'auto'; - } else { - dropdown.style.top = `${rect.bottom + 8}px`; - dropdown.style.bottom = 'auto'; - } - - const finalRect = dropdown.getBoundingClientRect(); - - if (finalRect.left < 8) { - dropdown.style.left = '8px'; - } - - if (finalRect.right > viewport.width - 8) { - dropdown.style.right = '8px'; - dropdown.style.left = 'auto'; - } - - if (finalRect.top < 8) { - dropdown.style.top = '8px'; - } - - if (finalRect.bottom > viewport.height - 8) { - dropdown.style.bottom = '8px'; - dropdown.style.top = 'auto'; - } - } - - isPanelMode() { - const isPanel = this.wrapper && this.wrapper.classList.contains('notifications-mode-panel'); - return isPanel; - } - - - showBackdrop() { - const backdrop = document.getElementById('notificationsBackdrop'); - if (backdrop) { - if (backdrop.parentNode !== document.body) { - this.originalBackdropParent = backdrop.parentNode; - this.originalBackdropNextSibling = backdrop.nextSibling; - document.body.appendChild(backdrop); - } - - backdrop.style.display = 'block'; - backdrop.offsetHeight; - backdrop.classList.add('notifications-backdrop-visible'); - } - } - - hideBackdrop() { - const backdrop = document.getElementById('notificationsBackdrop'); - if (backdrop) { - backdrop.classList.remove('notifications-backdrop-visible'); - setTimeout(() => { - if (!this.isOpen) { - backdrop.style.display = 'none'; - this.moveBackdropBackToOriginal(); - } - }, 300); - } - } - - moveBackdropBackToOriginal() { - const backdrop = document.getElementById('notificationsBackdrop'); - if (backdrop && this.originalBackdropParent) { - if (this.originalBackdropNextSibling) { - this.originalBackdropParent.insertBefore(backdrop, this.originalBackdropNextSibling); - } else { - this.originalBackdropParent.appendChild(backdrop); - } - - this.originalBackdropParent = null; - this.originalBackdropNextSibling = null; - } - } - - destroy() { - this.stopTimestampUpdates(); - this.close(); - } -} - -if (typeof module !== 'undefined' && module.exports) { - module.exports = { NotificationWidget: CakeNotificationWidget }; -} else { - window.CakeNotificationWidget = CakeNotificationWidget; -} - diff --git a/webroot/js/PusherModule.js b/webroot/js/PusherModule.js new file mode 100644 index 0000000..2da9025 --- /dev/null +++ b/webroot/js/PusherModule.js @@ -0,0 +1,132 @@ +/** + * Pusher Broadcasting Module + * + * Extends BroadcastingBase for Pusher-specific WebSocket connection + */ +class PusherBroadcastingModule extends BroadcastingBase { + constructor(options) { + super(options); + this.options = { + pusherKey: 'app-key', + pusherHost: '127.0.0.1', + pusherPort: 8080, + pusherCluster: 'mt1', + ...this.options + }; + + this.echo = null; + } + + initializeConnection() { + if (typeof Echo === 'undefined') { + console.warn('Pusher Module: Laravel Echo not loaded'); + return; + } + + if (typeof Pusher === 'undefined') { + console.warn('Pusher Module: Pusher not loaded'); + return; + } + + window.Pusher = Pusher; + + this.echo = new Echo({ + broadcaster: 'pusher', + key: this.options.pusherKey, + cluster: this.options.pusherCluster, + wsHost: this.options.pusherHost, + wsPort: this.options.pusherPort, + wssPort: this.options.pusherPort, + wsPath: '', + disableStats: true, + enabledTransports: ['ws', 'wss'], + forceTLS: false, + }); + + window.Echo = this.echo; + + if (this.echo.connector && this.echo.connector.pusher) { + this.echo.connector.pusher.connection.bind('connected', () => { + this.connected = true; + this.subscribeToChannel(); + }); + + this.echo.connector.pusher.connection.bind('disconnected', () => { + this.connected = false; + }); + + this.echo.connector.pusher.connection.bind('error', (error) => { + console.warn('Pusher Module: Connection error', error); + }); + } + } + + subscribeToChannel() { + if (!this.options.channelName) { + console.warn('Pusher Module: No channel name provided'); + return; + } + + if (!this.echo) { + return; + } + + const channel = this.echo.private(this.options.channelName); + + channel.subscription.bind('pusher:subscription_succeeded', () => { + }); + + channel.subscription.bind('pusher:subscription_error', (error) => { + console.error('Pusher Module: Subscription error', error); + }); + + this.echo.connector.pusher.bind_global((eventName, data) => { + if (eventName.startsWith('pusher:')) return; + this.handleBroadcastEvent(eventName, data); + }); + } + + destroy() { + if (this.echo) { + this.echo.disconnect(); + } + super.destroy(); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const broadcastConfig = window.broadcastingConfig; + if (!broadcastConfig) return; + + if (broadcastConfig.broadcaster === 'mercure') { + return; + } + + const initBroadcasting = () => { + try { + if (typeof Alpine === 'undefined') { + setTimeout(initBroadcasting, 100); + return; + } + + const store = Alpine.store('notifications'); + if (!store) { + setTimeout(initBroadcasting, 100); + return; + } + const module = new PusherBroadcastingModule(broadcastConfig); + module.init(store); + } catch (e) { + console.error('Pusher Module: Initialization failed, retrying...', e.message); + setTimeout(initBroadcasting, 100); + } + }; + + setTimeout(initBroadcasting, 100); +}); + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { PusherBroadcastingModule }; +} else { + window.PusherBroadcastingModule = PusherBroadcastingModule; +} diff --git a/webroot/js/index.js b/webroot/js/index.js index 26aa60b..5e49f4e 100644 --- a/webroot/js/index.js +++ b/webroot/js/index.js @@ -1,108 +1,81 @@ /** - * CakePHP Notification System - Main Entry Point + * CakePHP Notification System - Main Entry Point (Alpine.js) * - * Provides a unified API for notifications with pluggable modules. - * Compatible with both old and new integration patterns. + * Initializes Alpine.js store and provides backward compatibility */ +(function() { + 'use strict'; -let managerInstance = null; -let widgetInstance = null; -let rendererInstance = null; + window.initializeNotifications = function(options = {}) { + const defaultOptions = { + apiUrl: '/notification/notifications/unread.json', + pollInterval: 30000, + enablePolling: true, + perPage: 10, + bellId: 'notificationsBell', + dropdownId: 'notificationsDropdown', + contentId: 'notificationsContent', + badgeId: 'notificationsBadge', + markReadOnClick: true, + }; -window.initializeNotifications = function(options = {}) { - const defaultOptions = { - apiUrl: '/notification/notifications/unread.json', - pollInterval: 30000, - enablePolling: true, - perPage: 10, - bellId: 'notificationsBell', - dropdownId: 'notificationsDropdown', - contentId: 'notificationsContent', - badgeId: 'notificationsBadge', - markReadOnClick: true, - }; - - const config = { ...defaultOptions, ...options }; + const config = { ...defaultOptions, ...options }; - rendererInstance = new window.CakeNotificationRenderer({ - markReadOnClick: config.markReadOnClick - }); + function init() { + if (typeof Alpine === 'undefined') { + console.warn('NotificationUI: Alpine.js not yet available, waiting...'); + setTimeout(init, 50); + return; + } - managerInstance = new window.CakeNotificationManager({ - apiUrl: config.apiUrl, - pollInterval: config.pollInterval, - enablePolling: config.enablePolling, - perPage: config.perPage - }); + const store = Alpine.store('notifications'); + if (store && store.manager) { + store.manager.options = { ...store.manager.options, ...config }; + if (config.initialUnreadCount !== undefined) { + store.unreadCount = config.initialUnreadCount; + } + if (config.enablePolling && config.apiUrl) { + store.manager.startPolling(store); + } + } else { + console.warn('NotificationUI: Store not yet registered, waiting...'); + setTimeout(init, 50); + return; + } - widgetInstance = new window.CakeNotificationWidget( - managerInstance, - rendererInstance, - { - bellId: config.bellId, - dropdownId: config.dropdownId, - contentId: config.contentId, - badgeId: config.badgeId + return { + store: store, + manager: store?.manager + }; } - ); - managerInstance.init(); + return init(); + }; - window.CakeNotificationManager.instance = managerInstance; + window.CakeNotificationManager = window.CakeNotificationManager || {}; window.CakeNotificationManager.get = () => { - if (!managerInstance) { - throw new Error('Notification manager not initialized. Call initializeNotifications() first.'); + if (typeof Alpine === 'undefined') { + throw new Error('Alpine.js is not loaded. Ensure Alpine.js is loaded before accessing the notification manager.'); } - return managerInstance; - }; - - window.CakeNotificationWidget.instance = widgetInstance; - window.CakeNotificationWidget.get = () => { - if (!widgetInstance) { - throw new Error('Notification widget not initialized. Call initializeNotifications() first.'); + const store = Alpine.store('notifications'); + if (!store) { + throw new Error('Notification store not initialized. Ensure Alpine.js is loaded and components are registered.'); } - return widgetInstance; + return store.manager || null; }; - window.CakeNotificationRenderer.instance = rendererInstance; - window.CakeNotificationRenderer.get = () => { - if (!rendererInstance) { - throw new Error('Notification renderer not initialized. Call initializeNotifications() first.'); - } - return rendererInstance; - }; + window.getNotificationManager = () => window.CakeNotificationManager.get(); - return { - manager: managerInstance, - widget: widgetInstance, - renderer: rendererInstance + window.registerNotificationModule = (module) => { + console.warn('registerNotificationModule is deprecated. Broadcasting modules now auto-initialize.'); + return null; }; -}; -window.CakeNotificationManager = window.CakeNotificationManager || {}; -window.CakeNotificationManager.get = () => { - if (!managerInstance) { - throw new Error('Notification manager not initialized. Call initializeNotifications() first.'); + if (typeof module !== 'undefined' && module.exports) { + module.exports = { + initializeNotifications: window.initializeNotifications, + CakeNotification: window.CakeNotification, + CakeNotificationAction: window.CakeNotificationAction, + }; } - return managerInstance; -}; - -window.getNotificationManager = () => window.CakeNotificationManager.get(); -window.getNotificationWidget = () => window.CakeNotificationWidget.get(); -window.getNotificationRenderer = () => window.CakeNotificationRenderer.get(); - -window.registerNotificationModule = (module) => { - const manager = window.CakeNotificationManager.get(); - return manager.registerModule(module); -}; - -if (typeof module !== 'undefined' && module.exports) { - module.exports = { - initializeNotifications: window.initializeNotifications, - CakeNotification: window.CakeNotification, - CakeNotificationAction: window.CakeNotificationAction, - CakeNotificationManager: window.CakeNotificationManager, - CakeNotificationRenderer: window.CakeNotificationRenderer, - CakeNotificationWidget: window.CakeNotificationWidget - }; -} +})(); diff --git a/webroot/js/vendor/alpine.js b/webroot/js/vendor/alpine.js new file mode 100644 index 0000000..2a6849c --- /dev/null +++ b/webroot/js/vendor/alpine.js @@ -0,0 +1,5 @@ +(()=>{var nt=!1,it=!1,G=[],ot=-1;function Ut(e){In(e)}function In(e){G.includes(e)||G.push(e),$n()}function Wt(e){let t=G.indexOf(e);t!==-1&&t>ot&&G.splice(t,1)}function $n(){!it&&!nt&&(nt=!0,queueMicrotask(Ln))}function Ln(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),F(i))},i},()=>{t()}]}function Oe(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>F(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function re(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Re(e){Xt.push(e)}function Te(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function pe(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){jn(),ut.disconnect(),ft=!1}var de=[];function jn(){let e=ut.takeRecords();de.push(()=>e.length>0&&mt(e));let t=de.length;queueMicrotask(()=>{if(de.length===t)for(;de.length>0;)de.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return pe(),t}var pt=!1,Ce=[];function rr(){pt=!0}function nr(){pt=!1,mt(Ce),Ce=[]}function mt(e){if(pt){Ce=Ce.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Me(e){return k(B(e))}function D(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function k(e){return new Proxy({objects:e},Fn)}var Fn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Bn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Bn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function ne(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Ne(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>zn(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function zn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function K(e,t){let r=Hn(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Hn(e){let[t,r]=_t(e),n={interceptor:Ne,...t};return re(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){ie(i,e,t)}}function ie(...e){return sr(...e)}var sr=Kn;function ar(e){sr=e}function Kn(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var oe=!0;function De(e){let t=oe;oe=!1;let r=e();return oe=t,r}function T(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return cr(...e)}var cr=xt;function lr(e){cr=e}var ur;function fr(e){ur=e}function xt(e,t){let r={};K(r,e);let n=[r,...B(e)],i=typeof t=="function"?Vn(n,t):Un(n,t,e);return or.bind(null,e,t,i)}function Vn(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!oe){me(r,t,k([n,...e]),i);return}let s=t.apply(k([n,...e]),i);me(r,s)}}var gt={};function qn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return ie(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Un(e,t,r){let n=qn(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=k([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>ie(u,r,t));n.finished?(me(i,n.result,c,s,r),n.result=void 0):l.then(u=>{me(i,u,c,s,r)}).catch(u=>ie(u,r,t)).finally(()=>n.result=void 0)}}}function me(e,t,r,n,i){if(oe&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>me(e,s,r,n)).catch(s=>ie(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}function dr(...e){return ur(...e)}function pr(e,t,r={}){let n={};K(n,e);let i=[n,...B(e)],o=k([r.scope??{},...i]),s=r.params??[];if(t.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&oe?l.apply(o,s):l}}var wt="x-";function C(e=""){return wt+e}function mr(e){wt=e}var ke={};function d(e,t){return ke[e]=t,{before(r){if(!ke[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=J.indexOf(r);J.splice(n>=0?n:J.indexOf("DEFAULT"),0,e)}}}function hr(e){return Object.keys(ke).includes(e)}function _e(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(xr((o,s)=>n[o]=s)).filter(br).map(Gn(n,r)).sort(Jn).map(o=>Wn(e,o))}function Et(e){return Array.from(e).map(xr()).filter(t=>!br(t))}var yt=!1,he=new Map,_r=Symbol();function gr(e){yt=!0;let t=Symbol();_r=t,he.set(t,[]);let r=()=>{for(;he.get(t).length;)he.get(t).shift()();he.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:z,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:T.bind(T,e)},()=>t.forEach(a=>a())]}function Wn(e,t){let r=()=>{},n=ke[t.type]||r,[i,o]=_t(e);Te(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?he.get(_r).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function xr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=yr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var yr=[];function se(e){yr.push(e)}function br({name:e}){return wr().test(e)}var wr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function Gn(e,t){return({name:r,value:n})=>{let i=r.match(wr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",J=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Jn(e,t){let r=J.indexOf(e.type)===-1?bt:e.type,n=J.indexOf(t.type)===-1?bt:t.type;return J.indexOf(r)-J.indexOf(n)}function Y(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function P(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>P(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)P(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Er=!1;function vr(){Er&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Er=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `