@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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/templates/test-app/src/components/EventApiTester.tsx +51 -39
  3. package/templates/vite-extension/AGENTS.md.tpl +104 -1
  4. package/templates/vite-extension/docs/inwink-api/README.md +42 -0
  5. package/templates/vite-extension/docs/inwink-api/create.md +21 -0
  6. package/templates/vite-extension/docs/inwink-api/delete.md +21 -0
  7. package/templates/vite-extension/docs/inwink-api/entities.md +68 -0
  8. package/templates/vite-extension/docs/inwink-api/files.md +189 -0
  9. package/templates/vite-extension/docs/inwink-api/import.md +24 -0
  10. package/templates/vite-extension/docs/inwink-api/query-language.md +120 -0
  11. package/templates/vite-extension/docs/inwink-api/query.md +68 -0
  12. package/templates/vite-extension/docs/inwink-api/update.md +34 -0
  13. package/templates/vite-extension/src/components/ConfirmDialog.tsx +61 -0
  14. package/templates/vite-extension/src/components/ErrorBoundary.css +18 -0
  15. package/templates/vite-extension/src/components/ErrorBoundary.tsx +46 -0
  16. package/templates/vite-extension/src/components/Layout.tsx +4 -1
  17. package/templates/vite-extension/src/components/Modal.css +34 -0
  18. package/templates/vite-extension/src/components/Modal.tsx +69 -0
  19. package/templates/vite-extension/src/components/feedback/EmptyState.css +15 -0
  20. package/templates/vite-extension/src/components/feedback/EmptyState.tsx +20 -0
  21. package/templates/vite-extension/src/components/feedback/ErrorState.css +12 -0
  22. package/templates/vite-extension/src/components/feedback/ErrorState.tsx +28 -0
  23. package/templates/vite-extension/src/components/feedback/Spinner.css +21 -0
  24. package/templates/vite-extension/src/components/feedback/Spinner.tsx +15 -0
  25. package/templates/vite-extension/src/components/toast/ToastProvider.css +59 -0
  26. package/templates/vite-extension/src/components/toast/ToastProvider.tsx +67 -0
  27. package/templates/vite-extension/src/hooks/useAsync.ts +71 -0
  28. package/templates/vite-extension/src/hooks/useConfirm.ts +11 -0
  29. package/templates/vite-extension/src/hooks/useInwinkApi.ts +26 -0
  30. package/templates/vite-extension/src/hooks/useInwinkPagedQuery.ts +100 -0
  31. package/templates/vite-extension/src/hooks/useToast.ts +11 -0
  32. 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
- <Outlet />
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,15 @@
1
+ .empty-state {
2
+ padding: 2rem 1rem;
3
+ text-align: center;
4
+ color: var(--color-text-muted);
5
+ }
6
+
7
+ .empty-state__title {
8
+ margin: 0;
9
+ font-weight: 600;
10
+ }
11
+
12
+ .empty-state__message {
13
+ margin: 0.25rem 0 0;
14
+ font-size: 0.9rem;
15
+ }
@@ -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,12 @@
1
+ .error-state {
2
+ padding: 1.25rem;
3
+ border: 1px solid var(--color-border);
4
+ border-radius: var(--radius);
5
+ background: var(--color-surface);
6
+ text-align: center;
7
+ }
8
+
9
+ .error-state__message {
10
+ margin: 0 0 0.75rem;
11
+ color: var(--color-text);
12
+ }
@@ -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
+ }