@arcote.tech/platform 0.4.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/package.json +39 -0
- package/src/arc.ts +308 -0
- package/src/hooks/use-title.ts +11 -0
- package/src/i18n.tsx +76 -0
- package/src/index.ts +121 -0
- package/src/layout/blank-layout.tsx +15 -0
- package/src/layout/layout-provider.tsx +39 -0
- package/src/layout/overflow-nav.tsx +164 -0
- package/src/layout/page-router.tsx +130 -0
- package/src/layout/page-sub-nav-shell.tsx +23 -0
- package/src/layout/slot-renderer.tsx +70 -0
- package/src/layout/use-slot-fragments.ts +20 -0
- package/src/locale.tsx +64 -0
- package/src/module-loader.ts +102 -0
- package/src/platform-app.tsx +208 -0
- package/src/platform-context.tsx +45 -0
- package/src/registry.ts +238 -0
- package/src/router.tsx +23 -0
- package/src/theme.tsx +72 -0
- package/src/types.ts +165 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
cn,
|
|
4
|
+
DisplayModeProvider,
|
|
5
|
+
DragHandle,
|
|
6
|
+
ExpandablePanel,
|
|
7
|
+
arcTransitions,
|
|
8
|
+
useArcRoute,
|
|
9
|
+
useExpandable,
|
|
10
|
+
useOverlay,
|
|
11
|
+
} from "@arcote.tech/arc-ds";
|
|
12
|
+
import { motion } from "framer-motion";
|
|
13
|
+
import { LayoutGrid } from "lucide-react";
|
|
14
|
+
import {
|
|
15
|
+
Children,
|
|
16
|
+
useCallback,
|
|
17
|
+
useEffect,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
type ReactNode,
|
|
21
|
+
} from "react";
|
|
22
|
+
|
|
23
|
+
interface OverflowNavProps {
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
className?: string;
|
|
26
|
+
overlayId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function OverflowNav({
|
|
30
|
+
children,
|
|
31
|
+
className,
|
|
32
|
+
overlayId = "overflow-nav",
|
|
33
|
+
}: OverflowNavProps) {
|
|
34
|
+
const measureRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
|
36
|
+
const exp = useExpandable();
|
|
37
|
+
const overlay = useOverlay();
|
|
38
|
+
const path = useArcRoute();
|
|
39
|
+
|
|
40
|
+
// Close on route change
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (exp.isExpanded) {
|
|
43
|
+
exp.close();
|
|
44
|
+
overlay.releaseOverlay(overlayId);
|
|
45
|
+
}
|
|
46
|
+
}, [path]);
|
|
47
|
+
|
|
48
|
+
const handleOpen = useCallback(() => {
|
|
49
|
+
exp.open();
|
|
50
|
+
overlay.requestOverlay(overlayId, () => exp.close());
|
|
51
|
+
}, [exp, overlay, overlayId]);
|
|
52
|
+
|
|
53
|
+
const handleDragRelease = useCallback(
|
|
54
|
+
(shouldClose: boolean) => {
|
|
55
|
+
exp.onDragRelease(shouldClose);
|
|
56
|
+
if (shouldClose) overlay.releaseOverlay(overlayId);
|
|
57
|
+
},
|
|
58
|
+
[exp, overlay, overlayId],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const childArray = Children.toArray(children);
|
|
62
|
+
const totalCount = childArray.length;
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const container = measureRef.current;
|
|
66
|
+
if (!container) return;
|
|
67
|
+
|
|
68
|
+
const measure = () => {
|
|
69
|
+
const containerWidth = container.offsetWidth;
|
|
70
|
+
const items = Array.from(container.children) as HTMLElement[];
|
|
71
|
+
if (items.length === 0) {
|
|
72
|
+
setVisibleCount(totalCount);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const moreButton = items[items.length - 1];
|
|
77
|
+
const moreWidth = moreButton.offsetWidth;
|
|
78
|
+
|
|
79
|
+
let usedWidth = 0;
|
|
80
|
+
let fitCount = 0;
|
|
81
|
+
|
|
82
|
+
for (let i = 0; i < items.length - 1; i++) {
|
|
83
|
+
const itemWidth = items[i].offsetWidth;
|
|
84
|
+
const gap = i > 0 ? 4 : 0;
|
|
85
|
+
|
|
86
|
+
if (usedWidth + gap + itemWidth + 4 + moreWidth <= containerWidth) {
|
|
87
|
+
usedWidth += gap + itemWidth;
|
|
88
|
+
fitCount++;
|
|
89
|
+
} else {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const allFitWidth = items
|
|
95
|
+
.slice(0, -1)
|
|
96
|
+
.reduce((sum, el, i) => sum + el.offsetWidth + (i > 0 ? 4 : 0), 0);
|
|
97
|
+
|
|
98
|
+
setVisibleCount(allFitWidth <= containerWidth ? totalCount : fitCount);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
measure();
|
|
102
|
+
const ro = new ResizeObserver(measure);
|
|
103
|
+
ro.observe(container);
|
|
104
|
+
return () => ro.disconnect();
|
|
105
|
+
}, [children, totalCount]);
|
|
106
|
+
|
|
107
|
+
const isOverflowing = visibleCount !== null && visibleCount < totalCount;
|
|
108
|
+
const ready = visibleCount !== null;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn("relative", className)}>
|
|
112
|
+
<div
|
|
113
|
+
ref={measureRef}
|
|
114
|
+
aria-hidden
|
|
115
|
+
className="pointer-events-none flex h-0 w-full items-center gap-1 overflow-hidden opacity-0 [&>*]:shrink-0"
|
|
116
|
+
>
|
|
117
|
+
{childArray}
|
|
118
|
+
<div className="shrink-0">
|
|
119
|
+
<Button icon={LayoutGrid} />
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<motion.div
|
|
124
|
+
animate={
|
|
125
|
+
exp.isExpanded
|
|
126
|
+
? { height: 0, opacity: 0 }
|
|
127
|
+
: { height: "auto", opacity: ready ? 1 : 0 }
|
|
128
|
+
}
|
|
129
|
+
initial={false}
|
|
130
|
+
transition={arcTransitions.snappy}
|
|
131
|
+
style={{ overflow: "hidden" }}
|
|
132
|
+
>
|
|
133
|
+
<div className="flex items-center justify-around gap-1 [&>*]:shrink-0">
|
|
134
|
+
{ready &&
|
|
135
|
+
childArray.slice(0, isOverflowing ? visibleCount : totalCount)}
|
|
136
|
+
{isOverflowing && (
|
|
137
|
+
<Button
|
|
138
|
+
icon={LayoutGrid}
|
|
139
|
+
label="Menu"
|
|
140
|
+
tooltip="Pokaż wszystkie"
|
|
141
|
+
onClick={handleOpen}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</motion.div>
|
|
146
|
+
|
|
147
|
+
<ExpandablePanel
|
|
148
|
+
open={exp.isExpanded}
|
|
149
|
+
height={exp.expandedHeight}
|
|
150
|
+
dragHeight={exp.dragHeight}
|
|
151
|
+
>
|
|
152
|
+
<div ref={exp.expandedRef} className="flex flex-col gap-0.5">
|
|
153
|
+
<DragHandle
|
|
154
|
+
contentHeight={exp.expandedHeight}
|
|
155
|
+
onHeightChange={exp.onDragHeight}
|
|
156
|
+
onRelease={handleDragRelease}
|
|
157
|
+
closeDirection="down"
|
|
158
|
+
/>
|
|
159
|
+
<DisplayModeProvider mode="expanded">{children}</DisplayModeProvider>
|
|
160
|
+
</div>
|
|
161
|
+
</ExpandablePanel>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
2
|
+
import { Suspense, useEffect } from "react";
|
|
3
|
+
import { getDefaultLayout, getPageFragments } from "../registry";
|
|
4
|
+
import { useArcNavigate, useArcRoute, matchRoutePath, hasRouteParams } from "../router";
|
|
5
|
+
import { useArcRouter } from "@arcote.tech/arc-ds";
|
|
6
|
+
import type { PageFragment } from "../types";
|
|
7
|
+
|
|
8
|
+
type MatchResult = {
|
|
9
|
+
page: PageFragment;
|
|
10
|
+
child?: PageFragment;
|
|
11
|
+
params: Record<string, string>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function matchPage(path: string): MatchResult | undefined {
|
|
15
|
+
const pages = getPageFragments();
|
|
16
|
+
|
|
17
|
+
// 1. Exact match (non-parent pages only)
|
|
18
|
+
const exact = pages.find((p) => p.path === path && !p.children?.length);
|
|
19
|
+
if (exact) return { page: exact, params: {} };
|
|
20
|
+
|
|
21
|
+
// 2. Check children of parent pages
|
|
22
|
+
for (const p of pages) {
|
|
23
|
+
if (p.children?.length) {
|
|
24
|
+
if (path === p.path) return { page: p, params: {} };
|
|
25
|
+
|
|
26
|
+
// Try exact child match first
|
|
27
|
+
const exactChild = p.children.find((c) => c.path === path);
|
|
28
|
+
if (exactChild) return { page: p, child: exactChild, params: {} };
|
|
29
|
+
|
|
30
|
+
// Try parameterized child match
|
|
31
|
+
for (const c of p.children) {
|
|
32
|
+
if (hasRouteParams(c.path)) {
|
|
33
|
+
const params = matchRoutePath(c.path, path);
|
|
34
|
+
if (params) return { page: p, child: c, params };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Prefix child match (existing behavior)
|
|
39
|
+
const prefixChild = p.children.find((c) => path.startsWith(c.path));
|
|
40
|
+
if (prefixChild) return { page: p, child: prefixChild, params: {} };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 3. Parameterized page match
|
|
45
|
+
for (const p of pages) {
|
|
46
|
+
if (hasRouteParams(p.path) && !p.children?.length) {
|
|
47
|
+
const params = matchRoutePath(p.path, path);
|
|
48
|
+
if (params) return { page: p, params };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 4. Prefix match fallback
|
|
53
|
+
const prefix = pages.find((p) => path.startsWith(p.path));
|
|
54
|
+
if (prefix) return { page: prefix, params: {} };
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Renders the current page fragment inside its layout.
|
|
61
|
+
* Supports parent pages with children — renders shell with tabs prop.
|
|
62
|
+
*/
|
|
63
|
+
export function PageRouter() {
|
|
64
|
+
const route = useArcRoute();
|
|
65
|
+
const navigate = useArcNavigate();
|
|
66
|
+
const router = useArcRouter();
|
|
67
|
+
const match = matchPage(route);
|
|
68
|
+
const DefaultLayout = getDefaultLayout();
|
|
69
|
+
|
|
70
|
+
// Sync matched params to router context
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const newParams = match?.params ?? {};
|
|
73
|
+
const currentKeys = Object.keys(router.params);
|
|
74
|
+
const newKeys = Object.keys(newParams);
|
|
75
|
+
const changed =
|
|
76
|
+
currentKeys.length !== newKeys.length ||
|
|
77
|
+
newKeys.some((k) => router.params[k] !== newParams[k]);
|
|
78
|
+
if (changed) {
|
|
79
|
+
router.setParams(newParams);
|
|
80
|
+
}
|
|
81
|
+
}, [route, match?.params]);
|
|
82
|
+
|
|
83
|
+
if (!match) {
|
|
84
|
+
if (DefaultLayout) return <DefaultLayout>{null}</DefaultLayout>;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { page, child } = match;
|
|
89
|
+
const Layout = page.layout ?? DefaultLayout;
|
|
90
|
+
|
|
91
|
+
// Parent with children — redirect to first child if at parent path
|
|
92
|
+
if (page.children?.length && !child) {
|
|
93
|
+
navigate(page.children[0].path);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Parent with active child — render shell with tabs
|
|
98
|
+
if (page.children?.length && child) {
|
|
99
|
+
const Shell = page.component;
|
|
100
|
+
const ChildComponent = child.component;
|
|
101
|
+
const content = (
|
|
102
|
+
<Shell tabs={page.children}>
|
|
103
|
+
<Suspense fallback={null}>
|
|
104
|
+
<ChildComponent />
|
|
105
|
+
</Suspense>
|
|
106
|
+
</Shell>
|
|
107
|
+
);
|
|
108
|
+
return Layout ? <Layout>{content}</Layout> : content;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Regular page (no children)
|
|
112
|
+
const PageComponent = page.component;
|
|
113
|
+
const pageContent = (
|
|
114
|
+
<AnimatePresence mode="wait">
|
|
115
|
+
<motion.div
|
|
116
|
+
key={page.path}
|
|
117
|
+
initial={{ opacity: 0 }}
|
|
118
|
+
animate={{ opacity: 1 }}
|
|
119
|
+
exit={{ opacity: 0 }}
|
|
120
|
+
transition={{ duration: 0.15 }}
|
|
121
|
+
>
|
|
122
|
+
<Suspense fallback={null}>
|
|
123
|
+
<PageComponent />
|
|
124
|
+
</Suspense>
|
|
125
|
+
</motion.div>
|
|
126
|
+
</AnimatePresence>
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return Layout ? <Layout>{pageContent}</Layout> : pageContent;
|
|
130
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { PageFragment } from "../types";
|
|
3
|
+
import { useArcNavigate, useArcRoute } from "../router";
|
|
4
|
+
import { SubNavShell } from "@arcote.tech/arc-ds";
|
|
5
|
+
|
|
6
|
+
export function PageSubNavShell({ children, tabs }: { children: ReactNode; tabs: readonly PageFragment[] }) {
|
|
7
|
+
const navigate = useArcNavigate();
|
|
8
|
+
const route = useArcRoute();
|
|
9
|
+
|
|
10
|
+
const subNavTabs = tabs.map((t) => ({
|
|
11
|
+
path: t.path,
|
|
12
|
+
icon: t.icon,
|
|
13
|
+
label: t.label,
|
|
14
|
+
tooltip: t.tooltip,
|
|
15
|
+
isActive: route.startsWith(t.path),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<SubNavShell tabs={subNavTabs} onNavigate={navigate}>
|
|
20
|
+
{children}
|
|
21
|
+
</SubNavShell>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { DisplayMode } from "@arcote.tech/arc-ds";
|
|
2
|
+
import { DisplayModeProvider, useDynamicSlotContent } from "@arcote.tech/arc-ds";
|
|
3
|
+
import { Suspense } from "react";
|
|
4
|
+
import type { SlotId } from "../types";
|
|
5
|
+
import { useSlotFragments } from "./use-slot-fragments";
|
|
6
|
+
|
|
7
|
+
export interface SlotRendererProps {
|
|
8
|
+
slotId: SlotId | string;
|
|
9
|
+
className?: string;
|
|
10
|
+
dividerClassName?: string;
|
|
11
|
+
suspenseFallback?: React.ReactNode;
|
|
12
|
+
displayMode?: DisplayMode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const slotDisplayModes: Record<string, DisplayMode> = {
|
|
16
|
+
"toolbar-left": "default",
|
|
17
|
+
"toolbar-center": "default",
|
|
18
|
+
"toolbar-right": "default",
|
|
19
|
+
"main-content": "default",
|
|
20
|
+
"sidebar-left": "default",
|
|
21
|
+
"sidebar-right": "default",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function SlotRenderer({
|
|
25
|
+
slotId,
|
|
26
|
+
className,
|
|
27
|
+
dividerClassName,
|
|
28
|
+
suspenseFallback,
|
|
29
|
+
displayMode,
|
|
30
|
+
}: SlotRendererProps) {
|
|
31
|
+
const fragments = useSlotFragments(slotId);
|
|
32
|
+
const dynamicContent = useDynamicSlotContent(slotId);
|
|
33
|
+
|
|
34
|
+
const hasFragments = fragments.length > 0;
|
|
35
|
+
const hasDynamic = dynamicContent.length > 0;
|
|
36
|
+
|
|
37
|
+
if (!hasFragments && !hasDynamic) return null;
|
|
38
|
+
|
|
39
|
+
const mode = displayMode ?? slotDisplayModes[slotId] ?? "default";
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<DisplayModeProvider mode={mode}>
|
|
43
|
+
<div className={className}>
|
|
44
|
+
{fragments.map((fragment, index) => {
|
|
45
|
+
const Component = fragment.component;
|
|
46
|
+
return (
|
|
47
|
+
<div key={fragment.id} className="contents">
|
|
48
|
+
{index > 0 && (
|
|
49
|
+
<div
|
|
50
|
+
className={
|
|
51
|
+
dividerClassName ?? "mx-1 h-6 w-px self-center bg-border"
|
|
52
|
+
}
|
|
53
|
+
role="separator"
|
|
54
|
+
/>
|
|
55
|
+
)}
|
|
56
|
+
<Suspense fallback={suspenseFallback ?? null}>
|
|
57
|
+
<Component />
|
|
58
|
+
</Suspense>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
{dynamicContent.map((content, index) => (
|
|
63
|
+
<div key={`dynamic-${index}`} className="contents">
|
|
64
|
+
{content}
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
</DisplayModeProvider>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { getSlotFragments, subscribe } from "../registry";
|
|
3
|
+
import type { SlotFragment, SlotId } from "../types";
|
|
4
|
+
|
|
5
|
+
export function useSlotFragments(slotId: SlotId | string): SlotFragment[] {
|
|
6
|
+
const cacheRef = useRef<SlotFragment[]>([]);
|
|
7
|
+
|
|
8
|
+
const getSnapshot = () => {
|
|
9
|
+
const next = getSlotFragments(slotId);
|
|
10
|
+
const prev = cacheRef.current;
|
|
11
|
+
|
|
12
|
+
const changed =
|
|
13
|
+
next.length !== prev.length || next.some((f, i) => f.id !== prev[i]?.id);
|
|
14
|
+
|
|
15
|
+
if (changed) cacheRef.current = next;
|
|
16
|
+
return cacheRef.current;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
20
|
+
}
|
package/src/locale.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { I18n } from "@lingui/core";
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
interface LocaleState<TLocale extends string = string> {
|
|
11
|
+
locale: TLocale;
|
|
12
|
+
setLocale: (locale: TLocale) => void;
|
|
13
|
+
locales: Record<TLocale, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const LocaleContext = createContext<LocaleState | null>(null);
|
|
17
|
+
|
|
18
|
+
export interface LocaleProviderProps<TLocale extends string = string> {
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
/** Lingui i18n instance. */
|
|
21
|
+
i18n: I18n;
|
|
22
|
+
/** Mapa locale → label, np. { pl: "Polski", en: "English" }. */
|
|
23
|
+
locales: Record<TLocale, string>;
|
|
24
|
+
/** Domyślny locale. */
|
|
25
|
+
defaultLocale: TLocale;
|
|
26
|
+
/** Loader tłumaczeń — wywoływany przy zmianie locale. */
|
|
27
|
+
loadMessages: (locale: TLocale) => Promise<Record<string, string>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function LocaleProvider<TLocale extends string = string>({
|
|
31
|
+
children,
|
|
32
|
+
i18n,
|
|
33
|
+
locales,
|
|
34
|
+
defaultLocale,
|
|
35
|
+
loadMessages,
|
|
36
|
+
}: LocaleProviderProps<TLocale>) {
|
|
37
|
+
const [locale, setLocaleRaw] = useState<TLocale>(defaultLocale);
|
|
38
|
+
|
|
39
|
+
const setLocale = useCallback(
|
|
40
|
+
async (newLocale: TLocale) => {
|
|
41
|
+
const messages = await loadMessages(newLocale);
|
|
42
|
+
i18n.load(newLocale, messages);
|
|
43
|
+
i18n.activate(newLocale);
|
|
44
|
+
setLocaleRaw(newLocale);
|
|
45
|
+
},
|
|
46
|
+
[i18n, loadMessages],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<LocaleContext.Provider
|
|
51
|
+
value={{ locale, setLocale, locales } as unknown as LocaleState}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</LocaleContext.Provider>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useLocale<
|
|
59
|
+
TLocale extends string = string,
|
|
60
|
+
>(): LocaleState<TLocale> {
|
|
61
|
+
const ctx = useContext(LocaleContext);
|
|
62
|
+
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
|
|
63
|
+
return ctx as unknown as LocaleState<TLocale>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { clearModules, getContext } from "./registry";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
export interface ModuleManifest {
|
|
5
|
+
modules: string[];
|
|
6
|
+
buildTime: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ModuleLoaderState = "loading" | "ready" | "error";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Load all modules from manifest. Each module auto-registers via arc().
|
|
13
|
+
*/
|
|
14
|
+
export async function loadModules(baseUrl: string): Promise<ModuleManifest> {
|
|
15
|
+
const res = await fetch(`${baseUrl}/api/modules`);
|
|
16
|
+
if (!res.ok)
|
|
17
|
+
throw new Error(`Failed to fetch module manifest: ${res.status}`);
|
|
18
|
+
|
|
19
|
+
const manifest: ModuleManifest = await res.json();
|
|
20
|
+
|
|
21
|
+
// Import all modules — each calls arc() which auto-registers
|
|
22
|
+
await Promise.all(
|
|
23
|
+
manifest.modules.map(
|
|
24
|
+
(mod) => import(/* @vite-ignore */ `${baseUrl}/modules/${mod}`),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return manifest;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reload modules — clear registry, re-import with cache bust.
|
|
33
|
+
*/
|
|
34
|
+
export async function reloadModules(baseUrl: string): Promise<ModuleManifest> {
|
|
35
|
+
clearModules();
|
|
36
|
+
|
|
37
|
+
const res = await fetch(`${baseUrl}/api/modules`);
|
|
38
|
+
if (!res.ok)
|
|
39
|
+
throw new Error(`Failed to fetch module manifest: ${res.status}`);
|
|
40
|
+
|
|
41
|
+
const manifest: ModuleManifest = await res.json();
|
|
42
|
+
const bust = `?t=${Date.now()}`;
|
|
43
|
+
|
|
44
|
+
await Promise.all(
|
|
45
|
+
manifest.modules.map(
|
|
46
|
+
(mod) => import(/* @vite-ignore */ `${baseUrl}/modules/${mod}${bust}`),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return manifest;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Hook: loads modules on mount, provides state + context.
|
|
55
|
+
*/
|
|
56
|
+
export function useModuleLoader(
|
|
57
|
+
baseUrl: string,
|
|
58
|
+
options: { skip?: boolean } = {},
|
|
59
|
+
) {
|
|
60
|
+
const [state, setState] = useState<ModuleLoaderState>(
|
|
61
|
+
options.skip ? "ready" : "loading",
|
|
62
|
+
);
|
|
63
|
+
const [error, setError] = useState<Error | null>(null);
|
|
64
|
+
const loaded = useRef(false);
|
|
65
|
+
|
|
66
|
+
const reload = useCallback(async () => {
|
|
67
|
+
try {
|
|
68
|
+
await reloadModules(baseUrl);
|
|
69
|
+
setState("ready");
|
|
70
|
+
} catch (e) {
|
|
71
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
72
|
+
setState("error");
|
|
73
|
+
}
|
|
74
|
+
}, [baseUrl]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (options.skip || loaded.current) return;
|
|
78
|
+
loaded.current = true;
|
|
79
|
+
|
|
80
|
+
loadModules(baseUrl)
|
|
81
|
+
.then(() => setState("ready"))
|
|
82
|
+
.catch((e) => {
|
|
83
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
84
|
+
setState("error");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Listen for SSE reload events from CLI
|
|
88
|
+
const evtSource = new EventSource(`${baseUrl}/api/reload-stream`);
|
|
89
|
+
evtSource.onmessage = (event) => {
|
|
90
|
+
if (event.data === "connected") return;
|
|
91
|
+
console.log("[arc] Modules changed, reloading...");
|
|
92
|
+
reload();
|
|
93
|
+
};
|
|
94
|
+
evtSource.onerror = () => {
|
|
95
|
+
// SSE disconnected — will auto-reconnect
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return () => evtSource.close();
|
|
99
|
+
}, [baseUrl, reload, options.skip]);
|
|
100
|
+
|
|
101
|
+
return { state, error, context: getContext(), reload };
|
|
102
|
+
}
|