@navette-js/panels 0.0.0-stage → 1.0.0-beta.1
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 +142 -0
- package/lib/calendar.d.ts +82 -0
- package/lib/calendar.js +107 -0
- package/lib/index.d.ts +1619 -0
- package/lib/index.js +3487 -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 +241 -0
- package/package.json +59 -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
|
+
/** Ce qu'il faut d'un stockage de notifications pour y écrire (voir `DatabaseNotificationStore`). */
|
|
18
|
+
type NotificationSink = {
|
|
19
|
+
send(user: string | number, notification: NotificationData): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
/** Exécute `fn` dans le contexte d'une requête : `Notification.send()` y dépose ses 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
|
+
/** Retire et rend les notifications des lots demandés (tous si `batches` est nul). */
|
|
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
|
+
/** Liens proposés sous le texte. Ils ne sont affichés que dans le panneau des notifications en base. */
|
|
44
|
+
actions(actions: {
|
|
45
|
+
label: string;
|
|
46
|
+
url?: string | null;
|
|
47
|
+
openUrlInNewTab?: boolean;
|
|
48
|
+
}[]): this;
|
|
49
|
+
getData(): NotificationData;
|
|
50
|
+
/** Enregistre la notification pour cet utilisateur (cloche du panel) : `await Notification.make()….sendToDatabase(panel.getDatabaseNotifications(), user.id)`. Sans stockage, rien n'est fait. */
|
|
51
|
+
sendToDatabase(store: NotificationSink | null, user: string | number): Promise<this>;
|
|
52
|
+
/** Dépose la notification pour le navigateur de la requête en cours. */
|
|
53
|
+
send(): this;
|
|
54
|
+
}
|
|
55
|
+
/** Port de Notification::toEmbeddedHtml (sans date, actions ni mode « inline »). Le titre et le corps sont échappés. */
|
|
56
|
+
export declare function renderNotification(n: NotificationData): string;
|
|
57
|
+
/** Conteneur des notifications (port de Filament\Notifications\Livewire\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
|
+
/** À l'affichage d'un document (hors navigation SPA et préchargement), les notifications en attente sont tirées tout de suite. */
|
|
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
|
+
// Notifications flash — port de filament/notifications : Notification, boîte aux lettres, composant d'affichage.
|
|
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
|
+
/** Exécute `fn` dans le contexte d'une requête : `Notification.send()` y dépose ses 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
|
+
// Boîte aux lettres en mémoire, par navigateur (identifiant = jeton CSRF) puis par lot. Filament utilise le cache de l'application.
|
|
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
|
+
/** Retire et rend les notifications des lots demandés (tous si `batches` est nul). */
|
|
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
|
+
/** Liens proposés sous le texte. Ils ne sont affichés que dans le panneau des notifications en base. */
|
|
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
|
+
/** Enregistre la notification pour cet utilisateur (cloche du panel) : `await Notification.make()….sendToDatabase(panel.getDatabaseNotifications(), user.id)`. Sans stockage, rien n'est fait. */
|
|
60
|
+
async sendToDatabase(store, user) { if (store)
|
|
61
|
+
await store.send(user, { ...this.data, id: randomUUID() }); return this; }
|
|
62
|
+
/** Dépose la notification pour le navigateur de la requête en cours. */
|
|
63
|
+
send() {
|
|
64
|
+
const store = requestStore.getStore();
|
|
65
|
+
if (!store)
|
|
66
|
+
throw new Error('Notification.send() doit être appelé pendant le traitement d\'une requête.');
|
|
67
|
+
store.pending.push({ ...this.data });
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Port de Notification::toEmbeddedHtml (sans date, actions ni mode « inline »). Le titre et le corps sont échappés. */
|
|
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
|
+
/** Conteneur des notifications (port de Filament\Notifications\Livewire\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
|
+
/** À l'affichage d'un document (hors navigation SPA et préchargement), les notifications en attente sont tirées tout de suite. */
|
|
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, 'Lots de notifications invalides.');
|
|
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
|
+
/** Corps d'une notification : du HTML est nettoyé (balises de l'éditeur de texte enrichi seulement), un texte simple est échappé. */
|
|
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
|
+
/** Suffixe du champ d'après la plateforme du navigateur, comme `globalSearchFieldKeyBindingSuffix()` : rien si elle est inconnue. */
|
|
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
|
+
/** Stockage des notifications d'un utilisateur. `knexNotificationStore()` en fournit un sur la table `notifications` de Laravel. */
|
|
37
|
+
export interface DatabaseNotificationStore {
|
|
38
|
+
/** Notifications les plus récentes d'abord ; `offset` : nombre à sauter (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
|
+
* Stockage sur la table `notifications` de Laravel, au format de Filament : les notifications déjà présentes sont lues telles quelles,
|
|
52
|
+
* et celles envoyées d'ici restent lisibles par une application Filament. `mapUrl` réécrit l'adresse des actions lues en base.
|
|
53
|
+
*/
|
|
54
|
+
export declare function knexNotificationStore(knex: any, options?: {
|
|
55
|
+
table?: string;
|
|
56
|
+
notifiableType?: string;
|
|
57
|
+
mapUrl?: (url: string) => string;
|
|
58
|
+
}): DatabaseNotificationStore;
|
|
59
|
+
/** « 2 days ago », comme `diffForHumans()` de Carbon. */
|
|
60
|
+
export declare function timeAgo(date: Date, now?: Date): string;
|
|
61
|
+
/** Cloche et panneau des notifications enregistrées en base pour l'utilisateur connecté. */
|
|
62
|
+
export declare class DatabaseNotifications extends Component {
|
|
63
|
+
static callable: string[];
|
|
64
|
+
/** Page du panneau (pagination simple, comme `simplePaginate(50)` de Filament). */
|
|
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
|
+
/** Nombre de notifications par page du panneau (50, comme Filament). */
|
|
77
|
+
static limit: number;
|
|
78
|
+
isOpen: boolean;
|
|
79
|
+
/** Le stockage et l'utilisateur viennent du serveur : chaque opération est bornée aux notifications de cet utilisateur. */
|
|
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,241 @@
|
|
|
1
|
+
// Recherche globale et notifications en base — ports de Filament\Livewire\GlobalSearch et de
|
|
2
|
+
// Filament\Notifications\Livewire\DatabaseNotifications. Les deux composants vivent dans la sidebar du panel.
|
|
3
|
+
import { Attributes, e, icon, js, loadingIndicator, renderHook } from '@navette-js/support';
|
|
4
|
+
import { t } from '@navette-js/support/i18n';
|
|
5
|
+
import { Component, WireError } from '@navette-js/wire';
|
|
6
|
+
import { richDocumentToHtml, richHtmlToDocument } from '@navette-js/forms';
|
|
7
|
+
import { pagination } from '@navette-js/support/ui';
|
|
8
|
+
/** Corps d'une notification : du HTML est nettoyé (balises de l'éditeur de texte enrichi seulement), un texte simple est échappé. */
|
|
9
|
+
export const notificationBodyHtml = (body) => (/<[a-z][\s\S]*>/i.test(body) ? (richDocumentToHtml(richHtmlToDocument(body)) ?? '') : e(body));
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
const safeIcon = (name, size, classes) => {
|
|
12
|
+
if (!name || !/^[a-z0-9-]+$/.test(name))
|
|
13
|
+
return '';
|
|
14
|
+
try {
|
|
15
|
+
return icon(name, { size, attributes: new Attributes().class(classes) });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** Seules une adresse du site (`/…`) ou une adresse http(s) peuvent devenir un lien. */
|
|
22
|
+
const safeUrl = (url) => (typeof url === 'string' && /^(\/(?!\/)|https?:\/\/)/.test(url) ? url : null);
|
|
23
|
+
/** Suffixe du champ d'après la plateforme du navigateur, comme `globalSearchFieldKeyBindingSuffix()` : rien si elle est inconnue. */
|
|
24
|
+
export function keyBindingSuffix(keyBindings, userAgent) {
|
|
25
|
+
const first = keyBindings[0];
|
|
26
|
+
if (!first)
|
|
27
|
+
return null;
|
|
28
|
+
if (/Macintosh|Mac OS/i.test(userAgent))
|
|
29
|
+
return first.replace(/alt|option/g, '⌥').replace(/meta|command|mod/g, '⌘').replace(/ctrl/g, '⌃').toUpperCase();
|
|
30
|
+
if (!/Windows|Linux/i.test(userAgent))
|
|
31
|
+
return null;
|
|
32
|
+
return first.replace(/option/g, 'alt').replace(/command/g, 'meta').replace(/mod/g, 'ctrl').toUpperCase();
|
|
33
|
+
}
|
|
34
|
+
export class GlobalSearch extends Component {
|
|
35
|
+
search = '';
|
|
36
|
+
async render() {
|
|
37
|
+
const { panel, user, req } = (this._ctx ?? {});
|
|
38
|
+
if (!user || !panel)
|
|
39
|
+
return '<div class="fi-global-search-ctn"></div>';
|
|
40
|
+
const search = (typeof this.search === 'string' ? this.search : '').slice(0, 1000).trim();
|
|
41
|
+
this.search = typeof this.search === 'string' ? this.search.slice(0, 1000) : '';
|
|
42
|
+
const categories = search === '' ? null : await panel.getGlobalSearchResults(search, user);
|
|
43
|
+
if (categories !== null)
|
|
44
|
+
this.dispatch('open-global-search-results');
|
|
45
|
+
const count = categories?.reduce((total, category) => total + category.results.length, 0) ?? 0;
|
|
46
|
+
const [one, many] = t('filament-panels::global-search.results_count').split('|');
|
|
47
|
+
const message = count ? (count === 1 ? one : (many ?? one)).replace(':count', String(count)) : t('filament-panels::global-search.no_results_message');
|
|
48
|
+
const id = `global-search-${this.id}`;
|
|
49
|
+
const bindings = panel.getGlobalSearchKeyBindings();
|
|
50
|
+
const suffix = panel.getGlobalSearchFieldSuffix(String(req?.headers?.['user-agent'] ?? ''));
|
|
51
|
+
const input = `<input ${new Attributes({
|
|
52
|
+
autocomplete: 'off', maxlength: '1000', placeholder: t('filament-panels::global-search.field.placeholder'), type: 'search', 'wire:key': 'global-search.field.input', id: `${id}-input`,
|
|
53
|
+
'aria-controls': `${id}-results`, 'aria-describedby': `${id}-instructions`, 'x-ref': 'input', [`wire:model.live.debounce.${panel.getGlobalSearchDebounce()}`]: 'search',
|
|
54
|
+
...(bindings.length ? { [`x-mousetrap.global.${bindings.map((binding) => binding.replace(/\+/g, '-')).join('.')}`]: '$refs.input?.focus()' } : {}),
|
|
55
|
+
}).class(['fi-input', 'fi-input-has-inline-prefix']).toHtml()} />`;
|
|
56
|
+
const focus = "if (! $event.target.closest('.fi-input-wrp-actions')) $dispatch('focus-input')";
|
|
57
|
+
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>`;
|
|
58
|
+
let results = '';
|
|
59
|
+
if (categories !== null) {
|
|
60
|
+
results = categories.length === 0
|
|
61
|
+
? `<p class="fi-global-search-no-results-message">${e(t('filament-panels::global-search.no_results_message'))}</p>`
|
|
62
|
+
: `<ul class="fi-global-search-results">${categories.map((category, groupIndex) => {
|
|
63
|
+
const groupId = `${id}-group-${groupIndex}`;
|
|
64
|
+
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) => {
|
|
65
|
+
const details = Object.entries(result.details);
|
|
66
|
+
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>`;
|
|
67
|
+
}).join('')}</ul></li>`;
|
|
68
|
+
}).join('')}</ul>`;
|
|
69
|
+
}
|
|
70
|
+
const root = new Attributes({
|
|
71
|
+
'x-data': `filamentGlobalSearch({ loadingMessage: ${js(t('filament-panels::global-search.loading_message'))}, failureMessage: ${js(t('filament-panels::global-search.failure_message'))}, })`,
|
|
72
|
+
'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)',
|
|
73
|
+
'x-on:keydown': 'keydown($event)', 'x-on:click.outside': 'dismiss()',
|
|
74
|
+
'x-on:open-modal.window.capture': 'if ($refs.results.contains(document.activeElement)) {\n dismiss()\n restoreFocus()\n }',
|
|
75
|
+
'x-on:livewire:navigate.window': 'navigationStarted($event)', 'x-on:livewire:navigated.window': 'navigationCompleted()',
|
|
76
|
+
}).class(['fi-global-search']);
|
|
77
|
+
const region = new Attributes({
|
|
78
|
+
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,
|
|
79
|
+
'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)',
|
|
80
|
+
'x-transition:enter-start': 'fi-transition-enter-start', 'x-transition:leave-end': 'fi-transition-leave-end',
|
|
81
|
+
}).class(['fi-global-search-results-ctn']);
|
|
82
|
+
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>`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const stamp = (date) => date.toISOString().slice(0, 19).replace('T', ' ');
|
|
86
|
+
/**
|
|
87
|
+
* Stockage sur la table `notifications` de Laravel, au format de Filament : les notifications déjà présentes sont lues telles quelles,
|
|
88
|
+
* et celles envoyées d'ici restent lisibles par une application Filament. `mapUrl` réécrit l'adresse des actions lues en base.
|
|
89
|
+
*/
|
|
90
|
+
export function knexNotificationStore(knex, options = {}) {
|
|
91
|
+
const table = options.table ?? 'notifications';
|
|
92
|
+
const notifiableType = options.notifiableType ?? 'App\\Models\\User';
|
|
93
|
+
const scope = (user) => knex(table).where({ notifiable_type: notifiableType, notifiable_id: user }).whereRaw("json_extract(data, '$.format') = ?", ['filament']);
|
|
94
|
+
const text = (value) => (typeof value === 'string' && value !== '' ? value : null);
|
|
95
|
+
return {
|
|
96
|
+
async list(user, limit, offset = 0) {
|
|
97
|
+
const rows = await scope(user).orderBy('created_at', 'desc').limit(limit).offset(Math.max(0, Math.floor(offset)));
|
|
98
|
+
return rows.map((row) => {
|
|
99
|
+
let data = {};
|
|
100
|
+
try {
|
|
101
|
+
data = JSON.parse(row.data) ?? {};
|
|
102
|
+
}
|
|
103
|
+
catch { /* données illisibles : notification vide */ }
|
|
104
|
+
const iconName = text(data.icon)?.replace(/^heroicon-/, '') ?? null;
|
|
105
|
+
return {
|
|
106
|
+
id: String(row.id), title: text(data.title), body: text(data.body), icon: iconName, iconColor: text(data.iconColor), status: text(data.status),
|
|
107
|
+
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 })),
|
|
108
|
+
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,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
async count(user) { return Number((await scope(user).count({ n: '*' }).first())?.n ?? 0); },
|
|
113
|
+
async unreadCount(user) { return Number((await scope(user).whereNull('read_at').count({ n: '*' }).first())?.n ?? 0); },
|
|
114
|
+
async send(user, notification) {
|
|
115
|
+
const at = stamp(new Date());
|
|
116
|
+
const data = {
|
|
117
|
+
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' })),
|
|
118
|
+
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',
|
|
119
|
+
};
|
|
120
|
+
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 });
|
|
121
|
+
},
|
|
122
|
+
async delete(user, id) { await scope(user).where({ id }).delete(); },
|
|
123
|
+
async markAsRead(user, id, read) { await scope(user).where({ id }).update({ read_at: read ? stamp(new Date()) : null }); },
|
|
124
|
+
async markAllAsRead(user) { await scope(user).whereNull('read_at').update({ read_at: stamp(new Date()) }); },
|
|
125
|
+
async clear(user) { await scope(user).delete(); },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** « 2 days ago », comme `diffForHumans()` de Carbon. */
|
|
129
|
+
export function timeAgo(date, now = new Date()) {
|
|
130
|
+
const seconds = Math.max(0, Math.floor((now.getTime() - date.getTime()) / 1000));
|
|
131
|
+
const units = [[365 * 86400, 'year'], [30 * 86400, 'month'], [7 * 86400, 'week'], [86400, 'day'], [3600, 'hour'], [60, 'minute'], [1, 'second']];
|
|
132
|
+
for (const [size, name] of units) {
|
|
133
|
+
if (seconds >= size) {
|
|
134
|
+
const n = Math.floor(seconds / size);
|
|
135
|
+
return `${n} ${name}${n === 1 ? '' : 's'} ago`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return '0 seconds ago';
|
|
139
|
+
}
|
|
140
|
+
/** Notification affichée dans le panneau (mode « inline » de Filament). Titre et corps sont échappés. */
|
|
141
|
+
function renderDatabaseNotification(n) {
|
|
142
|
+
const color = n.iconColor ?? n.status;
|
|
143
|
+
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']);
|
|
144
|
+
const actions = n.actions.filter((action) => action.label !== '').map((action) => {
|
|
145
|
+
const url = safeUrl(action.url);
|
|
146
|
+
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>`;
|
|
147
|
+
});
|
|
148
|
+
const closeLabel = t('filament-notifications::notification.actions.close.label');
|
|
149
|
+
const attributes = new Attributes({
|
|
150
|
+
'x-data': `notificationComponent({ notification: ${js({ id: n.id, duration: 'persistent' })} })`,
|
|
151
|
+
'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',
|
|
152
|
+
'wire:key': `${n.id}.notifications.${n.id}`, 'x-on:close-notification.window': `if ($event.detail.id == ${js(n.id)}) close()`,
|
|
153
|
+
}).class(['fi-no-notification', n.status && /^[a-z]+$/.test(n.status) ? `fi-status-${n.status}` : null, 'fi-inline']);
|
|
154
|
+
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>`;
|
|
155
|
+
}
|
|
156
|
+
const BADGE = 'fi-color fi-color-primary fi-text-color-600 dark:fi-text-color-200 fi-badge';
|
|
157
|
+
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>`;
|
|
158
|
+
/** Cloche et panneau des notifications enregistrées en base pour l'utilisateur connecté. */
|
|
159
|
+
export class DatabaseNotifications extends Component {
|
|
160
|
+
static callable = ['refresh', 'removeNotification', 'markNotificationAsRead', 'markNotificationAsUnread', 'clearNotifications', 'markAllNotificationsAsRead', 'previousPage', 'nextPage', 'gotoPage'];
|
|
161
|
+
/** Page du panneau (pagination simple, comme `simplePaginate(50)` de Filament). */
|
|
162
|
+
paginators = { 'database-notifications-page': 1 };
|
|
163
|
+
get notificationsPage() { return Math.max(1, Math.min(10_000, Math.floor(Number(this.paginators?.['database-notifications-page']) || 1))); }
|
|
164
|
+
previousPage() { this.paginators = { 'database-notifications-page': Math.max(1, this.notificationsPage - 1) }; }
|
|
165
|
+
nextPage() { this.paginators = { 'database-notifications-page': this.notificationsPage + 1 }; }
|
|
166
|
+
gotoPage(page) { this.paginators = { 'database-notifications-page': Math.max(1, Math.floor(Number(page)) || 1) }; }
|
|
167
|
+
static listeners = { databaseNotificationsSent: 'refresh', notificationClosed: 'removeNotification', markedNotificationAsRead: 'markNotificationAsRead', markedNotificationAsUnread: 'markNotificationAsUnread' };
|
|
168
|
+
/** Nombre de notifications par page du panneau (50, comme Filament). */
|
|
169
|
+
static limit = 50;
|
|
170
|
+
isOpen = false;
|
|
171
|
+
/** Le stockage et l'utilisateur viennent du serveur : chaque opération est bornée aux notifications de cet utilisateur. */
|
|
172
|
+
get scope() {
|
|
173
|
+
const { panel, user } = (this._ctx ?? {});
|
|
174
|
+
const store = panel?.getDatabaseNotifications() ?? null;
|
|
175
|
+
if (!user || !store)
|
|
176
|
+
throw new WireError(401, 'Non authentifié.');
|
|
177
|
+
return { store, user: user.id, interval: panel.getDatabaseNotificationsPollingInterval() };
|
|
178
|
+
}
|
|
179
|
+
refresh() { }
|
|
180
|
+
async removeNotification(id) { if (typeof id === 'string' && UUID.test(id))
|
|
181
|
+
await this.scope.store.delete(this.scope.user, id); }
|
|
182
|
+
async markNotificationAsRead(id) { if (typeof id === 'string' && UUID.test(id))
|
|
183
|
+
await this.scope.store.markAsRead(this.scope.user, id, true); }
|
|
184
|
+
async markNotificationAsUnread(id) { if (typeof id === 'string' && UUID.test(id))
|
|
185
|
+
await this.scope.store.markAsRead(this.scope.user, id, false); }
|
|
186
|
+
async clearNotifications() { await this.scope.store.clear(this.scope.user); }
|
|
187
|
+
async markAllNotificationsAsRead() { await this.scope.store.markAllAsRead(this.scope.user); }
|
|
188
|
+
trigger(unread) {
|
|
189
|
+
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>`;
|
|
190
|
+
}
|
|
191
|
+
async placeholder() {
|
|
192
|
+
const { panel, user } = (this._ctx ?? {});
|
|
193
|
+
const store = panel?.getDatabaseNotifications() ?? null;
|
|
194
|
+
return `<div>${user && store ? this.trigger(await store.unreadCount(user.id)) : ''}</div>`;
|
|
195
|
+
}
|
|
196
|
+
async render() {
|
|
197
|
+
const { store, user, interval } = this.scope;
|
|
198
|
+
this.isOpen = this.isOpen === true;
|
|
199
|
+
const limit = this.constructor.limit;
|
|
200
|
+
const page = this.notificationsPage;
|
|
201
|
+
const fetched = this.isOpen ? await store.list(user, limit + 1, (page - 1) * limit) : null;
|
|
202
|
+
const hasMore = fetched !== null && fetched.length > limit;
|
|
203
|
+
const notifications = fetched ? fetched.slice(0, limit) : null;
|
|
204
|
+
const unread = await store.unreadCount(user);
|
|
205
|
+
const has = notifications ? notifications.length > 0 : (await store.count(user)) > 0;
|
|
206
|
+
const id = 'database-notifications';
|
|
207
|
+
const close = `$dispatch('close-modal', { id: ${js(id)} })`;
|
|
208
|
+
const key = (part) => `${this.id}.modal.${id}.${part}`;
|
|
209
|
+
const closeLabel = t('filament::components/modal.actions.close.label');
|
|
210
|
+
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>`;
|
|
211
|
+
const heading = t('filament-notifications::database.modal.heading');
|
|
212
|
+
let header;
|
|
213
|
+
let content = '';
|
|
214
|
+
if (has) {
|
|
215
|
+
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>`;
|
|
216
|
+
const body = notifications
|
|
217
|
+
? `<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>`
|
|
218
|
+
: `<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>`;
|
|
219
|
+
content = `<div wire:key="${e(key('content'))}" class="fi-modal-content">${body}</div>`;
|
|
220
|
+
if (notifications && (page > 1 || hasMore))
|
|
221
|
+
content += `<div wire:key="${e(key('footer'))}" class="fi-modal-footer fi-align-start">${pagination({ id: this.id, page, perPage: limit, hasMore })}</div>`;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
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>`;
|
|
225
|
+
}
|
|
226
|
+
const modal = new Attributes({
|
|
227
|
+
'aria-labelledby': `${id}.heading`, 'aria-modal': 'true', id, role: 'dialog', tabindex: '-1',
|
|
228
|
+
'x-data': `filamentModal({ id: ${js(id)}, isScrollLocked: true, shouldRestoreFocus: true, })`, 'data-fi-modal-id': id,
|
|
229
|
+
'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()`,
|
|
230
|
+
'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',
|
|
231
|
+
}).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']);
|
|
232
|
+
const poll = interval && /^[0-9]+(ms|s)$/.test(interval) ? ` wire:poll.${interval}=""` : '';
|
|
233
|
+
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(' ');
|
|
234
|
+
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"';
|
|
235
|
+
// La fenêtre porte `autofocus` : le piège de focus s'y pose, et non sur « Mark all as read » qu'Entrée déclencherait aussitôt.
|
|
236
|
+
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>`;
|
|
237
|
+
const opened = "if ($event.detail.id === 'database-notifications' && ! $wire.isOpen) $wire.set('isOpen', true)";
|
|
238
|
+
const closed = "if ($event.detail.id === 'database-notifications') $wire.set('isOpen', false, false)";
|
|
239
|
+
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>`;
|
|
240
|
+
}
|
|
241
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/panels",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"description": "Admin panels for Node.js: a port of Filament (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
|
+
"filament",
|
|
40
|
+
"admin",
|
|
41
|
+
"panel",
|
|
42
|
+
"livewire",
|
|
43
|
+
"express",
|
|
44
|
+
"node"
|
|
45
|
+
],
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@navette-js/support": "1.0.0-beta.1",
|
|
48
|
+
"@navette-js/schemas": "1.0.0-beta.1",
|
|
49
|
+
"@navette-js/forms": "1.0.0-beta.1",
|
|
50
|
+
"@navette-js/actions": "1.0.0-beta.1",
|
|
51
|
+
"@navette-js/wire": "1.0.0-beta.1",
|
|
52
|
+
"@navette-js/tables": "1.0.0-beta.1",
|
|
53
|
+
"@navette-js/infolists": "1.0.0-beta.1",
|
|
54
|
+
"@navette-js/database": "1.0.0-beta.1",
|
|
55
|
+
"express": "^5.1.0"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public",
|
|
59
|
+
"tag": "beta"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -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.
|