@inwink-mcp/backoffice-extension-helpers 1.0.7 → 1.0.8
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/package.json +1 -1
- package/templates/test-app/src/components/EventApiTester.tsx +51 -39
- package/templates/vite-extension/AGENTS.md.tpl +104 -1
- package/templates/vite-extension/docs/inwink-api/README.md +42 -0
- package/templates/vite-extension/docs/inwink-api/create.md +21 -0
- package/templates/vite-extension/docs/inwink-api/delete.md +21 -0
- package/templates/vite-extension/docs/inwink-api/entities.md +68 -0
- package/templates/vite-extension/docs/inwink-api/files.md +189 -0
- package/templates/vite-extension/docs/inwink-api/import.md +24 -0
- package/templates/vite-extension/docs/inwink-api/query-language.md +120 -0
- package/templates/vite-extension/docs/inwink-api/query.md +68 -0
- package/templates/vite-extension/docs/inwink-api/update.md +34 -0
- package/templates/vite-extension/src/components/ConfirmDialog.tsx +61 -0
- package/templates/vite-extension/src/components/ErrorBoundary.css +18 -0
- package/templates/vite-extension/src/components/ErrorBoundary.tsx +46 -0
- package/templates/vite-extension/src/components/Layout.tsx +4 -1
- package/templates/vite-extension/src/components/Modal.css +34 -0
- package/templates/vite-extension/src/components/Modal.tsx +69 -0
- package/templates/vite-extension/src/components/feedback/EmptyState.css +15 -0
- package/templates/vite-extension/src/components/feedback/EmptyState.tsx +20 -0
- package/templates/vite-extension/src/components/feedback/ErrorState.css +12 -0
- package/templates/vite-extension/src/components/feedback/ErrorState.tsx +28 -0
- package/templates/vite-extension/src/components/feedback/Spinner.css +21 -0
- package/templates/vite-extension/src/components/feedback/Spinner.tsx +15 -0
- package/templates/vite-extension/src/components/toast/ToastProvider.css +59 -0
- package/templates/vite-extension/src/components/toast/ToastProvider.tsx +67 -0
- package/templates/vite-extension/src/hooks/useAsync.ts +71 -0
- package/templates/vite-extension/src/hooks/useConfirm.ts +11 -0
- package/templates/vite-extension/src/hooks/useInwinkApi.ts +26 -0
- package/templates/vite-extension/src/hooks/useInwinkPagedQuery.ts +100 -0
- package/templates/vite-extension/src/hooks/useToast.ts +11 -0
- package/templates/vite-extension/src/main.tsx +7 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
◂ Retour à [README](./README.md)
|
|
2
|
+
|
|
3
|
+
# inwink JSON Query Language
|
|
4
|
+
|
|
5
|
+
The query language is inspired by MongoDB syntax. Expressions are JSON objects where `$`-prefixed keys are operators and non-prefixed keys are entity property names.
|
|
6
|
+
|
|
7
|
+
Utilisé par les `filters`, `orders` et `selects` des opérations [query](./query.md), [update](./update.md) (massupdate) et [delete](./delete.md) (massdelete).
|
|
8
|
+
|
|
9
|
+
## Predicate Expressions (filters)
|
|
10
|
+
|
|
11
|
+
### Comparison operators
|
|
12
|
+
|
|
13
|
+
| Operator | Aliases | Meaning |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `$eq` | `$equal` | Equals |
|
|
16
|
+
| `$neq` | `$notEqual` | Not equals |
|
|
17
|
+
| `$gt` | `$greaterThan` | Greater than |
|
|
18
|
+
| `$gte` | `$greaterThanOrEqual` | Greater than or equal |
|
|
19
|
+
| `$lt` | `$lessThan` | Less than |
|
|
20
|
+
| `$lte` | `$lessThanOrEqual` | Less than or equal |
|
|
21
|
+
|
|
22
|
+
**Implicit equality:** `{ "p1": "v1" }` is equivalent to `{ "p1": { "$eq": "v1" } }`.
|
|
23
|
+
|
|
24
|
+
### Logical operators
|
|
25
|
+
|
|
26
|
+
- `$and`: `{ "$and": [ { "p1": "v1" }, { "p2": "v2" } ] }`
|
|
27
|
+
- `$or`: `{ "$or": [ { "p1": "v1" }, { "p2": "v2" } ] }`
|
|
28
|
+
- `$not`: negates a predicate
|
|
29
|
+
|
|
30
|
+
### Text operators (under `$text` namespace)
|
|
31
|
+
|
|
32
|
+
| Operator | Example | Returns |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `$contains` | `{ "p1": { "$text.$contains": "val" } }` | true if p1 contains "val" |
|
|
35
|
+
| `$startsWith` | `{ "p1": { "$text.$startsWith": "val" } }` | true if p1 starts with "val" |
|
|
36
|
+
| `$endWith` | `{ "p1": { "$text.$endWith": "val" } }` | true if p1 ends with "val" |
|
|
37
|
+
| `$indexOf` | `{ "p1": { "$text.$indexOf": "val" } }` | position of first occurrence, -1 if none |
|
|
38
|
+
| `$substring` | `{ "p1": { "$text.$substring": { "$startIndex": 1, "$length": 3 } } }` | substring extraction |
|
|
39
|
+
| `$length` | `{ "p1": { "$text.$length": {} } }` | length of the string |
|
|
40
|
+
|
|
41
|
+
Alternate syntax: `{ "p1": { "$text": { "$contains": "val" } } }` is equivalent.
|
|
42
|
+
|
|
43
|
+
### Collection operators
|
|
44
|
+
|
|
45
|
+
- `$any`: `{ "relateds": { "p1": "v1" } }` — true if at least one item in the collection matches.
|
|
46
|
+
- `$all`: true if every item in the collection matches.
|
|
47
|
+
|
|
48
|
+
### Date and time operators
|
|
49
|
+
|
|
50
|
+
| Operator | Purpose |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `$now` | Current date/time |
|
|
53
|
+
| `$utcNow` | Current UTC date/time |
|
|
54
|
+
| `$days`, `$hours`, `$minutes`, `$seconds`, `$milliseconds`, `$ticks`, `$timespan` | Duration construction |
|
|
55
|
+
|
|
56
|
+
**Example — entities modified in the last 24 hours:**
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{ "validFrom": { "$gt": { "$utcNow": { "$substract": { "$days": 1 } } } } }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Arithmetic operators
|
|
63
|
+
|
|
64
|
+
`$add`, `$substract`, `$multiply`, `$divide`, `$modulo`
|
|
65
|
+
|
|
66
|
+
**Example — sum of two properties:** `{ "p1": { "$add": { "p2": {} } } }`
|
|
67
|
+
|
|
68
|
+
## Value Expressions
|
|
69
|
+
|
|
70
|
+
A property is referenced by nesting JSON keys terminated with `{}`:
|
|
71
|
+
|
|
72
|
+
- `{ "p1": {} }` → value of `p1`
|
|
73
|
+
- `{ "p1": { "p2": {} } }` → value of `p1.p2` (deep access)
|
|
74
|
+
|
|
75
|
+
## Verbose Binary Operator Syntax
|
|
76
|
+
|
|
77
|
+
When fluent chaining doesn't work (e.g. when an operand is a terminal expression), use:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{ "$operator": { "$left": { ... }, "$right": { ... } } }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Ordering
|
|
84
|
+
|
|
85
|
+
The `orders` array contains objects with `desc` (boolean) and `value` (a value expression):
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"orders": [
|
|
90
|
+
{ "desc": true, "value": { "validFrom": {} } }
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Projection Expressions (selects)
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{ "$all": true, "secretField": false }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- `true` = include, `false` = exclude.
|
|
102
|
+
- `$all` sets the default for all properties.
|
|
103
|
+
- Nested objects define joins/expands: `{ "sessions": { "$all": true } }`
|
|
104
|
+
|
|
105
|
+
## Localizable Properties and `$meta`
|
|
106
|
+
|
|
107
|
+
Some properties (like `title`, `description`) are localized objects: `{ "fr": "...", "en": "..." }`. By default, operations apply to the event's default language.
|
|
108
|
+
|
|
109
|
+
To force a specific language, wrap the expression in `$meta`:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"$meta": {
|
|
114
|
+
"lang": "en",
|
|
115
|
+
"$token": { "title": { "$text.$contains": "search term" } }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Nested `$meta` operators are cumulative; the closest ancestor `$meta` wins when a localizable property is evaluated.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
◂ Retour à [README](./README.md)
|
|
2
|
+
|
|
3
|
+
# Query & Count (read entities)
|
|
4
|
+
|
|
5
|
+
Pour la syntaxe des `filters`, `orders` et `selects`, voir [query-language.md](./query-language.md).
|
|
6
|
+
|
|
7
|
+
## Query (read entities)
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
POST {api}/{shardId}/{entity}/query
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Request body:**
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"page": { "index": 0, "size": 50 },
|
|
18
|
+
"filters": { },
|
|
19
|
+
"orders": [ ],
|
|
20
|
+
"selects": { }
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `page` — **required**. `index` starts at 0, `size` between 1 and 300 (recommended: 50).
|
|
25
|
+
- `filters` — optional. A predicate expression (see "Query Language" section below).
|
|
26
|
+
- `orders` — optional. An array of order clauses (see "Ordering" below). If omitted, the API sorts by `id` when available.
|
|
27
|
+
- `selects` — optional. A projection expression (see "Projection" below). Defaults to `{ "$all": true }`.
|
|
28
|
+
|
|
29
|
+
**Response:** `{ "data": [ ...entities ] }`
|
|
30
|
+
|
|
31
|
+
### Pagination
|
|
32
|
+
|
|
33
|
+
The API uses **stateless offset-based pagination** (no cursor). To fetch all records, increment `page.index` until `data.length < page.size`. Because pagination is stateless, **duplicates can occur across pages** — always deduplicate results by `id`. Missing records can also happen if the sort property is non-unique or mutable; omitting `orders` lets the API pick the safest sort.
|
|
34
|
+
|
|
35
|
+
### Selecting specific fields
|
|
36
|
+
|
|
37
|
+
Return only specific properties:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"selects": { "$all": false, "mail": true, "firstname": true },
|
|
42
|
+
"page": { "index": 0, "size": 50 }
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Expanding related entities (joins)
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"selects": {
|
|
51
|
+
"$all": true,
|
|
52
|
+
"speakers": { "$all": true }
|
|
53
|
+
},
|
|
54
|
+
"page": { "index": 0, "size": 50 }
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Related entities are **never loaded automatically** — you must explicitly request them in `selects`.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Count
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
POST {api}/{shardId}/{entity}/count
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Same body as query (supports `filters` and `page`) but without `selects`. Returns a **single integer**.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
◂ Retour à [README](./README.md)
|
|
2
|
+
|
|
3
|
+
# Edit & Massupdate
|
|
4
|
+
|
|
5
|
+
## Edit (Patch)
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
POST {api}/{shardId}/{entity}/{entityId}
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Request body:**
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"entity": { "firstname": "Jane" },
|
|
16
|
+
"selects": { "$all": true }
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Important:** This is a **PATCH**, not a replace. Only properties present in the payload are updated. To clear a property, explicitly set it to `null`.
|
|
21
|
+
|
|
22
|
+
`selects` contrôle les propriétés retournées (voir [query-language.md](./query-language.md)).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Massupdate
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
POST {api}/{shardId}/{entity}/massupdate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Bulk EDIT like `UPDATE … WHERE` in SQL. S'appuie sur un filtre (voir [query-language.md](./query-language.md)).
|
|
33
|
+
|
|
34
|
+
> Pour mettre à jour un lot d'entités identifiées une à une, voir aussi [import.md](./import.md).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createContext, useCallback, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import Modal from './Modal';
|
|
3
|
+
|
|
4
|
+
export interface ConfirmOptions {
|
|
5
|
+
title?: string;
|
|
6
|
+
message: string;
|
|
7
|
+
confirmLabel?: string;
|
|
8
|
+
cancelLabel?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>;
|
|
12
|
+
|
|
13
|
+
export const ConfirmContext = createContext<ConfirmFn | null>(null);
|
|
14
|
+
|
|
15
|
+
interface PendingConfirm extends ConfirmOptions {
|
|
16
|
+
resolve: (value: boolean) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fournit `useConfirm()` : une fonction `confirm(options)` qui ouvre un dialogue
|
|
21
|
+
* et résout `true`/`false` selon le choix de l'utilisateur. À monter une fois,
|
|
22
|
+
* haut dans l'arbre (cf. main.tsx). Idéal avant une action destructrice (`del`).
|
|
23
|
+
*/
|
|
24
|
+
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
25
|
+
const [pending, setPending] = useState<PendingConfirm | null>(null);
|
|
26
|
+
const pendingRef = useRef<PendingConfirm | null>(null);
|
|
27
|
+
pendingRef.current = pending;
|
|
28
|
+
|
|
29
|
+
const confirm = useCallback<ConfirmFn>((options) => {
|
|
30
|
+
return new Promise<boolean>((resolve) => {
|
|
31
|
+
setPending({ ...options, resolve });
|
|
32
|
+
});
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
const settle = useCallback((value: boolean) => {
|
|
36
|
+
pendingRef.current?.resolve(value);
|
|
37
|
+
setPending(null);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<ConfirmContext.Provider value={confirm}>
|
|
42
|
+
{children}
|
|
43
|
+
{pending && (
|
|
44
|
+
<Modal
|
|
45
|
+
title={pending.title ?? 'Confirmation'}
|
|
46
|
+
onClose={() => settle(false)}
|
|
47
|
+
footer={
|
|
48
|
+
<>
|
|
49
|
+
<button onClick={() => settle(false)}>{pending.cancelLabel ?? 'Annuler'}</button>
|
|
50
|
+
<button className="btn-primary" onClick={() => settle(true)}>
|
|
51
|
+
{pending.confirmLabel ?? 'Confirmer'}
|
|
52
|
+
</button>
|
|
53
|
+
</>
|
|
54
|
+
}
|
|
55
|
+
>
|
|
56
|
+
<p>{pending.message}</p>
|
|
57
|
+
</Modal>
|
|
58
|
+
)}
|
|
59
|
+
</ConfirmContext.Provider>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.error-boundary {
|
|
2
|
+
margin: 2rem auto;
|
|
3
|
+
max-width: 32rem;
|
|
4
|
+
padding: 1.5rem;
|
|
5
|
+
border: 1px solid var(--color-border);
|
|
6
|
+
border-radius: var(--radius);
|
|
7
|
+
background: var(--color-surface);
|
|
8
|
+
text-align: center;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.error-boundary__title {
|
|
12
|
+
margin-top: 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.error-boundary__message {
|
|
16
|
+
color: var(--color-text-muted);
|
|
17
|
+
word-break: break-word;
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import './ErrorBoundary.css';
|
|
2
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface ErrorBoundaryProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
/** Rendu de repli optionnel ; sinon, fallback par défaut avec « Réessayer ». */
|
|
7
|
+
fallback?: (error: Error, reset: () => void) => ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ErrorBoundaryState {
|
|
11
|
+
error: Error | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Capture les erreurs de rendu des composants enfants et affiche un repli au
|
|
16
|
+
* lieu d'un écran blanc. À monter haut dans l'arbre (ex. autour de l'`Outlet`
|
|
17
|
+
* dans Layout). Ne capture PAS les erreurs asynchrones (gérées par `useAsync`).
|
|
18
|
+
*/
|
|
19
|
+
export default class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
20
|
+
state: ErrorBoundaryState = { error: null };
|
|
21
|
+
|
|
22
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
23
|
+
return { error };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
27
|
+
console.error('ErrorBoundary a capturé une erreur :', error, info);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private reset = () => this.setState({ error: null });
|
|
31
|
+
|
|
32
|
+
render() {
|
|
33
|
+
const { error } = this.state;
|
|
34
|
+
if (!error) return this.props.children;
|
|
35
|
+
if (this.props.fallback) return this.props.fallback(error, this.reset);
|
|
36
|
+
return (
|
|
37
|
+
<div className="error-boundary" role="alert">
|
|
38
|
+
<h2 className="error-boundary__title">Une erreur est survenue</h2>
|
|
39
|
+
<p className="error-boundary__message">{error.message}</p>
|
|
40
|
+
<button className="btn-primary" onClick={this.reset}>
|
|
41
|
+
Réessayer
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import './Layout.css';
|
|
2
2
|
import { Outlet } from 'react-router-dom';
|
|
3
3
|
import Header from './Header';
|
|
4
|
+
import ErrorBoundary from './ErrorBoundary';
|
|
4
5
|
|
|
5
6
|
interface LayoutProps {
|
|
6
7
|
title: string;
|
|
@@ -12,7 +13,9 @@ export default function Layout({ title }: LayoutProps) {
|
|
|
12
13
|
<div className="app-layout">
|
|
13
14
|
<Header title={title} />
|
|
14
15
|
<main className="app-content">
|
|
15
|
-
<
|
|
16
|
+
<ErrorBoundary>
|
|
17
|
+
<Outlet />
|
|
18
|
+
</ErrorBoundary>
|
|
16
19
|
</main>
|
|
17
20
|
</div>
|
|
18
21
|
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
.modal-overlay {
|
|
2
|
+
position: fixed;
|
|
3
|
+
inset: 0;
|
|
4
|
+
z-index: 900;
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
padding: 1rem;
|
|
9
|
+
background: rgba(0, 0, 0, 0.4);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.modal {
|
|
13
|
+
width: 100%;
|
|
14
|
+
max-width: 28rem;
|
|
15
|
+
padding: 1.5rem;
|
|
16
|
+
border-radius: var(--radius);
|
|
17
|
+
background: var(--color-bg);
|
|
18
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.modal__title {
|
|
22
|
+
margin-top: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.modal__body {
|
|
26
|
+
color: var(--color-text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.modal__footer {
|
|
30
|
+
display: flex;
|
|
31
|
+
justify-content: flex-end;
|
|
32
|
+
gap: 0.5rem;
|
|
33
|
+
margin-top: 1.5rem;
|
|
34
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import './Modal.css';
|
|
2
|
+
import { useEffect, useRef, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface ModalProps {
|
|
5
|
+
title: string;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Contenu du pied (boutons d'action). */
|
|
9
|
+
footer?: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FOCUSABLE =
|
|
13
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Boîte modale minimale : overlay, fermeture par Échap / clic extérieur, focus
|
|
17
|
+
* piégé à l'intérieur. Base pour ConfirmDialog et tout dialogue personnalisé.
|
|
18
|
+
*/
|
|
19
|
+
export default function Modal({ title, onClose, children, footer }: ModalProps) {
|
|
20
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
24
|
+
ref.current?.focus();
|
|
25
|
+
|
|
26
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
27
|
+
if (e.key === 'Escape') {
|
|
28
|
+
onClose();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (e.key !== 'Tab') return;
|
|
32
|
+
const focusables = ref.current?.querySelectorAll<HTMLElement>(FOCUSABLE);
|
|
33
|
+
if (!focusables || focusables.length === 0) return;
|
|
34
|
+
const first = focusables[0];
|
|
35
|
+
const last = focusables[focusables.length - 1];
|
|
36
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
last.focus();
|
|
39
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
first.focus();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
document.addEventListener('keydown', onKeyDown);
|
|
46
|
+
return () => {
|
|
47
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
48
|
+
previouslyFocused?.focus();
|
|
49
|
+
};
|
|
50
|
+
}, [onClose]);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="modal-overlay" onMouseDown={onClose}>
|
|
54
|
+
<div
|
|
55
|
+
ref={ref}
|
|
56
|
+
className="modal"
|
|
57
|
+
role="dialog"
|
|
58
|
+
aria-modal="true"
|
|
59
|
+
aria-label={title}
|
|
60
|
+
tabIndex={-1}
|
|
61
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
62
|
+
>
|
|
63
|
+
<h2 className="modal__title">{title}</h2>
|
|
64
|
+
<div className="modal__body">{children}</div>
|
|
65
|
+
{footer && <div className="modal__footer">{footer}</div>}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import './EmptyState.css';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface EmptyStateProps {
|
|
5
|
+
title?: string;
|
|
6
|
+
message?: string;
|
|
7
|
+
/** Action optionnelle (ex. bouton « Créer »). */
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** État « aucun résultat » réutilisable pour les listes vides. */
|
|
12
|
+
export default function EmptyState({ title = 'Aucun résultat', message, children }: EmptyStateProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="empty-state">
|
|
15
|
+
<p className="empty-state__title">{title}</p>
|
|
16
|
+
{message && <p className="empty-state__message">{message}</p>}
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import './ErrorState.css';
|
|
2
|
+
import { InwinkApiError } from '../../hooks/useInwinkApi';
|
|
3
|
+
|
|
4
|
+
interface ErrorStateProps {
|
|
5
|
+
error: unknown;
|
|
6
|
+
/** Callback de relance ; affiche un bouton « Réessayer » si fourni. */
|
|
7
|
+
onRetry?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function describe(error: unknown): string {
|
|
11
|
+
if (error instanceof InwinkApiError) {
|
|
12
|
+
return error.status === 403
|
|
13
|
+
? "Vous n'avez pas les droits pour cette action (403)."
|
|
14
|
+
: `Erreur lors de l'appel à l'API inwink (code ${error.status}).`;
|
|
15
|
+
}
|
|
16
|
+
if (error instanceof Error) return error.message;
|
|
17
|
+
return String(error);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Affiche une erreur d'appel API de façon lisible (gère `InwinkApiError`). */
|
|
21
|
+
export default function ErrorState({ error, onRetry }: ErrorStateProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="error-state" role="alert">
|
|
24
|
+
<p className="error-state__message">{describe(error)}</p>
|
|
25
|
+
{onRetry && <button onClick={onRetry}>Réessayer</button>}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
.spinner {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: 0.5rem;
|
|
5
|
+
color: var(--color-text-muted);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.spinner__circle {
|
|
9
|
+
width: 1rem;
|
|
10
|
+
height: 1rem;
|
|
11
|
+
border: 2px solid var(--color-border);
|
|
12
|
+
border-top-color: var(--color-accent);
|
|
13
|
+
border-radius: 50%;
|
|
14
|
+
animation: spinner-spin 0.6s linear infinite;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@keyframes spinner-spin {
|
|
18
|
+
to {
|
|
19
|
+
transform: rotate(360deg);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import './Spinner.css';
|
|
2
|
+
|
|
3
|
+
interface SpinnerProps {
|
|
4
|
+
label?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Indicateur de chargement accessible. */
|
|
8
|
+
export default function Spinner({ label = 'Chargement…' }: SpinnerProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="spinner" role="status" aria-live="polite">
|
|
11
|
+
<span className="spinner__circle" aria-hidden />
|
|
12
|
+
<span className="spinner__label">{label}</span>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
.toast-stack {
|
|
2
|
+
position: fixed;
|
|
3
|
+
right: 1rem;
|
|
4
|
+
bottom: 1rem;
|
|
5
|
+
z-index: 1000;
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
gap: 0.5rem;
|
|
9
|
+
max-width: min(24rem, calc(100vw - 2rem));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.toast {
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: flex-start;
|
|
15
|
+
gap: 0.75rem;
|
|
16
|
+
padding: 0.75rem 1rem;
|
|
17
|
+
border-radius: var(--radius);
|
|
18
|
+
background: var(--color-text);
|
|
19
|
+
color: #fff;
|
|
20
|
+
box-shadow: var(--shadow-sm);
|
|
21
|
+
animation: toast-in 0.15s ease;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.toast--success {
|
|
25
|
+
background: #1f8a4c;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.toast--error {
|
|
29
|
+
background: var(--color-accent);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.toast--info {
|
|
33
|
+
background: var(--color-text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.toast__message {
|
|
37
|
+
flex: 1;
|
|
38
|
+
font-size: 0.9rem;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.toast__close {
|
|
42
|
+
padding: 0;
|
|
43
|
+
border: none;
|
|
44
|
+
background: transparent;
|
|
45
|
+
color: inherit;
|
|
46
|
+
font-size: 1.1rem;
|
|
47
|
+
line-height: 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.toast__close:hover:not(:disabled) {
|
|
51
|
+
border-color: transparent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@keyframes toast-in {
|
|
55
|
+
from {
|
|
56
|
+
opacity: 0;
|
|
57
|
+
transform: translateY(0.5rem);
|
|
58
|
+
}
|
|
59
|
+
}
|