@ismaelsoilet/synapsejs 1.8.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 +98 -6
- package/bin/synapse.ts +291 -241
- 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 +9 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +18 -4
- package/src/compiler/scaffolder.ts +227 -9
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +68 -6
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +101 -15
- package/src/core/config.ts +88 -10
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +103 -6
- package/src/core/index.ts +5 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +76 -9
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +124 -8
- package/src/core/sqlite-client.ts +20 -0
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +317 -218
- package/src/mcp/tools.ts +329 -0
- package/src/project-status.ts +433 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +167 -5
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +1174 -236
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/package.json +2 -2
- package/templates/starter/tsconfig.json +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ismaelsoilet/synapsejs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "Bun-only fullstack framework where a feature is a single contiguous file",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"postgres": "^3.4.9",
|
|
57
57
|
"react": "^19.0.0",
|
|
58
58
|
"react-dom": "^19.0.0",
|
|
59
|
-
"typescript": "
|
|
59
|
+
"typescript": "~5.9.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/bun": "^1.4.2",
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState
|
|
10
|
+
} from 'react';
|
|
2
11
|
|
|
3
12
|
// ============================================================================
|
|
4
13
|
// 1. BADGE COMPONENT
|
|
@@ -523,3 +532,452 @@ export function DataForm({
|
|
|
523
532
|
</form>
|
|
524
533
|
);
|
|
525
534
|
}
|
|
535
|
+
|
|
536
|
+
// ============================================================================
|
|
537
|
+
// 7. MODAL / DIALOG COMPONENT (WAI-ARIA COMPLIANT)
|
|
538
|
+
// ============================================================================
|
|
539
|
+
export interface ModalProps {
|
|
540
|
+
isOpen: boolean;
|
|
541
|
+
onClose: () => void;
|
|
542
|
+
title?: ReactNode;
|
|
543
|
+
description?: ReactNode;
|
|
544
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
545
|
+
children?: ReactNode;
|
|
546
|
+
footer?: ReactNode;
|
|
547
|
+
className?: string;
|
|
548
|
+
closeOnBackdrop?: boolean;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function Modal({
|
|
552
|
+
isOpen,
|
|
553
|
+
onClose,
|
|
554
|
+
title,
|
|
555
|
+
description,
|
|
556
|
+
size = 'md',
|
|
557
|
+
children,
|
|
558
|
+
footer,
|
|
559
|
+
className = '',
|
|
560
|
+
closeOnBackdrop = true
|
|
561
|
+
}: ModalProps) {
|
|
562
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
563
|
+
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
if (!isOpen) return;
|
|
566
|
+
|
|
567
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
568
|
+
if (e.key === 'Escape') {
|
|
569
|
+
e.stopPropagation();
|
|
570
|
+
onClose();
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const previousOverflow = document.body.style.overflow;
|
|
575
|
+
document.body.style.overflow = 'hidden';
|
|
576
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
577
|
+
|
|
578
|
+
return () => {
|
|
579
|
+
document.body.style.overflow = previousOverflow;
|
|
580
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
581
|
+
};
|
|
582
|
+
}, [isOpen, onClose]);
|
|
583
|
+
|
|
584
|
+
if (!isOpen) return null;
|
|
585
|
+
|
|
586
|
+
const sizeStyles = {
|
|
587
|
+
sm: 'max-w-sm',
|
|
588
|
+
md: 'max-w-lg',
|
|
589
|
+
lg: 'max-w-2xl',
|
|
590
|
+
xl: 'max-w-4xl',
|
|
591
|
+
full: 'max-w-[95vw] h-[90vh]'
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
<div
|
|
596
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-slate-950/70 backdrop-blur-sm animate-in fade-in duration-200"
|
|
597
|
+
style={{ position: 'fixed', top: 0, right: 0, bottom: 0, left: 0, zIndex: 50 }}
|
|
598
|
+
onClick={closeOnBackdrop ? onClose : undefined}
|
|
599
|
+
onKeyDown={(e) => {
|
|
600
|
+
if (e.key === 'Escape') onClose();
|
|
601
|
+
}}
|
|
602
|
+
aria-modal="true"
|
|
603
|
+
role="dialog"
|
|
604
|
+
tabIndex={-1}
|
|
605
|
+
>
|
|
606
|
+
<div
|
|
607
|
+
ref={dialogRef}
|
|
608
|
+
onClick={(e) => e.stopPropagation()}
|
|
609
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
610
|
+
role="document"
|
|
611
|
+
className={`bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl flex flex-col w-full overflow-hidden transition-all duration-200 transform scale-100 ${sizeStyles[size]} ${className}`}
|
|
612
|
+
>
|
|
613
|
+
{(title || description) && (
|
|
614
|
+
<div className="flex items-start justify-between p-5 border-b border-slate-800">
|
|
615
|
+
<div>
|
|
616
|
+
{title && <h3 className="text-lg font-bold text-slate-100 tracking-tight">{title}</h3>}
|
|
617
|
+
{description && <p className="text-xs text-slate-400 mt-1">{description}</p>}
|
|
618
|
+
</div>
|
|
619
|
+
<button
|
|
620
|
+
type="button"
|
|
621
|
+
onClick={onClose}
|
|
622
|
+
aria-label="Fechar diálogo"
|
|
623
|
+
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors text-base font-mono leading-none"
|
|
624
|
+
>
|
|
625
|
+
✕
|
|
626
|
+
</button>
|
|
627
|
+
</div>
|
|
628
|
+
)}
|
|
629
|
+
<div className="p-5 overflow-y-auto flex-1 text-slate-200 text-sm leading-relaxed">{children}</div>
|
|
630
|
+
{footer && (
|
|
631
|
+
<div className="p-4 border-t border-slate-800 bg-slate-950/40 flex items-center justify-end gap-2">
|
|
632
|
+
{footer}
|
|
633
|
+
</div>
|
|
634
|
+
)}
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ============================================================================
|
|
641
|
+
// 8. DRAWER / SLIDE-OVER SHEET COMPONENT
|
|
642
|
+
// ============================================================================
|
|
643
|
+
export interface DrawerProps {
|
|
644
|
+
isOpen: boolean;
|
|
645
|
+
onClose: () => void;
|
|
646
|
+
title?: ReactNode;
|
|
647
|
+
description?: ReactNode;
|
|
648
|
+
position?: 'left' | 'right';
|
|
649
|
+
size?: 'sm' | 'md' | 'lg' | 'full';
|
|
650
|
+
children?: ReactNode;
|
|
651
|
+
footer?: ReactNode;
|
|
652
|
+
className?: string;
|
|
653
|
+
closeOnBackdrop?: boolean;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function Drawer({
|
|
657
|
+
isOpen,
|
|
658
|
+
onClose,
|
|
659
|
+
title,
|
|
660
|
+
description,
|
|
661
|
+
position = 'right',
|
|
662
|
+
size = 'md',
|
|
663
|
+
children,
|
|
664
|
+
footer,
|
|
665
|
+
className = '',
|
|
666
|
+
closeOnBackdrop = true
|
|
667
|
+
}: DrawerProps) {
|
|
668
|
+
const drawerRef = useRef<HTMLDivElement>(null);
|
|
669
|
+
|
|
670
|
+
useEffect(() => {
|
|
671
|
+
if (!isOpen) return;
|
|
672
|
+
|
|
673
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
674
|
+
if (e.key === 'Escape') {
|
|
675
|
+
e.stopPropagation();
|
|
676
|
+
onClose();
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const previousOverflow = document.body.style.overflow;
|
|
681
|
+
document.body.style.overflow = 'hidden';
|
|
682
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
683
|
+
|
|
684
|
+
return () => {
|
|
685
|
+
document.body.style.overflow = previousOverflow;
|
|
686
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
687
|
+
};
|
|
688
|
+
}, [isOpen, onClose]);
|
|
689
|
+
|
|
690
|
+
if (!isOpen) return null;
|
|
691
|
+
|
|
692
|
+
const sizeStyles = {
|
|
693
|
+
sm: 'max-w-sm',
|
|
694
|
+
md: 'max-w-md',
|
|
695
|
+
lg: 'max-w-2xl',
|
|
696
|
+
full: 'max-w-full'
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const posStyles = {
|
|
700
|
+
right: 'inset-y-0 right-0',
|
|
701
|
+
left: 'inset-y-0 left-0'
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
return (
|
|
705
|
+
<div
|
|
706
|
+
className="fixed inset-0 z-50 flex bg-slate-950/70 backdrop-blur-sm animate-in fade-in duration-200"
|
|
707
|
+
style={{ position: 'fixed', top: 0, right: 0, bottom: 0, left: 0, zIndex: 50 }}
|
|
708
|
+
onClick={closeOnBackdrop ? onClose : undefined}
|
|
709
|
+
onKeyDown={(e) => {
|
|
710
|
+
if (e.key === 'Escape') onClose();
|
|
711
|
+
}}
|
|
712
|
+
aria-modal="true"
|
|
713
|
+
role="dialog"
|
|
714
|
+
tabIndex={-1}
|
|
715
|
+
>
|
|
716
|
+
<div
|
|
717
|
+
ref={drawerRef}
|
|
718
|
+
onClick={(e) => e.stopPropagation()}
|
|
719
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
720
|
+
role="document"
|
|
721
|
+
className={`fixed ${posStyles[position]} flex flex-col h-full w-full bg-slate-900 border-${position === 'right' ? 'l' : 'r'} border-slate-800 shadow-2xl overflow-hidden transition-transform duration-300 ${sizeStyles[size]} ${className}`}
|
|
722
|
+
style={{ position: 'fixed', top: 0, bottom: 0, [position]: 0 }}
|
|
723
|
+
>
|
|
724
|
+
<div className="flex items-start justify-between p-5 border-b border-slate-800">
|
|
725
|
+
<div>
|
|
726
|
+
{title && <h3 className="text-lg font-bold text-slate-100 tracking-tight">{title}</h3>}
|
|
727
|
+
{description && <p className="text-xs text-slate-400 mt-1">{description}</p>}
|
|
728
|
+
</div>
|
|
729
|
+
<button
|
|
730
|
+
type="button"
|
|
731
|
+
onClick={onClose}
|
|
732
|
+
aria-label="Fechar painel lateral"
|
|
733
|
+
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors text-base font-mono leading-none"
|
|
734
|
+
>
|
|
735
|
+
✕
|
|
736
|
+
</button>
|
|
737
|
+
</div>
|
|
738
|
+
<div className="p-5 overflow-y-auto flex-1 text-slate-200 text-sm leading-relaxed">{children}</div>
|
|
739
|
+
{footer && (
|
|
740
|
+
<div className="p-4 border-t border-slate-800 bg-slate-950/40 flex items-center justify-end gap-2">
|
|
741
|
+
{footer}
|
|
742
|
+
</div>
|
|
743
|
+
)}
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ============================================================================
|
|
750
|
+
// 9. TABS ACCESSIBLE COMPONENT
|
|
751
|
+
// ============================================================================
|
|
752
|
+
interface TabsContextValue {
|
|
753
|
+
value: string;
|
|
754
|
+
setValue: (val: string) => void;
|
|
755
|
+
}
|
|
756
|
+
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
757
|
+
|
|
758
|
+
export interface TabsProps {
|
|
759
|
+
defaultValue?: string;
|
|
760
|
+
value?: string;
|
|
761
|
+
onValueChange?: (val: string) => void;
|
|
762
|
+
syncQueryParam?: string;
|
|
763
|
+
children?: ReactNode;
|
|
764
|
+
className?: string;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export function Tabs({
|
|
768
|
+
defaultValue = '',
|
|
769
|
+
value: controlledValue,
|
|
770
|
+
onValueChange,
|
|
771
|
+
syncQueryParam,
|
|
772
|
+
children,
|
|
773
|
+
className = ''
|
|
774
|
+
}: TabsProps) {
|
|
775
|
+
const [internalValue, setInternalValue] = useState<string>(() => {
|
|
776
|
+
if (typeof window !== 'undefined' && syncQueryParam) {
|
|
777
|
+
const sp = new URLSearchParams(window.location.search);
|
|
778
|
+
const fromUrl = sp.get(syncQueryParam);
|
|
779
|
+
if (fromUrl) return fromUrl;
|
|
780
|
+
}
|
|
781
|
+
return defaultValue;
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
const activeValue = controlledValue !== undefined ? controlledValue : internalValue;
|
|
785
|
+
|
|
786
|
+
const handleValueChange = useCallback(
|
|
787
|
+
(newVal: string) => {
|
|
788
|
+
if (controlledValue === undefined) {
|
|
789
|
+
setInternalValue(newVal);
|
|
790
|
+
}
|
|
791
|
+
onValueChange?.(newVal);
|
|
792
|
+
|
|
793
|
+
if (typeof window !== 'undefined' && syncQueryParam) {
|
|
794
|
+
const url = new URL(window.location.href);
|
|
795
|
+
url.searchParams.set(syncQueryParam, newVal);
|
|
796
|
+
window.history.replaceState({}, '', url.toString());
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
[controlledValue, onValueChange, syncQueryParam]
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
return (
|
|
803
|
+
<TabsContext.Provider value={{ value: activeValue, setValue: handleValueChange }}>
|
|
804
|
+
<div className={`space-y-4 ${className}`}>{children}</div>
|
|
805
|
+
</TabsContext.Provider>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export interface TabListProps {
|
|
810
|
+
children?: ReactNode;
|
|
811
|
+
className?: string;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export function TabList({ children, className = '' }: TabListProps) {
|
|
815
|
+
return (
|
|
816
|
+
<div
|
|
817
|
+
role="tablist"
|
|
818
|
+
className={`flex items-center gap-1.5 p-1 bg-slate-900 border border-slate-800 rounded-xl overflow-x-auto ${className}`}
|
|
819
|
+
>
|
|
820
|
+
{children}
|
|
821
|
+
</div>
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export interface TabProps {
|
|
826
|
+
value: string;
|
|
827
|
+
children?: ReactNode;
|
|
828
|
+
disabled?: boolean;
|
|
829
|
+
className?: string;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function Tab({ value, children, disabled = false, className = '' }: TabProps) {
|
|
833
|
+
const ctx = useContext(TabsContext);
|
|
834
|
+
if (!ctx) throw new Error('<Tab> must be used within <Tabs>');
|
|
835
|
+
|
|
836
|
+
const isSelected = ctx.value === value;
|
|
837
|
+
|
|
838
|
+
return (
|
|
839
|
+
<button
|
|
840
|
+
type="button"
|
|
841
|
+
role="tab"
|
|
842
|
+
aria-selected={isSelected}
|
|
843
|
+
disabled={disabled}
|
|
844
|
+
onClick={() => ctx.setValue(value)}
|
|
845
|
+
className={`px-3.5 py-1.5 text-xs font-semibold rounded-lg transition-all duration-150 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 whitespace-nowrap ${
|
|
846
|
+
isSelected
|
|
847
|
+
? 'bg-slate-800 text-cyan-400 shadow-sm border border-slate-700/80 font-bold'
|
|
848
|
+
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
|
849
|
+
} ${className}`}
|
|
850
|
+
>
|
|
851
|
+
{children}
|
|
852
|
+
</button>
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export interface TabPanelProps {
|
|
857
|
+
value: string;
|
|
858
|
+
children?: ReactNode;
|
|
859
|
+
className?: string;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function TabPanel({ value, children, className = '' }: TabPanelProps) {
|
|
863
|
+
const ctx = useContext(TabsContext);
|
|
864
|
+
if (!ctx) throw new Error('<TabPanel> must be used within <Tabs>');
|
|
865
|
+
|
|
866
|
+
if (ctx.value !== value) return null;
|
|
867
|
+
|
|
868
|
+
return (
|
|
869
|
+
<div role="tabpanel" className={`focus:outline-none ${className}`}>
|
|
870
|
+
{children}
|
|
871
|
+
</div>
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ============================================================================
|
|
876
|
+
// 10. DROPDOWN MENU ACCESSIBLE COMPONENT
|
|
877
|
+
// ============================================================================
|
|
878
|
+
export interface DropdownMenuProps {
|
|
879
|
+
trigger: ReactNode;
|
|
880
|
+
align?: 'left' | 'right';
|
|
881
|
+
children?: ReactNode;
|
|
882
|
+
className?: string;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function DropdownMenu({ trigger, align = 'right', children, className = '' }: DropdownMenuProps) {
|
|
886
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
887
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
888
|
+
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
if (!isOpen) return;
|
|
891
|
+
|
|
892
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
893
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
894
|
+
setIsOpen(false);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
899
|
+
if (e.key === 'Escape') {
|
|
900
|
+
setIsOpen(false);
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
905
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
906
|
+
|
|
907
|
+
return () => {
|
|
908
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
909
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
910
|
+
};
|
|
911
|
+
}, [isOpen]);
|
|
912
|
+
|
|
913
|
+
const alignStyles = {
|
|
914
|
+
left: 'left-0',
|
|
915
|
+
right: 'right-0'
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
return (
|
|
919
|
+
<div ref={containerRef} className={`relative inline-block text-left ${className}`}>
|
|
920
|
+
{/* biome-ignore lint/a11y/useSemanticElements: custom trigger wrapper avoids button-inside-button HTML nesting */}
|
|
921
|
+
<div
|
|
922
|
+
role="button"
|
|
923
|
+
tabIndex={0}
|
|
924
|
+
onClick={() => setIsOpen((prev) => !prev)}
|
|
925
|
+
onKeyDown={(e) => {
|
|
926
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
927
|
+
e.preventDefault();
|
|
928
|
+
setIsOpen((prev) => !prev);
|
|
929
|
+
}
|
|
930
|
+
}}
|
|
931
|
+
className="text-left bg-transparent border-0 p-0 m-0 cursor-pointer inline-block"
|
|
932
|
+
>
|
|
933
|
+
{trigger}
|
|
934
|
+
</div>
|
|
935
|
+
{isOpen && (
|
|
936
|
+
<div
|
|
937
|
+
role="menu"
|
|
938
|
+
aria-orientation="vertical"
|
|
939
|
+
tabIndex={-1}
|
|
940
|
+
className={`absolute ${alignStyles[align]} mt-2 w-52 rounded-xl bg-slate-900 border border-slate-800 shadow-2xl p-1 z-50 animate-in fade-in duration-150`}
|
|
941
|
+
>
|
|
942
|
+
{children}
|
|
943
|
+
</div>
|
|
944
|
+
)}
|
|
945
|
+
</div>
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export interface DropdownItemProps {
|
|
950
|
+
onClick?: () => void;
|
|
951
|
+
variant?: 'default' | 'danger';
|
|
952
|
+
icon?: ReactNode;
|
|
953
|
+
disabled?: boolean;
|
|
954
|
+
children?: ReactNode;
|
|
955
|
+
className?: string;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export function DropdownItem({
|
|
959
|
+
onClick,
|
|
960
|
+
variant = 'default',
|
|
961
|
+
icon,
|
|
962
|
+
disabled = false,
|
|
963
|
+
children,
|
|
964
|
+
className = ''
|
|
965
|
+
}: DropdownItemProps) {
|
|
966
|
+
const variantStyles = {
|
|
967
|
+
default: 'text-slate-300 hover:text-white hover:bg-slate-800',
|
|
968
|
+
danger: 'text-rose-400 hover:text-rose-300 hover:bg-rose-950/50'
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
return (
|
|
972
|
+
<button
|
|
973
|
+
type="button"
|
|
974
|
+
role="menuitem"
|
|
975
|
+
disabled={disabled}
|
|
976
|
+
onClick={onClick}
|
|
977
|
+
className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs font-medium rounded-lg transition-colors text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 ${variantStyles[variant]} ${className}`}
|
|
978
|
+
>
|
|
979
|
+
{icon && <span className="shrink-0">{icon}</span>}
|
|
980
|
+
<span className="flex-1 truncate">{children}</span>
|
|
981
|
+
</button>
|
|
982
|
+
);
|
|
983
|
+
}
|
package/src/client/context.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createContext, type ReactNode, useContext } from 'react';
|
|
2
2
|
import type { SessionContext } from '../core/session-context';
|
|
3
|
+
import { SliceErrorBoundary, type SliceErrorBoundaryProps } from './error-boundary';
|
|
3
4
|
|
|
4
5
|
export interface SynapseContextValue {
|
|
5
6
|
props: Record<string, unknown>;
|
|
6
7
|
session?: SessionContext;
|
|
8
|
+
url?: string;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
const SynapseContext = createContext<SynapseContextValue | null>(null);
|
|
@@ -11,13 +13,24 @@ const SynapseContext = createContext<SynapseContextValue | null>(null);
|
|
|
11
13
|
export interface SynapseProviderProps {
|
|
12
14
|
props?: Record<string, unknown>;
|
|
13
15
|
session?: SessionContext;
|
|
16
|
+
url?: string;
|
|
17
|
+
/** Defaults to true: a slice render error shows a fallback instead of a dead tree. */
|
|
18
|
+
errorBoundary?: boolean;
|
|
19
|
+
errorBoundaryFallback?: SliceErrorBoundaryProps['fallback'];
|
|
14
20
|
children?: ReactNode;
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* SynapseProvider delivers per-request SSR data isolation and seamless client hydration.
|
|
19
25
|
*/
|
|
20
|
-
export function SynapseProvider({
|
|
26
|
+
export function SynapseProvider({
|
|
27
|
+
props,
|
|
28
|
+
session,
|
|
29
|
+
url,
|
|
30
|
+
errorBoundary = true,
|
|
31
|
+
errorBoundaryFallback,
|
|
32
|
+
children
|
|
33
|
+
}: SynapseProviderProps) {
|
|
21
34
|
// On client, if props not passed directly, hydrate from window.__SYNAPSE_PROPS__
|
|
22
35
|
const clientProps =
|
|
23
36
|
props ??
|
|
@@ -27,10 +40,17 @@ export function SynapseProvider({ props, session, children }: SynapseProviderPro
|
|
|
27
40
|
|
|
28
41
|
const value: SynapseContextValue = {
|
|
29
42
|
props: clientProps,
|
|
30
|
-
session
|
|
43
|
+
session,
|
|
44
|
+
url
|
|
31
45
|
};
|
|
32
46
|
|
|
33
|
-
|
|
47
|
+
const content = errorBoundary ? (
|
|
48
|
+
<SliceErrorBoundary fallback={errorBoundaryFallback}>{children}</SliceErrorBoundary>
|
|
49
|
+
) : (
|
|
50
|
+
children
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return <SynapseContext.Provider value={value}>{content}</SynapseContext.Provider>;
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
export function useSynapseContext(): SynapseContextValue {
|
|
@@ -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
|
+
}
|