@navette-js/panels 0.0.0-stage → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD-PARTY-NOTICES.md +12 -0
- package/dist/FILAMENT-LICENSE.md +21 -0
- package/dist/filament/css/filament/filament/app.css +2 -0
- package/dist/filament/fonts/INTER-LICENSE.txt +92 -0
- package/dist/filament/fonts/filament/filament/inter/index.css +1 -0
- package/dist/filament/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-cyrillic-wght-normal-DR6K5BQD.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-greek-ext-wght-normal-A2J6H3EX.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-greek-wght-normal-ZABHMKQG.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-latin-ext-wght-normal-ZIT2UBIY.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-latin-wght-normal-DIHYUR35.woff2 +0 -0
- package/dist/filament/fonts/filament/filament/inter/inter-vietnamese-wght-normal-VWEHJHBA.woff2 +0 -0
- package/dist/filament/js/filament/actions/actions.js +1 -0
- package/dist/filament/js/filament/filament/app.js +1 -0
- package/dist/filament/js/filament/filament/echo.js +13 -0
- package/dist/filament/js/filament/forms/components/builder.js +1 -0
- package/dist/filament/js/filament/forms/components/checkbox-list.js +1 -0
- package/dist/filament/js/filament/forms/components/code-editor.js +38 -0
- package/dist/filament/js/filament/forms/components/color-picker.js +1 -0
- package/dist/filament/js/filament/forms/components/date-time-picker.js +1 -0
- package/dist/filament/js/filament/forms/components/file-upload.js +116 -0
- package/dist/filament/js/filament/forms/components/js-field.js +1 -0
- package/dist/filament/js/filament/forms/components/key-value.js +1 -0
- package/dist/filament/js/filament/forms/components/markdown-editor.js +51 -0
- package/dist/filament/js/filament/forms/components/repeater.js +1 -0
- package/dist/filament/js/filament/forms/components/rich-editor.js +161 -0
- package/dist/filament/js/filament/forms/components/select.js +11 -0
- package/dist/filament/js/filament/forms/components/slider.js +1 -0
- package/dist/filament/js/filament/forms/components/tags-input.js +1 -0
- package/dist/filament/js/filament/forms/components/text-input/password-reveal.js +1 -0
- package/dist/filament/js/filament/forms/components/textarea.js +1 -0
- package/dist/filament/js/filament/forms/components/toggle-buttons.js +1 -0
- package/dist/filament/js/filament/notifications/notifications.js +1 -0
- package/dist/filament/js/filament/schemas/components/actions.js +1 -0
- package/dist/filament/js/filament/schemas/components/js-component.js +1 -0
- package/dist/filament/js/filament/schemas/components/tabs.js +1 -0
- package/dist/filament/js/filament/schemas/components/wizard.js +1 -0
- package/dist/filament/js/filament/schemas/schemas.js +1 -0
- package/dist/filament/js/filament/support/support.js +46 -0
- package/dist/filament/js/filament/tables/components/columns/checkbox.js +1 -0
- package/dist/filament/js/filament/tables/components/columns/select.js +11 -0
- package/dist/filament/js/filament/tables/components/columns/text-input.js +1 -0
- package/dist/filament/js/filament/tables/components/columns/toggle.js +1 -0
- package/dist/filament/js/filament/tables/tables.js +1 -0
- package/dist/filament/js/filament/widgets/components/chart.js +31 -0
- package/dist/filament/js/filament/widgets/components/js-widget.js +1 -0
- package/dist/filament/js/filament/widgets/components/stats-overview/stat/chart.js +20 -0
- package/dist/navette/navette/calendar.css +108 -0
- package/dist/navette/navette/motion.css +101 -0
- package/dist/navette/navette/motion.js +49 -0
- package/lib/auth.d.ts +61 -0
- package/lib/auth.js +140 -0
- package/lib/calendar.d.ts +82 -0
- package/lib/calendar.js +107 -0
- package/lib/index.d.ts +1624 -0
- package/lib/index.js +3532 -0
- package/lib/notifications.d.ts +76 -0
- package/lib/notifications.js +112 -0
- package/lib/topbar.d.ts +90 -0
- package/lib/topbar.js +240 -0
- package/package.json +58 -4
- package/resources/filament/LICENSE.md +21 -0
- package/resources/filament/README.md +2 -0
- package/resources/filament/packages/actions/resources/css/actions.css +30 -0
- package/resources/filament/packages/actions/resources/css/index.css +1 -0
- package/resources/filament/packages/forms/dist/index.css +1 -0
- package/resources/filament/packages/forms/resources/css/components/builder.css +206 -0
- package/resources/filament/packages/forms/resources/css/components/checkbox-list.css +55 -0
- package/resources/filament/packages/forms/resources/css/components/code-editor.css +79 -0
- package/resources/filament/packages/forms/resources/css/components/color-picker.css +17 -0
- package/resources/filament/packages/forms/resources/css/components/date-time-picker.css +127 -0
- package/resources/filament/packages/forms/resources/css/components/field.css +88 -0
- package/resources/filament/packages/forms/resources/css/components/file-upload/filepond-plugin-media-preview.css +104 -0
- package/resources/filament/packages/forms/resources/css/components/file-upload.css +199 -0
- package/resources/filament/packages/forms/resources/css/components/key-value.css +57 -0
- package/resources/filament/packages/forms/resources/css/components/markdown-editor.css +343 -0
- package/resources/filament/packages/forms/resources/css/components/modal-table-select.css +17 -0
- package/resources/filament/packages/forms/resources/css/components/radio.css +41 -0
- package/resources/filament/packages/forms/resources/css/components/repeater.css +401 -0
- package/resources/filament/packages/forms/resources/css/components/rich-editor.css +666 -0
- package/resources/filament/packages/forms/resources/css/components/select.css +13 -0
- package/resources/filament/packages/forms/resources/css/components/slider.css +54 -0
- package/resources/filament/packages/forms/resources/css/components/tags-input.css +33 -0
- package/resources/filament/packages/forms/resources/css/components/text-input.css +20 -0
- package/resources/filament/packages/forms/resources/css/components/textarea.css +26 -0
- package/resources/filament/packages/forms/resources/css/components/toggle-buttons.css +49 -0
- package/resources/filament/packages/forms/resources/css/index.css +32 -0
- package/resources/filament/packages/infolists/resources/css/components/code.css +21 -0
- package/resources/filament/packages/infolists/resources/css/components/color.css +34 -0
- package/resources/filament/packages/infolists/resources/css/components/entry.css +68 -0
- package/resources/filament/packages/infolists/resources/css/components/icon.css +39 -0
- package/resources/filament/packages/infolists/resources/css/components/image.css +161 -0
- package/resources/filament/packages/infolists/resources/css/components/key-value.css +24 -0
- package/resources/filament/packages/infolists/resources/css/components/repeatable.css +136 -0
- package/resources/filament/packages/infolists/resources/css/components/text.css +234 -0
- package/resources/filament/packages/infolists/resources/css/index.css +14 -0
- package/resources/filament/packages/notifications/resources/css/database-notifications.css +56 -0
- package/resources/filament/packages/notifications/resources/css/index.css +3 -0
- package/resources/filament/packages/notifications/resources/css/notification.css +109 -0
- package/resources/filament/packages/notifications/resources/css/notifications.css +29 -0
- package/resources/filament/packages/panels/resources/css/base.css +13 -0
- package/resources/filament/packages/panels/resources/css/components/global-search.css +93 -0
- package/resources/filament/packages/panels/resources/css/components/header.css +56 -0
- package/resources/filament/packages/panels/resources/css/components/layout.css +182 -0
- package/resources/filament/packages/panels/resources/css/components/logo.css +11 -0
- package/resources/filament/packages/panels/resources/css/components/page.css +60 -0
- package/resources/filament/packages/panels/resources/css/components/sidebar.css +311 -0
- package/resources/filament/packages/panels/resources/css/components/tenant-menu.css +49 -0
- package/resources/filament/packages/panels/resources/css/components/theme-switcher.css +15 -0
- package/resources/filament/packages/panels/resources/css/components/topbar.css +89 -0
- package/resources/filament/packages/panels/resources/css/components/user-menu.css +46 -0
- package/resources/filament/packages/panels/resources/css/index.css +40 -0
- package/resources/filament/packages/panels/resources/css/theme.css +2 -0
- package/resources/filament/packages/panels/resources/css/utilities.css +3 -0
- package/resources/filament/packages/panels/resources/css/widgets/account-widget.css +21 -0
- package/resources/filament/packages/panels/resources/css/widgets/filament-info-widget.css +21 -0
- package/resources/filament/packages/schemas/resources/css/components/actions.css +29 -0
- package/resources/filament/packages/schemas/resources/css/components/flex.css +106 -0
- package/resources/filament/packages/schemas/resources/css/components/form.css +7 -0
- package/resources/filament/packages/schemas/resources/css/components/fused-group.css +337 -0
- package/resources/filament/packages/schemas/resources/css/components/icon.css +11 -0
- package/resources/filament/packages/schemas/resources/css/components/image.css +12 -0
- package/resources/filament/packages/schemas/resources/css/components/section.css +24 -0
- package/resources/filament/packages/schemas/resources/css/components/tabs.css +77 -0
- package/resources/filament/packages/schemas/resources/css/components/text.css +77 -0
- package/resources/filament/packages/schemas/resources/css/components/unordered-list.css +15 -0
- package/resources/filament/packages/schemas/resources/css/components/wizard.css +177 -0
- package/resources/filament/packages/schemas/resources/css/index.css +12 -0
- package/resources/filament/packages/schemas/resources/css/schema.css +132 -0
- package/resources/filament/packages/support/dist/index.css +1 -0
- package/resources/filament/packages/support/resources/css/base.css +23 -0
- package/resources/filament/packages/support/resources/css/components/avatar.css +15 -0
- package/resources/filament/packages/support/resources/css/components/badge.css +90 -0
- package/resources/filament/packages/support/resources/css/components/breadcrumbs.css +25 -0
- package/resources/filament/packages/support/resources/css/components/button.css +214 -0
- package/resources/filament/packages/support/resources/css/components/callout.css +57 -0
- package/resources/filament/packages/support/resources/css/components/dropdown/header.css +21 -0
- package/resources/filament/packages/support/resources/css/components/dropdown/index.css +79 -0
- package/resources/filament/packages/support/resources/css/components/dropdown/list/index.css +7 -0
- package/resources/filament/packages/support/resources/css/components/dropdown/list/item.css +74 -0
- package/resources/filament/packages/support/resources/css/components/empty-state.css +67 -0
- package/resources/filament/packages/support/resources/css/components/fieldset.css +23 -0
- package/resources/filament/packages/support/resources/css/components/grid.css +511 -0
- package/resources/filament/packages/support/resources/css/components/icon-button.css +124 -0
- package/resources/filament/packages/support/resources/css/components/icon.css +31 -0
- package/resources/filament/packages/support/resources/css/components/input/checkbox.css +32 -0
- package/resources/filament/packages/support/resources/css/components/input/index.css +55 -0
- package/resources/filament/packages/support/resources/css/components/input/one-time-code.css +7 -0
- package/resources/filament/packages/support/resources/css/components/input/radio.css +11 -0
- package/resources/filament/packages/support/resources/css/components/input/select.css +154 -0
- package/resources/filament/packages/support/resources/css/components/input/wrapper.css +107 -0
- package/resources/filament/packages/support/resources/css/components/link.css +90 -0
- package/resources/filament/packages/support/resources/css/components/loading-indicator.css +3 -0
- package/resources/filament/packages/support/resources/css/components/loading-section.css +3 -0
- package/resources/filament/packages/support/resources/css/components/modal.css +494 -0
- package/resources/filament/packages/support/resources/css/components/pagination.css +160 -0
- package/resources/filament/packages/support/resources/css/components/section.css +234 -0
- package/resources/filament/packages/support/resources/css/components/tabs.css +70 -0
- package/resources/filament/packages/support/resources/css/components/toggle.css +60 -0
- package/resources/filament/packages/support/resources/css/components/tooltip.css +36 -0
- package/resources/filament/packages/support/resources/css/index.css +133 -0
- package/resources/filament/packages/support/resources/css/sortable.css +3 -0
- package/resources/filament/packages/support/resources/css/utilities.css +897 -0
- package/resources/filament/packages/tables/resources/css/actions.css +51 -0
- package/resources/filament/packages/tables/resources/css/cell.css +139 -0
- package/resources/filament/packages/tables/resources/css/column-manager.css +48 -0
- package/resources/filament/packages/tables/resources/css/columns/checkbox.css +54 -0
- package/resources/filament/packages/tables/resources/css/columns/color.css +76 -0
- package/resources/filament/packages/tables/resources/css/columns/icon.css +81 -0
- package/resources/filament/packages/tables/resources/css/columns/image.css +207 -0
- package/resources/filament/packages/tables/resources/css/columns/layout/grid.css +49 -0
- package/resources/filament/packages/tables/resources/css/columns/layout/panel.css +3 -0
- package/resources/filament/packages/tables/resources/css/columns/layout/split.css +35 -0
- package/resources/filament/packages/tables/resources/css/columns/layout/stack.css +29 -0
- package/resources/filament/packages/tables/resources/css/columns/select.css +36 -0
- package/resources/filament/packages/tables/resources/css/columns/summaries/icon-count.css +43 -0
- package/resources/filament/packages/tables/resources/css/columns/summaries/range.css +27 -0
- package/resources/filament/packages/tables/resources/css/columns/summaries/text.css +29 -0
- package/resources/filament/packages/tables/resources/css/columns/summaries/values.css +33 -0
- package/resources/filament/packages/tables/resources/css/columns/text-input.css +36 -0
- package/resources/filament/packages/tables/resources/css/columns/text.css +319 -0
- package/resources/filament/packages/tables/resources/css/columns/toggle.css +54 -0
- package/resources/filament/packages/tables/resources/css/container.css +311 -0
- package/resources/filament/packages/tables/resources/css/content.css +342 -0
- package/resources/filament/packages/tables/resources/css/empty-state.css +31 -0
- package/resources/filament/packages/tables/resources/css/group-header.css +54 -0
- package/resources/filament/packages/tables/resources/css/header-cell.css +224 -0
- package/resources/filament/packages/tables/resources/css/index.css +26 -0
- package/resources/filament/packages/tables/resources/css/row.css +47 -0
- package/resources/filament/packages/tables/resources/css/table.css +394 -0
- package/resources/filament/packages/widgets/resources/css/chart-widget.css +88 -0
- package/resources/filament/packages/widgets/resources/css/index.css +3 -0
- package/resources/filament/packages/widgets/resources/css/stats-overview-widget.css +67 -0
- package/resources/filament/packages/widgets/resources/css/widget.css +3 -0
- package/theme.css +17 -0
- package/views/components/logo.ejs +9 -0
- package/views/components/tenant-menu.ejs +13 -0
- package/views/components/user-menu.ejs +82 -0
- package/views/layout/base.ejs +129 -0
- package/views/layout/index.ejs +41 -0
- package/views/layout/simple.ejs +19 -0
- package/views/livewire/sidebar.ejs +123 -0
- package/views/livewire/topbar.ejs +47 -0
- package/views/pages/page.ejs +143 -0
- package/views/pages/simple.ejs +32 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Component } from '@navette-js/wire';
|
|
2
|
+
export type NotificationActionData = {
|
|
3
|
+
label: string;
|
|
4
|
+
url: string | null;
|
|
5
|
+
openUrlInNewTab: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type NotificationData = {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string | null;
|
|
10
|
+
body: string | null;
|
|
11
|
+
status: string | null;
|
|
12
|
+
duration: number | 'persistent';
|
|
13
|
+
icon: string | null;
|
|
14
|
+
iconColor: string | null;
|
|
15
|
+
actions?: NotificationActionData[];
|
|
16
|
+
};
|
|
17
|
+
/** The part of a notification store needed to write to it (see `DatabaseNotificationStore`). */
|
|
18
|
+
type NotificationSink = {
|
|
19
|
+
send(user: string | number, notification: NotificationData): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
/** Runs `fn` in a request context, where `Notification.send()` queues its notifications. */
|
|
22
|
+
export declare function runInRequest<T>(mailbox: string, fn: () => Promise<T>): Promise<{
|
|
23
|
+
result: T;
|
|
24
|
+
pending: NotificationData[];
|
|
25
|
+
}>;
|
|
26
|
+
export declare function publish(mailbox: string, notifications: NotificationData[]): string;
|
|
27
|
+
/** Removes and returns the notifications of the given batches (all of them if `batches` is null). */
|
|
28
|
+
export declare function pull(mailbox: string, batches: string[] | null): NotificationData[];
|
|
29
|
+
export declare class Notification {
|
|
30
|
+
private data;
|
|
31
|
+
static make(): Notification;
|
|
32
|
+
title(title: string): this;
|
|
33
|
+
body(body: string): this;
|
|
34
|
+
status(status: 'success' | 'danger' | 'warning' | 'info'): this;
|
|
35
|
+
success(): this;
|
|
36
|
+
danger(): this;
|
|
37
|
+
warning(): this;
|
|
38
|
+
info(): this;
|
|
39
|
+
icon(name: string): this;
|
|
40
|
+
iconColor(color: string): this;
|
|
41
|
+
duration(ms: number): this;
|
|
42
|
+
persistent(): this;
|
|
43
|
+
/** Links shown under the text. Only displayed in the database notifications panel. */
|
|
44
|
+
actions(actions: {
|
|
45
|
+
label: string;
|
|
46
|
+
url?: string | null;
|
|
47
|
+
openUrlInNewTab?: boolean;
|
|
48
|
+
}[]): this;
|
|
49
|
+
getData(): NotificationData;
|
|
50
|
+
/** Stores the notification for this user (panel bell): `await Notification.make()….sendToDatabase(panel.getDatabaseNotifications(), user.id)`. Does nothing without a store. */
|
|
51
|
+
sendToDatabase(store: NotificationSink | null, user: string | number): Promise<this>;
|
|
52
|
+
/** Queues the notification for the browser of the current request. */
|
|
53
|
+
send(): this;
|
|
54
|
+
}
|
|
55
|
+
/** Renders a flash notification (no date, actions or inline mode). Title and body are escaped. */
|
|
56
|
+
export declare function renderNotification(n: NotificationData): string;
|
|
57
|
+
/** Container component for flash notifications. */
|
|
58
|
+
export declare class Notifications extends Component {
|
|
59
|
+
static callable: string[];
|
|
60
|
+
isFilamentNotificationsComponent: boolean;
|
|
61
|
+
notifications: Record<string, NotificationData>;
|
|
62
|
+
hasPulledNotificationsOnMount: boolean;
|
|
63
|
+
static locked: string[];
|
|
64
|
+
static listeners: {
|
|
65
|
+
notificationsSent: string;
|
|
66
|
+
notificationClosed: string;
|
|
67
|
+
};
|
|
68
|
+
private get mailbox();
|
|
69
|
+
/** On a full document load (not SPA navigation or prefetch), pending notifications are pulled right away. */
|
|
70
|
+
mount(): void;
|
|
71
|
+
pullNotifications(batches?: unknown): void;
|
|
72
|
+
pullNotificationsFromSession(batches?: unknown): void;
|
|
73
|
+
removeNotification(id: unknown): void;
|
|
74
|
+
render(): string;
|
|
75
|
+
}
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Flash notifications: the Notification builder, a per-browser mailbox, and the component that displays them.
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import { Attributes, e, icon, js } from '@navette-js/support';
|
|
5
|
+
import { t } from '@navette-js/support/i18n';
|
|
6
|
+
import { Component, WireError } from '@navette-js/wire';
|
|
7
|
+
const requestStore = new AsyncLocalStorage();
|
|
8
|
+
/** Runs `fn` in a request context, where `Notification.send()` queues its notifications. */
|
|
9
|
+
export function runInRequest(mailbox, fn) {
|
|
10
|
+
const store = { mailbox, pending: [] };
|
|
11
|
+
return requestStore.run(store, async () => ({ result: await fn(), pending: store.pending }));
|
|
12
|
+
}
|
|
13
|
+
// In-memory mailbox, keyed by browser (the CSRF token) then by batch.
|
|
14
|
+
const mailboxes = new Map();
|
|
15
|
+
const TTL = 5 * 60 * 1000;
|
|
16
|
+
export function publish(mailbox, notifications) {
|
|
17
|
+
const batch = randomBytes(20).toString('hex');
|
|
18
|
+
const box = mailboxes.get(mailbox) ?? new Map();
|
|
19
|
+
for (const [key, entry] of box)
|
|
20
|
+
if (Date.now() - entry.at > TTL)
|
|
21
|
+
box.delete(key);
|
|
22
|
+
box.set(batch, { at: Date.now(), notifications });
|
|
23
|
+
mailboxes.set(mailbox, box);
|
|
24
|
+
return batch;
|
|
25
|
+
}
|
|
26
|
+
/** Removes and returns the notifications of the given batches (all of them if `batches` is null). */
|
|
27
|
+
export function pull(mailbox, batches) {
|
|
28
|
+
const box = mailboxes.get(mailbox);
|
|
29
|
+
if (!box)
|
|
30
|
+
return [];
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const key of batches ?? [...box.keys()]) {
|
|
33
|
+
const entry = box.get(key);
|
|
34
|
+
if (entry) {
|
|
35
|
+
out.push(...entry.notifications);
|
|
36
|
+
box.delete(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
const STATUS_ICON = { danger: 'o-x-circle', info: 'o-information-circle', success: 'o-check-circle', warning: 'o-exclamation-circle' };
|
|
42
|
+
export class Notification {
|
|
43
|
+
data = { id: randomUUID(), title: null, body: null, status: null, duration: 6000, icon: null, iconColor: null };
|
|
44
|
+
static make() { return new Notification(); }
|
|
45
|
+
title(title) { this.data.title = title; return this; }
|
|
46
|
+
body(body) { this.data.body = body; return this; }
|
|
47
|
+
status(status) { this.data.status = status; return this; }
|
|
48
|
+
success() { return this.status('success'); }
|
|
49
|
+
danger() { return this.status('danger'); }
|
|
50
|
+
warning() { return this.status('warning'); }
|
|
51
|
+
info() { return this.status('info'); }
|
|
52
|
+
icon(name) { this.data.icon = name; return this; }
|
|
53
|
+
iconColor(color) { this.data.iconColor = color; return this; }
|
|
54
|
+
duration(ms) { this.data.duration = ms; return this; }
|
|
55
|
+
persistent() { this.data.duration = 'persistent'; return this; }
|
|
56
|
+
/** Links shown under the text. Only displayed in the database notifications panel. */
|
|
57
|
+
actions(actions) { this.data.actions = actions.slice(0, 10).map((action) => ({ label: String(action.label), url: action.url ?? null, openUrlInNewTab: action.openUrlInNewTab === true })); return this; }
|
|
58
|
+
getData() { return { ...this.data }; }
|
|
59
|
+
/** Stores the notification for this user (panel bell): `await Notification.make()….sendToDatabase(panel.getDatabaseNotifications(), user.id)`. Does nothing without a store. */
|
|
60
|
+
async sendToDatabase(store, user) { if (store)
|
|
61
|
+
await store.send(user, { ...this.data, id: randomUUID() }); return this; }
|
|
62
|
+
/** Queues the notification for the browser of the current request. */
|
|
63
|
+
send() {
|
|
64
|
+
const store = requestStore.getStore();
|
|
65
|
+
if (!store)
|
|
66
|
+
throw new Error('Notification.send() must be called while handling a request.');
|
|
67
|
+
store.pending.push({ ...this.data });
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Renders a flash notification (no date, actions or inline mode). Title and body are escaped. */
|
|
72
|
+
export function renderNotification(n) {
|
|
73
|
+
const closeLabel = t('filament-notifications::notification.actions.close.label');
|
|
74
|
+
const attributes = new Attributes()
|
|
75
|
+
.merge({ role: n.status === 'danger' ? 'alert' : null, 'wire:key': `${n.id}.notifications.${n.id}`, 'x-on:close-notification.window': `if ($event.detail.id == ${js(n.id)}) close()` })
|
|
76
|
+
.class(['fi-no-notification', n.status ? `fi-status-${n.status}` : null]);
|
|
77
|
+
const iconName = n.icon ?? (n.status ? STATUS_ICON[n.status] : null);
|
|
78
|
+
const iconColor = n.iconColor ?? n.status;
|
|
79
|
+
const iconHtml = iconName ? icon(iconName, { size: 'lg', attributes: new Attributes().class([...(iconColor && iconColor !== 'gray' ? ['fi-color', `fi-color-${iconColor}`] : []), 'fi-no-notification-icon']) }) : '';
|
|
80
|
+
return `<div x-data="notificationComponent({ notification: ${e(js({ id: n.id, duration: n.duration }))} })" x-transition:enter-start="fi-transition-enter-start" x-transition:enter-end="fi-transition-enter-end" x-transition:leave-start="fi-transition-leave-start" x-transition:leave-end="fi-transition-leave-end" x-on:mouseenter="pauseDuration('hover')" x-on:mouseleave="resumeDuration('hover')" x-on:focusin="handleFocusIn($event)" x-on:focusout="handleFocusOut($event)" x-on:keydown.escape="handleEscape($event)" ${attributes.toHtml().replace(/"if \(\$event\.detail\.id == '([^']*)'\) close\(\)"/, (_m, id) => `"if ($event.detail.id == '${id}') close()"`)}>${iconHtml}<div class="fi-no-notification-main">${n.title !== null || n.body !== null ? `<div class="fi-no-notification-text">${n.title !== null ? `<h3 class="fi-no-notification-title">${e(n.title)}</h3>` : ''}${n.body !== null ? `<div class="fi-no-notification-body">${e(n.body)}</div>` : ''}</div>` : ''}</div><button type="button" x-on:click="dismiss()" aria-label="${e(closeLabel)}" title="${e(closeLabel)}" class="fi-icon-btn fi-no-notification-close-btn">${icon('m-x-mark')}</button></div>`;
|
|
81
|
+
}
|
|
82
|
+
/** Container component for flash notifications. */
|
|
83
|
+
export class Notifications extends Component {
|
|
84
|
+
static callable = ['pullNotifications'];
|
|
85
|
+
isFilamentNotificationsComponent = true;
|
|
86
|
+
notifications = {};
|
|
87
|
+
hasPulledNotificationsOnMount = false;
|
|
88
|
+
static locked = ['isFilamentNotificationsComponent', 'notifications', 'hasPulledNotificationsOnMount'];
|
|
89
|
+
static listeners = { notificationsSent: 'pullNotifications', notificationClosed: 'removeNotification' };
|
|
90
|
+
get mailbox() { return String(this._ctx?.mailbox ?? ''); }
|
|
91
|
+
/** On a full document load (not SPA navigation or prefetch), pending notifications are pulled right away. */
|
|
92
|
+
mount() {
|
|
93
|
+
const req = this._ctx?.req;
|
|
94
|
+
if (!req || req.method !== 'GET' || req.headers['sec-fetch-dest'] !== 'document' || req.headers['x-livewire-navigate'] !== undefined || String(req.headers['sec-purpose'] ?? '').toLowerCase().includes('prefetch'))
|
|
95
|
+
return;
|
|
96
|
+
for (const n of pull(this.mailbox, null))
|
|
97
|
+
this.notifications[n.id] = n;
|
|
98
|
+
this.hasPulledNotificationsOnMount = true;
|
|
99
|
+
}
|
|
100
|
+
pullNotifications(batches = []) {
|
|
101
|
+
if (!Array.isArray(batches) || batches.length > 50 || batches.some((b) => typeof b !== 'string' || !/^[a-f0-9]{40}$/.test(b)))
|
|
102
|
+
throw new WireError(422, 'Invalid notification batches.');
|
|
103
|
+
for (const n of pull(this.mailbox, batches))
|
|
104
|
+
this.notifications[n.id] = n;
|
|
105
|
+
}
|
|
106
|
+
pullNotificationsFromSession(batches = []) { this.pullNotifications(batches); }
|
|
107
|
+
removeNotification(id) { if (typeof id === 'string')
|
|
108
|
+
delete this.notifications[id]; }
|
|
109
|
+
render() {
|
|
110
|
+
return `<div x-data="notificationsComponent()" data-filament-notifications="${this.hasPulledNotificationsOnMount ? 'mounted' : 'pending'}"><div class="fi-no fi-align-right fi-vertical-align-start" role="status" aria-atomic="false">${Object.values(this.notifications).map(renderNotification).join('')}</div></div>`;
|
|
111
|
+
}
|
|
112
|
+
}
|
package/lib/topbar.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Component } from '@navette-js/wire';
|
|
2
|
+
import type { NotificationData } from './notifications.ts';
|
|
3
|
+
/** Notification body: HTML is sanitized (rich editor tags only), plain text is escaped. */
|
|
4
|
+
export declare const notificationBodyHtml: (body: string) => string;
|
|
5
|
+
export type GlobalSearchResult = {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
details: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
export type GlobalSearchCategory = {
|
|
11
|
+
label: string;
|
|
12
|
+
results: GlobalSearchResult[];
|
|
13
|
+
};
|
|
14
|
+
/** Search field suffix showing the first key binding for the browser's platform; `null` if the platform is unknown. */
|
|
15
|
+
export declare function keyBindingSuffix(keyBindings: string[], userAgent: string): string | null;
|
|
16
|
+
export declare class GlobalSearch extends Component {
|
|
17
|
+
search: string;
|
|
18
|
+
render(): Promise<string>;
|
|
19
|
+
}
|
|
20
|
+
export type DatabaseNotificationAction = {
|
|
21
|
+
label: string;
|
|
22
|
+
url: string | null;
|
|
23
|
+
openUrlInNewTab: boolean;
|
|
24
|
+
};
|
|
25
|
+
export type DatabaseNotificationRecord = {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string | null;
|
|
28
|
+
body: string | null;
|
|
29
|
+
icon: string | null;
|
|
30
|
+
iconColor: string | null;
|
|
31
|
+
status: string | null;
|
|
32
|
+
actions: DatabaseNotificationAction[];
|
|
33
|
+
createdAt: Date | null;
|
|
34
|
+
unread: boolean;
|
|
35
|
+
};
|
|
36
|
+
/** Storage for a user's notifications. `knexNotificationStore()` provides one backed by a `notifications` table. */
|
|
37
|
+
export interface DatabaseNotificationStore {
|
|
38
|
+
/** Most recent first; `offset` is the number of notifications to skip (pagination). */
|
|
39
|
+
list(user: string | number, limit: number, offset?: number): Promise<DatabaseNotificationRecord[]>;
|
|
40
|
+
count(user: string | number): Promise<number>;
|
|
41
|
+
unreadCount(user: string | number): Promise<number>;
|
|
42
|
+
send(user: string | number, notification: NotificationData & {
|
|
43
|
+
actions?: DatabaseNotificationAction[];
|
|
44
|
+
}): Promise<void>;
|
|
45
|
+
delete(user: string | number, id: string): Promise<void>;
|
|
46
|
+
markAsRead(user: string | number, id: string, read: boolean): Promise<void>;
|
|
47
|
+
markAllAsRead(user: string | number): Promise<void>;
|
|
48
|
+
clear(user: string | number): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Store backed by a `notifications` table (polymorphic `notifiable_type`/`notifiable_id`, JSON `data` with `format: 'filament'`).
|
|
52
|
+
* Existing rows in that format are read as-is. `mapUrl` rewrites action URLs read from the table.
|
|
53
|
+
*/
|
|
54
|
+
export declare function knexNotificationStore(knex: any, options?: {
|
|
55
|
+
table?: string;
|
|
56
|
+
notifiableType?: string;
|
|
57
|
+
mapUrl?: (url: string) => string;
|
|
58
|
+
}): DatabaseNotificationStore;
|
|
59
|
+
/** Relative time in English, e.g. "2 days ago". */
|
|
60
|
+
export declare function timeAgo(date: Date, now?: Date): string;
|
|
61
|
+
/** Bell and slide-over listing the signed-in user's database notifications. */
|
|
62
|
+
export declare class DatabaseNotifications extends Component {
|
|
63
|
+
static callable: string[];
|
|
64
|
+
/** Current page of the panel (simple pagination). */
|
|
65
|
+
paginators: Record<string, number>;
|
|
66
|
+
private get notificationsPage();
|
|
67
|
+
previousPage(): void;
|
|
68
|
+
nextPage(): void;
|
|
69
|
+
gotoPage(page: unknown): void;
|
|
70
|
+
static listeners: {
|
|
71
|
+
databaseNotificationsSent: string;
|
|
72
|
+
notificationClosed: string;
|
|
73
|
+
markedNotificationAsRead: string;
|
|
74
|
+
markedNotificationAsUnread: string;
|
|
75
|
+
};
|
|
76
|
+
/** Notifications per page. */
|
|
77
|
+
static limit: number;
|
|
78
|
+
isOpen: boolean;
|
|
79
|
+
/** Store and user come from the server: every operation is restricted to this user's notifications. */
|
|
80
|
+
private get scope();
|
|
81
|
+
refresh(): void;
|
|
82
|
+
removeNotification(id: unknown): Promise<void>;
|
|
83
|
+
markNotificationAsRead(id: unknown): Promise<void>;
|
|
84
|
+
markNotificationAsUnread(id: unknown): Promise<void>;
|
|
85
|
+
clearNotifications(): Promise<void>;
|
|
86
|
+
markAllNotificationsAsRead(): Promise<void>;
|
|
87
|
+
private trigger;
|
|
88
|
+
placeholder(): Promise<string>;
|
|
89
|
+
render(): Promise<string>;
|
|
90
|
+
}
|
package/lib/topbar.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Global search and database notifications: two components that live in the panel sidebar.
|
|
2
|
+
import { Attributes, e, icon, js, loadingIndicator, renderHook } from '@navette-js/support';
|
|
3
|
+
import { t } from '@navette-js/support/i18n';
|
|
4
|
+
import { Component, WireError } from '@navette-js/wire';
|
|
5
|
+
import { richDocumentToHtml, richHtmlToDocument } from '@navette-js/forms';
|
|
6
|
+
import { pagination } from '@navette-js/support/ui';
|
|
7
|
+
/** Notification body: HTML is sanitized (rich editor tags only), plain text is escaped. */
|
|
8
|
+
export const notificationBodyHtml = (body) => (/<[a-z][\s\S]*>/i.test(body) ? (richDocumentToHtml(richHtmlToDocument(body)) ?? '') : e(body));
|
|
9
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
const safeIcon = (name, size, classes) => {
|
|
11
|
+
if (!name || !/^[a-z0-9-]+$/.test(name))
|
|
12
|
+
return '';
|
|
13
|
+
try {
|
|
14
|
+
return icon(name, { size, attributes: new Attributes().class(classes) });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/** Only a same-site path (`/…`) or an http(s) URL can become a link (no `javascript:` or protocol-relative URLs). */
|
|
21
|
+
const safeUrl = (url) => (typeof url === 'string' && /^(\/(?!\/)|https?:\/\/)/.test(url) ? url : null);
|
|
22
|
+
/** Search field suffix showing the first key binding for the browser's platform; `null` if the platform is unknown. */
|
|
23
|
+
export function keyBindingSuffix(keyBindings, userAgent) {
|
|
24
|
+
const first = keyBindings[0];
|
|
25
|
+
if (!first)
|
|
26
|
+
return null;
|
|
27
|
+
if (/Macintosh|Mac OS/i.test(userAgent))
|
|
28
|
+
return first.replace(/alt|option/g, '⌥').replace(/meta|command|mod/g, '⌘').replace(/ctrl/g, '⌃').toUpperCase();
|
|
29
|
+
if (!/Windows|Linux/i.test(userAgent))
|
|
30
|
+
return null;
|
|
31
|
+
return first.replace(/option/g, 'alt').replace(/command/g, 'meta').replace(/mod/g, 'ctrl').toUpperCase();
|
|
32
|
+
}
|
|
33
|
+
export class GlobalSearch extends Component {
|
|
34
|
+
search = '';
|
|
35
|
+
async render() {
|
|
36
|
+
const { panel, user, req } = (this._ctx ?? {});
|
|
37
|
+
if (!user || !panel)
|
|
38
|
+
return '<div class="fi-global-search-ctn"></div>';
|
|
39
|
+
const search = (typeof this.search === 'string' ? this.search : '').slice(0, 1000).trim();
|
|
40
|
+
this.search = typeof this.search === 'string' ? this.search.slice(0, 1000) : '';
|
|
41
|
+
const categories = search === '' ? null : await panel.getGlobalSearchResults(search, user);
|
|
42
|
+
if (categories !== null)
|
|
43
|
+
this.dispatch('open-global-search-results');
|
|
44
|
+
const count = categories?.reduce((total, category) => total + category.results.length, 0) ?? 0;
|
|
45
|
+
const [one, many] = t('filament-panels::global-search.results_count').split('|');
|
|
46
|
+
const message = count ? (count === 1 ? one : (many ?? one)).replace(':count', String(count)) : t('filament-panels::global-search.no_results_message');
|
|
47
|
+
const id = `global-search-${this.id}`;
|
|
48
|
+
const bindings = panel.getGlobalSearchKeyBindings();
|
|
49
|
+
const suffix = panel.getGlobalSearchFieldSuffix(String(req?.headers?.['user-agent'] ?? ''));
|
|
50
|
+
const input = `<input ${new Attributes({
|
|
51
|
+
autocomplete: 'off', maxlength: '1000', placeholder: t('filament-panels::global-search.field.placeholder'), type: 'search', 'wire:key': 'global-search.field.input', id: `${id}-input`,
|
|
52
|
+
'aria-controls': `${id}-results`, 'aria-describedby': `${id}-instructions`, 'x-ref': 'input', [`wire:model.live.debounce.${panel.getGlobalSearchDebounce()}`]: 'search',
|
|
53
|
+
...(bindings.length ? { [`x-mousetrap.global.${bindings.map((binding) => binding.replace(/\+/g, '-')).join('.')}`]: '$refs.input?.focus()' } : {}),
|
|
54
|
+
}).class(['fi-input', 'fi-input-has-inline-prefix']).toHtml()} />`;
|
|
55
|
+
const focus = "if (! $event.target.closest('.fi-input-wrp-actions')) $dispatch('focus-input')";
|
|
56
|
+
const field = `<div class="fi-input-wrp"><div x-on:click="${e(focus)}" class="fi-input-wrp-prefix fi-input-wrp-prefix-has-content fi-inline">${icon('m-magnifying-glass', { attributes: new Attributes({ 'wire:loading.remove.delay.default': '1', 'wire:target': 'search' }) })}${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': 'search' }))}</div><div class="fi-input-wrp-content-ctn">${input}</div>${suffix ? `<div x-on:click="${e(focus)}" class="fi-input-wrp-suffix fi-inline fi-input-wrp-suffix-has-label"><span class="fi-input-wrp-label">${e(suffix)}</span></div>` : ''}</div>`;
|
|
57
|
+
let results = '';
|
|
58
|
+
if (categories !== null) {
|
|
59
|
+
results = categories.length === 0
|
|
60
|
+
? `<p class="fi-global-search-no-results-message">${e(t('filament-panels::global-search.no_results_message'))}</p>`
|
|
61
|
+
: `<ul class="fi-global-search-results">${categories.map((category, groupIndex) => {
|
|
62
|
+
const groupId = `${id}-group-${groupIndex}`;
|
|
63
|
+
return `<li class="fi-global-search-result-group"><h3 id="${e(groupId)}" class="fi-global-search-result-group-header">${e(category.label)}</h3><ul class="fi-global-search-result-group-results">${category.results.map((result, index) => {
|
|
64
|
+
const details = Object.entries(result.details);
|
|
65
|
+
return `<li class="fi-global-search-result"><a href="${e(result.url)}" wire:navigate class="fi-global-search-result-link"><h4 id="${e(`${groupId}-result-${index}`)}" class="fi-global-search-result-heading">${e(result.title)}</h4>${details.length ? `<dl class="fi-global-search-result-details">${details.map(([label, value]) => `<div class="fi-global-search-result-detail"><dt class="fi-global-search-result-detail-label">${e(label)}:</dt><dd class="fi-global-search-result-detail-value">${e(value)}</dd></div>`).join('')}</dl>` : ''}</a></li>`;
|
|
66
|
+
}).join('')}</ul></li>`;
|
|
67
|
+
}).join('')}</ul>`;
|
|
68
|
+
}
|
|
69
|
+
const root = new Attributes({
|
|
70
|
+
'x-data': `filamentGlobalSearch({ loadingMessage: ${js(t('filament-panels::global-search.loading_message'))}, failureMessage: ${js(t('filament-panels::global-search.failure_message'))}, })`,
|
|
71
|
+
'x-on:input': 'if ($event.target === $refs.input) inputChanged($event.target.value)', 'x-on:focusin': 'if ($event.target === $refs.input) reopen()', 'x-on:focusout': 'focusLeft($event)',
|
|
72
|
+
'x-on:keydown': 'keydown($event)', 'x-on:click.outside': 'dismiss()',
|
|
73
|
+
'x-on:open-modal.window.capture': 'if ($refs.results.contains(document.activeElement)) {\n dismiss()\n restoreFocus()\n }',
|
|
74
|
+
'x-on:livewire:navigate.window': 'navigationStarted($event)', 'x-on:livewire:navigated.window': 'navigationCompleted()',
|
|
75
|
+
}).class(['fi-global-search']);
|
|
76
|
+
const region = new Attributes({
|
|
77
|
+
id: `${id}-results`, role: 'region', 'aria-label': t('filament-panels::global-search.results_label'), 'data-query': search, 'data-has-results': categories !== null ? 'true' : 'false', 'data-message': message,
|
|
78
|
+
'x-ref': 'results', 'x-show': 'isOpen', 'x-bind:inert': '! isOpen', 'x-bind:aria-hidden': '! isOpen', 'x-cloak': '', 'x-on:mousedown.self.prevent': '', 'x-on:click': 'linkActivated($event)',
|
|
79
|
+
'x-transition:enter-start': 'fi-transition-enter-start', 'x-transition:leave-end': 'fi-transition-leave-end',
|
|
80
|
+
}).class(['fi-global-search-results-ctn']);
|
|
81
|
+
return `<div class="fi-global-search-ctn">${renderHook('panels::global-search.start')}<div ${root.toHtml()}><div class="fi-global-search-field"><label for="${e(id)}-input" class="fi-sr-only">${e(t('filament-panels::global-search.field.label'))}</label>${field}</div><p id="${e(id)}-instructions" class="fi-sr-only">${e(t('filament-panels::global-search.field.instructions'))}</p><p wire:ignore role="status" aria-atomic="true" x-text="status" class="fi-sr-only"></p><div ${region.toHtml()}>${results}</div></div>${renderHook('panels::global-search.end')}</div>`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const stamp = (date) => date.toISOString().slice(0, 19).replace('T', ' ');
|
|
85
|
+
/**
|
|
86
|
+
* Store backed by a `notifications` table (polymorphic `notifiable_type`/`notifiable_id`, JSON `data` with `format: 'filament'`).
|
|
87
|
+
* Existing rows in that format are read as-is. `mapUrl` rewrites action URLs read from the table.
|
|
88
|
+
*/
|
|
89
|
+
export function knexNotificationStore(knex, options = {}) {
|
|
90
|
+
const table = options.table ?? 'notifications';
|
|
91
|
+
const notifiableType = options.notifiableType ?? 'App\\Models\\User';
|
|
92
|
+
const scope = (user) => knex(table).where({ notifiable_type: notifiableType, notifiable_id: user }).whereRaw("json_extract(data, '$.format') = ?", ['filament']);
|
|
93
|
+
const text = (value) => (typeof value === 'string' && value !== '' ? value : null);
|
|
94
|
+
return {
|
|
95
|
+
async list(user, limit, offset = 0) {
|
|
96
|
+
const rows = await scope(user).orderBy('created_at', 'desc').limit(limit).offset(Math.max(0, Math.floor(offset)));
|
|
97
|
+
return rows.map((row) => {
|
|
98
|
+
let data = {};
|
|
99
|
+
try {
|
|
100
|
+
data = JSON.parse(row.data) ?? {};
|
|
101
|
+
}
|
|
102
|
+
catch { /* unreadable data: empty notification */ }
|
|
103
|
+
const iconName = text(data.icon)?.replace(/^heroicon-/, '') ?? null;
|
|
104
|
+
return {
|
|
105
|
+
id: String(row.id), title: text(data.title), body: text(data.body), icon: iconName, iconColor: text(data.iconColor), status: text(data.status),
|
|
106
|
+
actions: (Array.isArray(data.actions) ? data.actions : []).slice(0, 10).map((action) => ({ label: String(action?.label ?? action?.name ?? ''), url: typeof action?.url === 'string' ? (options.mapUrl ? options.mapUrl(action.url) : action.url) : null, openUrlInNewTab: action?.shouldOpenUrlInNewTab === true })),
|
|
107
|
+
createdAt: row.created_at ? new Date(`${String(row.created_at).replace(' ', 'T').slice(0, 19)}Z`) : null, unread: row.read_at === null || row.read_at === undefined,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
async count(user) { return Number((await scope(user).count({ n: '*' }).first())?.n ?? 0); },
|
|
112
|
+
async unreadCount(user) { return Number((await scope(user).whereNull('read_at').count({ n: '*' }).first())?.n ?? 0); },
|
|
113
|
+
async send(user, notification) {
|
|
114
|
+
const at = stamp(new Date());
|
|
115
|
+
const data = {
|
|
116
|
+
actions: (notification.actions ?? []).map((action) => ({ name: action.label, color: null, icon: null, isDisabled: false, label: action.label, shouldClose: false, shouldMarkAsRead: false, shouldMarkAsUnread: false, shouldOpenUrlInNewTab: action.openUrlInNewTab, size: 'sm', url: action.url, view: 'filament::components.link' })),
|
|
117
|
+
body: notification.body, color: null, duration: 'persistent', icon: notification.icon ? `heroicon-${notification.icon}` : null, iconColor: notification.iconColor, status: notification.status, title: notification.title, view: null, viewData: [], format: 'filament',
|
|
118
|
+
};
|
|
119
|
+
await knex(table).insert({ id: notification.id, type: 'Filament\\Notifications\\DatabaseNotification', notifiable_type: notifiableType, notifiable_id: user, data: JSON.stringify(data), read_at: null, created_at: at, updated_at: at });
|
|
120
|
+
},
|
|
121
|
+
async delete(user, id) { await scope(user).where({ id }).delete(); },
|
|
122
|
+
async markAsRead(user, id, read) { await scope(user).where({ id }).update({ read_at: read ? stamp(new Date()) : null }); },
|
|
123
|
+
async markAllAsRead(user) { await scope(user).whereNull('read_at').update({ read_at: stamp(new Date()) }); },
|
|
124
|
+
async clear(user) { await scope(user).delete(); },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Relative time in English, e.g. "2 days ago". */
|
|
128
|
+
export function timeAgo(date, now = new Date()) {
|
|
129
|
+
const seconds = Math.max(0, Math.floor((now.getTime() - date.getTime()) / 1000));
|
|
130
|
+
const units = [[365 * 86400, 'year'], [30 * 86400, 'month'], [7 * 86400, 'week'], [86400, 'day'], [3600, 'hour'], [60, 'minute'], [1, 'second']];
|
|
131
|
+
for (const [size, name] of units) {
|
|
132
|
+
if (seconds >= size) {
|
|
133
|
+
const n = Math.floor(seconds / size);
|
|
134
|
+
return `${n} ${name}${n === 1 ? '' : 's'} ago`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return '0 seconds ago';
|
|
138
|
+
}
|
|
139
|
+
/** A notification rendered inline in the panel. Title and body are escaped. */
|
|
140
|
+
function renderDatabaseNotification(n) {
|
|
141
|
+
const color = n.iconColor ?? n.status;
|
|
142
|
+
const iconHtml = safeIcon(n.icon ?? (n.status ? { danger: 'o-x-circle', info: 'o-information-circle', success: 'o-check-circle', warning: 'o-exclamation-circle' }[n.status] ?? null : null), 'md', [...(color && color !== 'gray' && /^[a-z]+$/.test(color) ? ['fi-color', `fi-color-${color}`] : []), 'fi-no-notification-icon']);
|
|
143
|
+
const actions = n.actions.filter((action) => action.label !== '').map((action) => {
|
|
144
|
+
const url = safeUrl(action.url);
|
|
145
|
+
return `<a ${new Attributes({ href: url, ...(action.openUrlInNewTab ? { target: '_blank', rel: 'noopener noreferrer' } : url?.startsWith('/') ? { 'wire:navigate': true } : {}) }).class(['fi-ac-link-action', 'fi-link', 'fi-size-sm', 'fi-color', 'fi-color-primary', 'fi-text-color-600', 'dark:fi-text-color-300']).toHtml()}><span class="fi-link-label">${e(action.label)}</span></a>`;
|
|
146
|
+
});
|
|
147
|
+
const closeLabel = t('filament-notifications::notification.actions.close.label');
|
|
148
|
+
const attributes = new Attributes({
|
|
149
|
+
'x-data': `notificationComponent({ notification: ${js({ id: n.id, duration: 'persistent' })} })`,
|
|
150
|
+
'x-transition:enter-start': 'fi-transition-enter-start', 'x-transition:enter-end': 'fi-transition-enter-end', 'x-transition:leave-start': 'fi-transition-leave-start', 'x-transition:leave-end': 'fi-transition-leave-end',
|
|
151
|
+
'wire:key': `${n.id}.notifications.${n.id}`, 'x-on:close-notification.window': `if ($event.detail.id == ${js(n.id)}) close()`,
|
|
152
|
+
}).class(['fi-no-notification', n.status && /^[a-z]+$/.test(n.status) ? `fi-status-${n.status}` : null, 'fi-inline']);
|
|
153
|
+
return `<div ${attributes.toHtml()}>${iconHtml}<div class="fi-no-notification-main"><div class="fi-no-notification-text">${n.title ? `<h3 class="fi-no-notification-title">${e(n.title)}</h3>` : ''}${n.createdAt ? `<time class="fi-no-notification-date">${e(timeAgo(n.createdAt))}</time>` : ''}${n.body ? `<div class="fi-no-notification-body">${notificationBodyHtml(n.body)}</div>` : ''}</div>${actions.length ? `<div class="fi-ac fi-no-notification-actions">${actions.join('')}</div>` : ''}</div><button type="button" x-on:click="close" aria-label="${e(closeLabel)}" title="${e(closeLabel)}" class="fi-icon-btn fi-no-notification-close-btn">${icon('m-x-mark')}</button></div>`;
|
|
154
|
+
}
|
|
155
|
+
const BADGE = 'fi-color fi-color-primary fi-text-color-600 dark:fi-text-color-200 fi-badge';
|
|
156
|
+
const linkAction = (label, method, color, extra = '') => `<button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-link-action fi-link fi-size-md fi-color fi-color-${color} fi-text-color-600 dark:fi-text-color-300" wire:click="${method}"${extra}>${loadingIndicator(new Attributes({ 'wire:loading.delay.default': '', 'wire:target': method }))}<span class="fi-link-label">${e(label)}</span></button>`;
|
|
157
|
+
/** Bell and slide-over listing the signed-in user's database notifications. */
|
|
158
|
+
export class DatabaseNotifications extends Component {
|
|
159
|
+
static callable = ['refresh', 'removeNotification', 'markNotificationAsRead', 'markNotificationAsUnread', 'clearNotifications', 'markAllNotificationsAsRead', 'previousPage', 'nextPage', 'gotoPage'];
|
|
160
|
+
/** Current page of the panel (simple pagination). */
|
|
161
|
+
paginators = { 'database-notifications-page': 1 };
|
|
162
|
+
get notificationsPage() { return Math.max(1, Math.min(10_000, Math.floor(Number(this.paginators?.['database-notifications-page']) || 1))); }
|
|
163
|
+
previousPage() { this.paginators = { 'database-notifications-page': Math.max(1, this.notificationsPage - 1) }; }
|
|
164
|
+
nextPage() { this.paginators = { 'database-notifications-page': this.notificationsPage + 1 }; }
|
|
165
|
+
gotoPage(page) { this.paginators = { 'database-notifications-page': Math.max(1, Math.floor(Number(page)) || 1) }; }
|
|
166
|
+
static listeners = { databaseNotificationsSent: 'refresh', notificationClosed: 'removeNotification', markedNotificationAsRead: 'markNotificationAsRead', markedNotificationAsUnread: 'markNotificationAsUnread' };
|
|
167
|
+
/** Notifications per page. */
|
|
168
|
+
static limit = 50;
|
|
169
|
+
isOpen = false;
|
|
170
|
+
/** Store and user come from the server: every operation is restricted to this user's notifications. */
|
|
171
|
+
get scope() {
|
|
172
|
+
const { panel, user } = (this._ctx ?? {});
|
|
173
|
+
const store = panel?.getDatabaseNotifications() ?? null;
|
|
174
|
+
if (!user || !store)
|
|
175
|
+
throw new WireError(401, 'Unauthenticated.');
|
|
176
|
+
return { store, user: user.id, interval: panel.getDatabaseNotificationsPollingInterval() };
|
|
177
|
+
}
|
|
178
|
+
refresh() { }
|
|
179
|
+
async removeNotification(id) { if (typeof id === 'string' && UUID.test(id))
|
|
180
|
+
await this.scope.store.delete(this.scope.user, id); }
|
|
181
|
+
async markNotificationAsRead(id) { if (typeof id === 'string' && UUID.test(id))
|
|
182
|
+
await this.scope.store.markAsRead(this.scope.user, id, true); }
|
|
183
|
+
async markNotificationAsUnread(id) { if (typeof id === 'string' && UUID.test(id))
|
|
184
|
+
await this.scope.store.markAsRead(this.scope.user, id, false); }
|
|
185
|
+
async clearNotifications() { await this.scope.store.clear(this.scope.user); }
|
|
186
|
+
async markAllNotificationsAsRead() { await this.scope.store.markAllAsRead(this.scope.user); }
|
|
187
|
+
trigger(unread) {
|
|
188
|
+
return `<button class="fi-sidebar-database-notifications-btn">${icon('o-bell', { size: 'lg' })}<span class="fi-sidebar-database-notifications-btn-label">${e(t('filament-panels::layout.actions.open_database_notifications.label'))}</span>${unread ? `<span class="fi-sidebar-database-notifications-btn-badge-ctn"><span class="${BADGE} fi-size-md"><span class="fi-badge-label-ctn"><span class="fi-badge-label">${unread}</span></span></span></span>` : ''}</button>`;
|
|
189
|
+
}
|
|
190
|
+
async placeholder() {
|
|
191
|
+
const { panel, user } = (this._ctx ?? {});
|
|
192
|
+
const store = panel?.getDatabaseNotifications() ?? null;
|
|
193
|
+
return `<div>${user && store ? this.trigger(await store.unreadCount(user.id)) : ''}</div>`;
|
|
194
|
+
}
|
|
195
|
+
async render() {
|
|
196
|
+
const { store, user, interval } = this.scope;
|
|
197
|
+
this.isOpen = this.isOpen === true;
|
|
198
|
+
const limit = this.constructor.limit;
|
|
199
|
+
const page = this.notificationsPage;
|
|
200
|
+
const fetched = this.isOpen ? await store.list(user, limit + 1, (page - 1) * limit) : null;
|
|
201
|
+
const hasMore = fetched !== null && fetched.length > limit;
|
|
202
|
+
const notifications = fetched ? fetched.slice(0, limit) : null;
|
|
203
|
+
const unread = await store.unreadCount(user);
|
|
204
|
+
const has = notifications ? notifications.length > 0 : (await store.count(user)) > 0;
|
|
205
|
+
const id = 'database-notifications';
|
|
206
|
+
const close = `$dispatch('close-modal', { id: ${js(id)} })`;
|
|
207
|
+
const key = (part) => `${this.id}.modal.${id}.${part}`;
|
|
208
|
+
const closeLabel = t('filament::components/modal.actions.close.label');
|
|
209
|
+
const closeButton = `<button class="fi-icon-btn fi-size-md fi-modal-close-btn" title="${e(closeLabel)}" aria-label="${e(closeLabel)}" tabindex="-1" type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" x-on:click="${e(close)}">${icon('o-x-mark', { size: 'lg' })}</button>`;
|
|
210
|
+
const heading = t('filament-notifications::database.modal.heading');
|
|
211
|
+
let header;
|
|
212
|
+
let content = '';
|
|
213
|
+
if (has) {
|
|
214
|
+
header = `<div wire:key="${e(key('header'))}" class="fi-modal-header">${closeButton}<div><h2 id="${id}.heading" class="fi-modal-heading">${e(heading)}${unread ? ` <span class="${BADGE} fi-size-xs">${unread}</span>` : ''}</h2><div class="fi-ac">${unread ? linkAction(t('filament-notifications::database.modal.actions.mark_all_as_read.label'), 'markAllNotificationsAsRead', 'primary') : ''}${linkAction(t('filament-notifications::database.modal.actions.clear.label'), 'clearNotifications', 'danger', ' x-on:click="close()"')}</div></div></div>`;
|
|
215
|
+
const body = notifications
|
|
216
|
+
? `<div aria-label="${e(heading)}" role="list" class="fi-no-notifications">${notifications.map((n) => `<div role="listitem" wire:key="${e(n.id)}.database-notifications.ctn" class="${n.unread ? 'fi-no-notification-unread-ctn' : 'fi-no-notification-read-ctn'}">${n.unread ? `<span class="fi-sr-only">${e(t('filament-notifications::database.modal.unread_label'))}</span>` : ''}${renderDatabaseNotification(n)}</div>`).join('')}</div>`
|
|
217
|
+
: `<div role="status" aria-busy="true" class="fi-no-notifications-loading-ctn">${loadingIndicator()}<span class="fi-sr-only">${e(t('filament::components/loading-section.label'))}</span></div>`;
|
|
218
|
+
content = `<div wire:key="${e(key('content'))}" class="fi-modal-content">${body}</div>`;
|
|
219
|
+
if (notifications && (page > 1 || hasMore))
|
|
220
|
+
content += `<div wire:key="${e(key('footer'))}" class="fi-modal-footer fi-align-start">${pagination({ id: this.id, page, perPage: limit, hasMore })}</div>`;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
header = `<div wire:key="${e(key('header'))}" class="fi-modal-header">${closeButton}<div class="fi-modal-icon-ctn"><div class="fi-modal-icon-bg">${icon('o-bell-slash', { size: 'lg' })}</div></div><div><h2 id="${id}.heading" class="fi-modal-heading">${e(t('filament-notifications::database.modal.empty.heading'))}</h2><p id="${id}.description" class="fi-modal-description">${e(t('filament-notifications::database.modal.empty.description'))}</p></div></div>`;
|
|
224
|
+
}
|
|
225
|
+
const modal = new Attributes({
|
|
226
|
+
'aria-labelledby': `${id}.heading`, 'aria-modal': 'true', id, role: 'dialog', tabindex: '-1',
|
|
227
|
+
'x-data': `filamentModal({ id: ${js(id)}, isScrollLocked: true, shouldRestoreFocus: true, })`, 'data-fi-modal-id': id,
|
|
228
|
+
'x-on:close-modal.window': `if (($event.detail.id === ${js(id)}) && isOpen) close()`, 'x-on:close-modal-quietly.window': `if (($event.detail.id === ${js(id)}) && isOpen) closeQuietly()`,
|
|
229
|
+
'x-on:open-modal.window': `if (($event.detail.id === ${js(id)}) && (! isOpen)) open()`, 'x-bind:class': "{ 'fi-modal-open': isOpen, }", 'x-cloak': '', 'x-show': 'isOpen',
|
|
230
|
+
}).class(['fi-modal', 'fi-absolute-positioning-context', 'fi-modal-slide-over', 'fi-modal-slide-over-from-end', has ? 'fi-modal-has-sticky-header' : null, 'fi-no-database']);
|
|
231
|
+
const poll = interval && /^[0-9]+(ms|s)$/.test(interval) ? ` wire:poll.${interval}=""` : '';
|
|
232
|
+
const windowClasses = ['fi-modal-window', 'fi-modal-window-has-close-btn', has ? 'fi-modal-window-has-content' : 'fi-modal-window-has-icon', `fi-align-${has ? 'start' : 'center'}`, 'fi-width-md'].join(' ');
|
|
233
|
+
const transitions = 'x-transition:enter="fi-transition-enter" x-transition:leave="fi-transition-leave" x-transition:enter-start="fi-transition-enter-start" x-transition:enter-end="fi-transition-enter-end" x-transition:leave-start="fi-transition-leave-start" x-transition:leave-end="fi-transition-leave-end"';
|
|
234
|
+
// The window has `autofocus` so the focus trap lands on it, not on "Mark all as read", which Enter would trigger at once.
|
|
235
|
+
const dialog = `<div ${modal.toHtml()}${poll}><div aria-hidden="true" x-show="isOpen" x-transition.duration.300ms.opacity class="fi-modal-close-overlay"></div><div tabindex="-1" x-on:click.self="${e(close)}" x-trap.noreturn="isTrapActive" class="fi-modal-window-ctn fi-clickable"><div x-on:keydown.window.escape="if (isTopmost()) ${e(close)}" x-show="isWindowVisible" ${transitions} wire:key="${e(key('window'))}" tabindex="-1" autofocus="autofocus" class="${windowClasses}">${header}${content}</div></div></div>`;
|
|
236
|
+
const opened = "if ($event.detail.id === 'database-notifications' && ! $wire.isOpen) $wire.set('isOpen', true)";
|
|
237
|
+
const closed = "if ($event.detail.id === 'database-notifications') $wire.set('isOpen', false, false)";
|
|
238
|
+
return `<div x-data x-on:x-modal-opened.window="${e(opened)}" x-on:modal-closed.window="${e(closed)}" class="fi-no-database"><div><div x-on:click="$dispatch('open-modal', { id: 'database-notifications' })" class="fi-modal-trigger">${this.trigger(unread)}</div><template x-teleport="body">${dialog}</template></div></div>`;
|
|
239
|
+
}
|
|
240
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/panels",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
4
|
+
"description": "Admin panels for Node.js: resources, pages, navigation, authentication, tenancy.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.ts",
|
|
10
|
+
"default": "./lib/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./theme.css": "./theme.css"
|
|
13
|
+
},
|
|
14
|
+
"types": "./lib/index.d.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"views",
|
|
18
|
+
"dist",
|
|
19
|
+
"resources",
|
|
20
|
+
"theme.css",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"README.md",
|
|
23
|
+
"THIRD-PARTY-NOTICES.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=24"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/NavetteJS/navette.git",
|
|
31
|
+
"directory": "packages/panels"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/NavetteJS/navette#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/NavetteJS/navette/issues"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"navette",
|
|
39
|
+
"admin",
|
|
40
|
+
"panel",
|
|
41
|
+
"livewire",
|
|
42
|
+
"express",
|
|
43
|
+
"node"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@navette-js/support": "1.0.0-beta.2",
|
|
47
|
+
"@navette-js/schemas": "1.0.0-beta.2",
|
|
48
|
+
"@navette-js/forms": "1.0.0-beta.2",
|
|
49
|
+
"@navette-js/actions": "1.0.0-beta.2",
|
|
50
|
+
"@navette-js/wire": "1.0.0-beta.2",
|
|
51
|
+
"@navette-js/tables": "1.0.0-beta.2",
|
|
52
|
+
"@navette-js/infolists": "1.0.0-beta.2",
|
|
53
|
+
"@navette-js/database": "1.0.0-beta.2",
|
|
54
|
+
"express": "^5.1.0"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public",
|
|
58
|
+
"tag": "beta"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Filament
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|