@ismaelsoilet/synapsejs 1.9.0 → 1.11.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/README.md +4 -3
- package/bin/synapse.ts +74 -2
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +2 -0
- package/src/compiler/contract.ts +1 -1
- package/src/compiler/oracle-runner.ts +10 -1
- package/src/compiler/scaffolder.ts +113 -4
- package/src/compiler/slice-splitter.ts +21 -3
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +50 -1
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +35 -0
- package/src/core/index.ts +2 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +51 -2
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +16 -1
- package/src/core/sqlite-client.ts +20 -0
- package/src/mcp/server.ts +257 -1
- package/src/mcp/tools.ts +92 -1
- package/src/project-status.ts +103 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/queue-engine.ts +120 -5
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +300 -58
- package/templates/starter/package.json +2 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SliceErrorBoundaryProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
fallback?: ReactNode | ((error: Error) => ReactNode);
|
|
6
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface SliceErrorBoundaryState {
|
|
10
|
+
hasError: boolean;
|
|
11
|
+
error: Error | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class SliceErrorBoundary extends Component<SliceErrorBoundaryProps, SliceErrorBoundaryState> {
|
|
15
|
+
override state: SliceErrorBoundaryState = {
|
|
16
|
+
hasError: false,
|
|
17
|
+
error: null
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
static getDerivedStateFromError(error: Error): SliceErrorBoundaryState {
|
|
21
|
+
return { hasError: true, error };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
25
|
+
if (this.props.onError) {
|
|
26
|
+
this.props.onError(error, info);
|
|
27
|
+
} else {
|
|
28
|
+
console.error('[SliceErrorBoundary] Erro capturado na interface da fatia:', error, info);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override render(): ReactNode {
|
|
33
|
+
if (this.state.hasError && this.state.error) {
|
|
34
|
+
if (typeof this.props.fallback === 'function') {
|
|
35
|
+
return this.props.fallback(this.state.error);
|
|
36
|
+
}
|
|
37
|
+
if (this.props.fallback) {
|
|
38
|
+
return this.props.fallback;
|
|
39
|
+
}
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
role="alert"
|
|
43
|
+
style={{
|
|
44
|
+
padding: '1rem',
|
|
45
|
+
border: '1px solid #f43f5e',
|
|
46
|
+
borderRadius: '0.375rem',
|
|
47
|
+
background: 'rgba(244, 63, 94, 0.1)',
|
|
48
|
+
color: '#f43f5e',
|
|
49
|
+
fontFamily: 'system-ui, -apple-system, sans-serif'
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<strong style={{ display: 'block', marginBottom: '0.25rem' }}>Erro na interface da fatia:</strong>
|
|
53
|
+
<span>{this.state.error.message}</span>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return this.props.children;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/client/hooks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import type { Result } from '../core/machine-types';
|
|
3
3
|
import { useSynapseContext } from './context';
|
|
4
|
+
import { useToast } from './toast';
|
|
4
5
|
|
|
5
6
|
export interface UseSubscriptionOptions {
|
|
6
7
|
enabled?: boolean;
|
|
@@ -90,6 +91,7 @@ export interface UseActionResult<TPayload, TData, TError> {
|
|
|
90
91
|
export interface UseActionOptions<TData, TError> {
|
|
91
92
|
onSuccess?: (data: TData) => void;
|
|
92
93
|
onError?: (error: TError) => void;
|
|
94
|
+
toast?: boolean | { success?: string; error?: string };
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
/**
|
|
@@ -104,6 +106,7 @@ export function useAction<TPayload = unknown, TData = unknown, TError = string>(
|
|
|
104
106
|
const [data, setData] = useState<TData | null>(null);
|
|
105
107
|
const [error, setError] = useState<TError | null>(null);
|
|
106
108
|
const [isSuccess, setIsSuccess] = useState<boolean>(false);
|
|
109
|
+
const toast = useToast();
|
|
107
110
|
|
|
108
111
|
const ctx = useSynapseContext();
|
|
109
112
|
const resolvedAction =
|
|
@@ -141,10 +144,24 @@ export function useAction<TPayload = unknown, TData = unknown, TError = string>(
|
|
|
141
144
|
setIsSuccess(true);
|
|
142
145
|
setError(null);
|
|
143
146
|
optionsRef.current?.onSuccess?.(result.value);
|
|
147
|
+
if (optionsRef.current?.toast) {
|
|
148
|
+
const successMsg =
|
|
149
|
+
typeof optionsRef.current.toast === 'object' && optionsRef.current.toast.success
|
|
150
|
+
? optionsRef.current.toast.success
|
|
151
|
+
: 'Ação executada com sucesso!';
|
|
152
|
+
toast.success(successMsg);
|
|
153
|
+
}
|
|
144
154
|
} else {
|
|
145
155
|
setError(result.error);
|
|
146
156
|
setIsSuccess(false);
|
|
147
157
|
optionsRef.current?.onError?.(result.error);
|
|
158
|
+
if (optionsRef.current?.toast) {
|
|
159
|
+
const errorMsg =
|
|
160
|
+
typeof optionsRef.current.toast === 'object' && optionsRef.current.toast.error
|
|
161
|
+
? optionsRef.current.toast.error
|
|
162
|
+
: String(result.error);
|
|
163
|
+
toast.error(errorMsg);
|
|
164
|
+
}
|
|
148
165
|
}
|
|
149
166
|
return result;
|
|
150
167
|
} catch (err: unknown) {
|
|
@@ -152,12 +169,15 @@ export function useAction<TPayload = unknown, TData = unknown, TError = string>(
|
|
|
152
169
|
setError(failure);
|
|
153
170
|
setIsSuccess(false);
|
|
154
171
|
optionsRef.current?.onError?.(failure);
|
|
172
|
+
if (optionsRef.current?.toast) {
|
|
173
|
+
toast.error(String(failure));
|
|
174
|
+
}
|
|
155
175
|
return { ok: false, error: failure };
|
|
156
176
|
} finally {
|
|
157
177
|
setIsSubmitting(false);
|
|
158
178
|
}
|
|
159
179
|
},
|
|
160
|
-
[resolvedAction]
|
|
180
|
+
[resolvedAction, toast]
|
|
161
181
|
);
|
|
162
182
|
|
|
163
183
|
return {
|
|
@@ -171,3 +191,82 @@ export function useAction<TPayload = unknown, TData = unknown, TError = string>(
|
|
|
171
191
|
reset
|
|
172
192
|
};
|
|
173
193
|
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Hook for reading dynamic URL route parameters (e.g. /:domain/:name/:id).
|
|
197
|
+
* Resolves from window.location pathname segments or SSR injected route params.
|
|
198
|
+
*/
|
|
199
|
+
export function useParams<T extends Record<string, string> = Record<string, string>>(): T {
|
|
200
|
+
const ctx = useSynapseContext();
|
|
201
|
+
const [params] = useState<T>(() => {
|
|
202
|
+
if (ctx.props?.__synapse_params) {
|
|
203
|
+
return ctx.props.__synapse_params as T;
|
|
204
|
+
}
|
|
205
|
+
if (typeof window !== 'undefined') {
|
|
206
|
+
const globalParams = (window as unknown as { __SYNAPSE_ROUTE_PARAMS__?: T }).__SYNAPSE_ROUTE_PARAMS__;
|
|
207
|
+
if (globalParams) return globalParams;
|
|
208
|
+
const segments = window.location.pathname.split('/').filter(Boolean);
|
|
209
|
+
if (segments.length >= 3) {
|
|
210
|
+
const remaining = segments.slice(2);
|
|
211
|
+
const mapped: Record<string, string> = { id: remaining[0] };
|
|
212
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
213
|
+
mapped[`param${i + 1}`] = remaining[i];
|
|
214
|
+
}
|
|
215
|
+
return mapped as T;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {} as T;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return params;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Hook for reading and mutating query string search parameters seamlessly.
|
|
226
|
+
*/
|
|
227
|
+
export function useSearchParams(): [
|
|
228
|
+
URLSearchParams,
|
|
229
|
+
(updater: Record<string, string | null | undefined> | URLSearchParams) => void
|
|
230
|
+
] {
|
|
231
|
+
const ctx = useSynapseContext();
|
|
232
|
+
const [searchParams, setSearchParamsState] = useState<URLSearchParams>(() => {
|
|
233
|
+
if (typeof window !== 'undefined') {
|
|
234
|
+
return new URLSearchParams(window.location.search);
|
|
235
|
+
}
|
|
236
|
+
if (ctx.url) {
|
|
237
|
+
try {
|
|
238
|
+
const u = new URL(ctx.url, 'http://localhost');
|
|
239
|
+
return u.searchParams;
|
|
240
|
+
} catch {
|
|
241
|
+
// fallback
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return new URLSearchParams();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const setSearchParams = useCallback((updater: Record<string, string | null | undefined> | URLSearchParams) => {
|
|
248
|
+
if (typeof window === 'undefined') return;
|
|
249
|
+
|
|
250
|
+
const next = new URLSearchParams(window.location.search);
|
|
251
|
+
if (updater instanceof URLSearchParams) {
|
|
252
|
+
for (const [k, v] of updater.entries()) {
|
|
253
|
+
next.set(k, v);
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
for (const [k, v] of Object.entries(updater)) {
|
|
257
|
+
if (v === null || v === undefined) {
|
|
258
|
+
next.delete(k);
|
|
259
|
+
} else {
|
|
260
|
+
next.set(k, v);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const queryStr = next.toString();
|
|
266
|
+
const newUrl = queryStr ? `${window.location.pathname}?${queryStr}` : window.location.pathname;
|
|
267
|
+
window.history.pushState({}, '', newUrl);
|
|
268
|
+
setSearchParamsState(next);
|
|
269
|
+
}, []);
|
|
270
|
+
|
|
271
|
+
return [searchParams, setSearchParams];
|
|
272
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// TOAST NOTIFICATION TYPES
|
|
5
|
+
// ============================================================================
|
|
6
|
+
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
|
7
|
+
|
|
8
|
+
export interface ToastItem {
|
|
9
|
+
id: string;
|
|
10
|
+
type: ToastType;
|
|
11
|
+
title?: string;
|
|
12
|
+
message: string;
|
|
13
|
+
durationMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ToastContextValue {
|
|
17
|
+
toast: (options: Omit<ToastItem, 'id'>) => string;
|
|
18
|
+
success: (message: string, title?: string) => string;
|
|
19
|
+
error: (message: string, title?: string) => string;
|
|
20
|
+
warning: (message: string, title?: string) => string;
|
|
21
|
+
info: (message: string, title?: string) => string;
|
|
22
|
+
dismiss: (id: string) => void;
|
|
23
|
+
clear: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
27
|
+
|
|
28
|
+
let globalToastEmitter: ((item: Omit<ToastItem, 'id'>) => string) | null = null;
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// TOAST PROVIDER & FLOATING STACK
|
|
32
|
+
// ============================================================================
|
|
33
|
+
export interface ToastProviderProps {
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
|
36
|
+
maxToasts?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ToastProvider({ children, position = 'bottom-right', maxToasts = 5 }: ToastProviderProps) {
|
|
40
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
41
|
+
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
42
|
+
|
|
43
|
+
const dismiss = useCallback((id: string) => {
|
|
44
|
+
const timer = timersRef.current.get(id);
|
|
45
|
+
if (timer) {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
timersRef.current.delete(id);
|
|
48
|
+
}
|
|
49
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const clear = useCallback(() => {
|
|
53
|
+
for (const timer of timersRef.current.values()) {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
timersRef.current.clear();
|
|
57
|
+
setToasts([]);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const toast = useCallback(
|
|
61
|
+
(options: Omit<ToastItem, 'id'>): string => {
|
|
62
|
+
const id = `toast_${Math.random().toString(36).substring(2, 9)}`;
|
|
63
|
+
const duration = options.durationMs ?? 4000;
|
|
64
|
+
const newItem: ToastItem = { ...options, id };
|
|
65
|
+
|
|
66
|
+
setToasts((prev) => {
|
|
67
|
+
const next = [...prev, newItem];
|
|
68
|
+
if (next.length > maxToasts) {
|
|
69
|
+
const evicted = next.shift();
|
|
70
|
+
if (evicted) {
|
|
71
|
+
const timer = timersRef.current.get(evicted.id);
|
|
72
|
+
if (timer) clearTimeout(timer);
|
|
73
|
+
timersRef.current.delete(evicted.id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return next;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (duration > 0) {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
dismiss(id);
|
|
82
|
+
}, duration);
|
|
83
|
+
timersRef.current.set(id, timer);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return id;
|
|
87
|
+
},
|
|
88
|
+
[dismiss, maxToasts]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const success = useCallback((message: string, title?: string) => toast({ type: 'success', message, title }), [toast]);
|
|
92
|
+
const error = useCallback((message: string, title?: string) => toast({ type: 'error', message, title }), [toast]);
|
|
93
|
+
const warning = useCallback((message: string, title?: string) => toast({ type: 'warning', message, title }), [toast]);
|
|
94
|
+
const info = useCallback((message: string, title?: string) => toast({ type: 'info', message, title }), [toast]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
globalToastEmitter = toast;
|
|
98
|
+
return () => {
|
|
99
|
+
globalToastEmitter = null;
|
|
100
|
+
for (const timer of timersRef.current.values()) {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
timersRef.current.clear();
|
|
104
|
+
};
|
|
105
|
+
}, [toast]);
|
|
106
|
+
|
|
107
|
+
const positionStyles = {
|
|
108
|
+
'top-right': 'top-4 right-4 items-end',
|
|
109
|
+
'top-left': 'top-4 left-4 items-start',
|
|
110
|
+
'bottom-right': 'bottom-4 right-4 items-end',
|
|
111
|
+
'bottom-left': 'bottom-4 left-4 items-start'
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const typeConfig: Record<ToastType, { bg: string; border: string; text: string; icon: string }> = {
|
|
115
|
+
success: {
|
|
116
|
+
bg: 'bg-emerald-950/90',
|
|
117
|
+
border: 'border-emerald-800',
|
|
118
|
+
text: 'text-emerald-300',
|
|
119
|
+
icon: '✓'
|
|
120
|
+
},
|
|
121
|
+
error: {
|
|
122
|
+
bg: 'bg-rose-950/90',
|
|
123
|
+
border: 'border-rose-800',
|
|
124
|
+
text: 'text-rose-300',
|
|
125
|
+
icon: '✕'
|
|
126
|
+
},
|
|
127
|
+
warning: {
|
|
128
|
+
bg: 'bg-amber-950/90',
|
|
129
|
+
border: 'border-amber-800',
|
|
130
|
+
text: 'text-amber-300',
|
|
131
|
+
icon: '⚠'
|
|
132
|
+
},
|
|
133
|
+
info: {
|
|
134
|
+
bg: 'bg-sky-950/90',
|
|
135
|
+
border: 'border-sky-800',
|
|
136
|
+
text: 'text-sky-300',
|
|
137
|
+
icon: 'ℹ'
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<ToastContext.Provider value={{ toast, success, error, warning, info, dismiss, clear }}>
|
|
143
|
+
{children}
|
|
144
|
+
<div
|
|
145
|
+
aria-live="polite"
|
|
146
|
+
className={`fixed z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none p-4 ${positionStyles[position]}`}
|
|
147
|
+
>
|
|
148
|
+
{toasts.map((item) => {
|
|
149
|
+
const conf = typeConfig[item.type] || typeConfig.info;
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
key={item.id}
|
|
153
|
+
role="alert"
|
|
154
|
+
className={`pointer-events-auto flex items-start gap-3 p-4 rounded-xl border backdrop-blur-md shadow-2xl transition-all duration-200 transform translate-y-0 text-xs w-full ${conf.bg} ${conf.border}`}
|
|
155
|
+
>
|
|
156
|
+
<span
|
|
157
|
+
className={`h-5 w-5 rounded-full flex items-center justify-center font-bold text-xs shrink-0 ${conf.text} bg-slate-900/60 border ${conf.border}`}
|
|
158
|
+
>
|
|
159
|
+
{conf.icon}
|
|
160
|
+
</span>
|
|
161
|
+
<div className="flex-1 min-w-0">
|
|
162
|
+
{item.title && <h5 className={`font-bold mb-0.5 ${conf.text}`}>{item.title}</h5>}
|
|
163
|
+
<p className="text-slate-200 leading-relaxed break-words">{item.message}</p>
|
|
164
|
+
</div>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
onClick={() => dismiss(item.id)}
|
|
168
|
+
aria-label="Fechar notificação"
|
|
169
|
+
className="text-slate-400 hover:text-white transition-colors text-sm font-mono shrink-0 ml-1 p-0.5"
|
|
170
|
+
>
|
|
171
|
+
✕
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</div>
|
|
177
|
+
</ToastContext.Provider>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// HOOK & IMPERATIVE API
|
|
183
|
+
// ============================================================================
|
|
184
|
+
export function useToast(): ToastContextValue {
|
|
185
|
+
const ctx = useContext(ToastContext);
|
|
186
|
+
if (!ctx) {
|
|
187
|
+
// Fallback gracioso caso chamado fora do provider ou no server
|
|
188
|
+
return {
|
|
189
|
+
toast: (opts) => {
|
|
190
|
+
if (globalToastEmitter) return globalToastEmitter(opts);
|
|
191
|
+
if (typeof console !== 'undefined') console.log(`[Toast ${opts.type}] ${opts.message}`);
|
|
192
|
+
return 'noop';
|
|
193
|
+
},
|
|
194
|
+
success: (msg, title) => {
|
|
195
|
+
if (globalToastEmitter) return globalToastEmitter({ type: 'success', message: msg, title });
|
|
196
|
+
if (typeof console !== 'undefined') console.log(`[Toast success] ${msg}`);
|
|
197
|
+
return 'noop';
|
|
198
|
+
},
|
|
199
|
+
error: (msg, title) => {
|
|
200
|
+
if (globalToastEmitter) return globalToastEmitter({ type: 'error', message: msg, title });
|
|
201
|
+
if (typeof console !== 'undefined') console.error(`[Toast error] ${msg}`);
|
|
202
|
+
return 'noop';
|
|
203
|
+
},
|
|
204
|
+
warning: (msg, title) => {
|
|
205
|
+
if (globalToastEmitter) return globalToastEmitter({ type: 'warning', message: msg, title });
|
|
206
|
+
if (typeof console !== 'undefined') console.warn(`[Toast warning] ${msg}`);
|
|
207
|
+
return 'noop';
|
|
208
|
+
},
|
|
209
|
+
info: (msg, title) => {
|
|
210
|
+
if (globalToastEmitter) return globalToastEmitter({ type: 'info', message: msg, title });
|
|
211
|
+
if (typeof console !== 'undefined') console.info(`[Toast info] ${msg}`);
|
|
212
|
+
return 'noop';
|
|
213
|
+
},
|
|
214
|
+
dismiss: () => {},
|
|
215
|
+
clear: () => {}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return ctx;
|
|
219
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import type { Result } from '../core/machine-types';
|
|
3
|
+
|
|
4
|
+
export interface UseUploadOptions {
|
|
5
|
+
/** Target upload URL (default: /_synapse/files/general) */
|
|
6
|
+
url?: string;
|
|
7
|
+
/** Maximum allowed file size in bytes (default: 50MB) */
|
|
8
|
+
maxBytes?: number;
|
|
9
|
+
/** Allowed MIME types (e.g. ['image/png', 'image/jpeg', 'application/pdf']) */
|
|
10
|
+
allowedTypes?: string[];
|
|
11
|
+
/** Callback fired as upload progress increments */
|
|
12
|
+
onProgress?: (percent: number, loaded: number, total: number) => void;
|
|
13
|
+
/** Callback fired on successful upload completion */
|
|
14
|
+
onSuccess?: (response: unknown) => void;
|
|
15
|
+
/** Callback fired on upload failure */
|
|
16
|
+
onError?: (error: Error) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseUploadResult {
|
|
20
|
+
upload: (file: File) => Promise<Result<unknown, string>>;
|
|
21
|
+
progress: number;
|
|
22
|
+
loaded: number;
|
|
23
|
+
total: number;
|
|
24
|
+
isUploading: boolean;
|
|
25
|
+
abort: () => void;
|
|
26
|
+
reset: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Universal browser hook for client-side file uploads with granular progress events and abort controls.
|
|
31
|
+
*/
|
|
32
|
+
export function useUpload(options: UseUploadOptions = {}): UseUploadResult {
|
|
33
|
+
const [progress, setProgress] = useState<number>(0);
|
|
34
|
+
const [loaded, setLoaded] = useState<number>(0);
|
|
35
|
+
const [total, setTotal] = useState<number>(0);
|
|
36
|
+
const [isUploading, setIsUploading] = useState<boolean>(false);
|
|
37
|
+
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
|
38
|
+
|
|
39
|
+
const reset = useCallback(() => {
|
|
40
|
+
if (xhrRef.current) {
|
|
41
|
+
xhrRef.current.abort();
|
|
42
|
+
xhrRef.current = null;
|
|
43
|
+
}
|
|
44
|
+
setProgress(0);
|
|
45
|
+
setLoaded(0);
|
|
46
|
+
setTotal(0);
|
|
47
|
+
setIsUploading(false);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const abort = useCallback(() => {
|
|
51
|
+
if (xhrRef.current) {
|
|
52
|
+
xhrRef.current.abort();
|
|
53
|
+
xhrRef.current = null;
|
|
54
|
+
setIsUploading(false);
|
|
55
|
+
}
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const upload = useCallback(
|
|
59
|
+
async (file: File): Promise<Result<unknown, string>> => {
|
|
60
|
+
// 1. Client-Side Pre-Flight Validations
|
|
61
|
+
if (options.maxBytes && file.size > options.maxBytes) {
|
|
62
|
+
const err = `Arquivo excede o tamanho máximo permitido (${options.maxBytes} bytes)`;
|
|
63
|
+
options.onError?.(new Error(err));
|
|
64
|
+
return { ok: false, error: 'FILE_TOO_LARGE' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (options.allowedTypes && options.allowedTypes.length > 0) {
|
|
68
|
+
if (!options.allowedTypes.includes(file.type)) {
|
|
69
|
+
const err = `Tipo de arquivo "${file.type}" não permitido. Aceitos: ${options.allowedTypes.join(', ')}`;
|
|
70
|
+
options.onError?.(new Error(err));
|
|
71
|
+
return { ok: false, error: 'FILE_TYPE_NOT_ALLOWED' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setIsUploading(true);
|
|
76
|
+
setProgress(0);
|
|
77
|
+
setLoaded(0);
|
|
78
|
+
setTotal(file.size);
|
|
79
|
+
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const xhr = new XMLHttpRequest();
|
|
82
|
+
xhrRef.current = xhr;
|
|
83
|
+
|
|
84
|
+
const targetUrl = options.url || '/_synapse/files/general';
|
|
85
|
+
xhr.open('POST', targetUrl, true);
|
|
86
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
87
|
+
|
|
88
|
+
xhr.upload.onprogress = (e) => {
|
|
89
|
+
if (e.lengthComputable) {
|
|
90
|
+
const pct = Math.round((e.loaded / e.total) * 100);
|
|
91
|
+
setProgress(pct);
|
|
92
|
+
setLoaded(e.loaded);
|
|
93
|
+
setTotal(e.total);
|
|
94
|
+
options.onProgress?.(pct, e.loaded, e.total);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
xhr.onload = () => {
|
|
99
|
+
setIsUploading(false);
|
|
100
|
+
xhrRef.current = null;
|
|
101
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
102
|
+
setProgress(100);
|
|
103
|
+
try {
|
|
104
|
+
const res = JSON.parse(xhr.responseText);
|
|
105
|
+
options.onSuccess?.(res);
|
|
106
|
+
resolve({ ok: true, value: res });
|
|
107
|
+
} catch {
|
|
108
|
+
options.onSuccess?.(xhr.responseText);
|
|
109
|
+
resolve({ ok: true, value: xhr.responseText });
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
let errorMsg = `Upload falhou com status HTTP ${xhr.status}`;
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(xhr.responseText);
|
|
115
|
+
if (parsed.error) errorMsg = parsed.error;
|
|
116
|
+
} catch {}
|
|
117
|
+
options.onError?.(new Error(errorMsg));
|
|
118
|
+
resolve({ ok: false, error: errorMsg });
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
xhr.onerror = () => {
|
|
123
|
+
setIsUploading(false);
|
|
124
|
+
xhrRef.current = null;
|
|
125
|
+
const err = new Error('Erro de rede ao enviar arquivo.');
|
|
126
|
+
options.onError?.(err);
|
|
127
|
+
resolve({ ok: false, error: 'NETWORK_ERROR' });
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
xhr.onabort = () => {
|
|
131
|
+
setIsUploading(false);
|
|
132
|
+
xhrRef.current = null;
|
|
133
|
+
resolve({ ok: false, error: 'UPLOAD_ABORTED' });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const formData = new FormData();
|
|
137
|
+
formData.append('file', file, file.name);
|
|
138
|
+
xhr.send(formData);
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
[options]
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
upload,
|
|
146
|
+
progress,
|
|
147
|
+
loaded,
|
|
148
|
+
total,
|
|
149
|
+
isUploading,
|
|
150
|
+
abort,
|
|
151
|
+
reset
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -23,12 +23,28 @@ export {
|
|
|
23
23
|
type DataFormProps,
|
|
24
24
|
DataTable,
|
|
25
25
|
type DataTableProps,
|
|
26
|
+
Drawer,
|
|
27
|
+
type DrawerProps,
|
|
28
|
+
DropdownItem,
|
|
29
|
+
type DropdownItemProps,
|
|
30
|
+
DropdownMenu,
|
|
31
|
+
type DropdownMenuProps,
|
|
26
32
|
expandNestedObject,
|
|
27
33
|
type FormField,
|
|
28
34
|
getNestedProperty,
|
|
35
|
+
Modal,
|
|
36
|
+
type ModalProps,
|
|
29
37
|
Pagination,
|
|
30
38
|
type PaginationProps,
|
|
31
|
-
setNestedProperty
|
|
39
|
+
setNestedProperty,
|
|
40
|
+
Tab,
|
|
41
|
+
TabList,
|
|
42
|
+
type TabListProps,
|
|
43
|
+
TabPanel,
|
|
44
|
+
type TabPanelProps,
|
|
45
|
+
type TabProps,
|
|
46
|
+
Tabs,
|
|
47
|
+
type TabsProps
|
|
32
48
|
} from './client/components';
|
|
33
49
|
// Context & SSR data isolation
|
|
34
50
|
export {
|
|
@@ -39,13 +55,19 @@ export {
|
|
|
39
55
|
useSession,
|
|
40
56
|
useSynapseContext
|
|
41
57
|
} from './client/context';
|
|
42
|
-
|
|
58
|
+
export {
|
|
59
|
+
SliceErrorBoundary,
|
|
60
|
+
type SliceErrorBoundaryProps
|
|
61
|
+
} from './client/error-boundary';
|
|
62
|
+
// Reactive Action, Routing & Realtime Subscription Hooks
|
|
43
63
|
export {
|
|
44
64
|
type UseActionOptions,
|
|
45
65
|
type UseActionResult,
|
|
46
66
|
type UseSubscriptionOptions,
|
|
47
67
|
type UseSubscriptionResult,
|
|
48
68
|
useAction,
|
|
69
|
+
useParams,
|
|
70
|
+
useSearchParams,
|
|
49
71
|
useSubscription
|
|
50
72
|
} from './client/hooks';
|
|
51
73
|
export {
|
|
@@ -60,6 +82,19 @@ export {
|
|
|
60
82
|
type SliceOutletProviderProps
|
|
61
83
|
} from './client/outlet';
|
|
62
84
|
export { SubSlice, type SubSliceProps } from './client/sub-slice';
|
|
85
|
+
export {
|
|
86
|
+
type ToastContextValue,
|
|
87
|
+
type ToastItem,
|
|
88
|
+
ToastProvider,
|
|
89
|
+
type ToastProviderProps,
|
|
90
|
+
type ToastType,
|
|
91
|
+
useToast
|
|
92
|
+
} from './client/toast';
|
|
93
|
+
export {
|
|
94
|
+
type UseUploadOptions,
|
|
95
|
+
type UseUploadResult,
|
|
96
|
+
useUpload
|
|
97
|
+
} from './client/upload';
|
|
63
98
|
export {
|
|
64
99
|
type UseWebSocketOptions,
|
|
65
100
|
type UseWebSocketResult,
|