@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.
@@ -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
+ }