@affectively/aeon-pages 1.3.0
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/CHANGELOG.md +112 -0
- package/README.md +625 -0
- package/examples/basic/aeon.config.ts +39 -0
- package/examples/basic/components/Cursor.tsx +86 -0
- package/examples/basic/components/OfflineIndicator.tsx +103 -0
- package/examples/basic/components/PresenceBar.tsx +77 -0
- package/examples/basic/package.json +20 -0
- package/examples/basic/pages/index.tsx +80 -0
- package/package.json +101 -0
- package/packages/analytics/README.md +309 -0
- package/packages/analytics/build.ts +35 -0
- package/packages/analytics/package.json +50 -0
- package/packages/analytics/src/click-tracker.ts +368 -0
- package/packages/analytics/src/context-bridge.ts +319 -0
- package/packages/analytics/src/data-layer.ts +302 -0
- package/packages/analytics/src/gtm-loader.ts +239 -0
- package/packages/analytics/src/index.ts +230 -0
- package/packages/analytics/src/merkle-tree.ts +489 -0
- package/packages/analytics/src/provider.tsx +300 -0
- package/packages/analytics/src/types.ts +320 -0
- package/packages/analytics/src/use-analytics.ts +296 -0
- package/packages/analytics/tsconfig.json +19 -0
- package/packages/benchmarks/src/benchmark.test.ts +691 -0
- package/packages/cli/dist/index.js +61899 -0
- package/packages/cli/package.json +43 -0
- package/packages/cli/src/commands/build.test.ts +682 -0
- package/packages/cli/src/commands/build.ts +890 -0
- package/packages/cli/src/commands/dev.ts +473 -0
- package/packages/cli/src/commands/init.ts +409 -0
- package/packages/cli/src/commands/start.ts +297 -0
- package/packages/cli/src/index.ts +105 -0
- package/packages/directives/src/use-aeon.ts +272 -0
- package/packages/mcp-server/package.json +51 -0
- package/packages/mcp-server/src/index.ts +178 -0
- package/packages/mcp-server/src/resources.ts +346 -0
- package/packages/mcp-server/src/tools/index.ts +36 -0
- package/packages/mcp-server/src/tools/navigation.ts +545 -0
- package/packages/mcp-server/tsconfig.json +21 -0
- package/packages/react/package.json +40 -0
- package/packages/react/src/Link.tsx +388 -0
- package/packages/react/src/components/InstallPrompt.tsx +286 -0
- package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
- package/packages/react/src/components/PushNotifications.tsx +453 -0
- package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
- package/packages/react/src/hooks/useConflicts.ts +277 -0
- package/packages/react/src/hooks/useNetworkState.ts +209 -0
- package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
- package/packages/react/src/hooks/useServiceWorker.ts +278 -0
- package/packages/react/src/hooks.ts +195 -0
- package/packages/react/src/index.ts +151 -0
- package/packages/react/src/provider.tsx +467 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/runtime/README.md +399 -0
- package/packages/runtime/build.ts +48 -0
- package/packages/runtime/package.json +71 -0
- package/packages/runtime/schema.sql +40 -0
- package/packages/runtime/src/api-routes.ts +465 -0
- package/packages/runtime/src/benchmark.ts +171 -0
- package/packages/runtime/src/cache.ts +479 -0
- package/packages/runtime/src/durable-object.ts +1341 -0
- package/packages/runtime/src/index.ts +360 -0
- package/packages/runtime/src/navigation.test.ts +421 -0
- package/packages/runtime/src/navigation.ts +422 -0
- package/packages/runtime/src/nextjs-adapter.ts +272 -0
- package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
- package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
- package/packages/runtime/src/offline/encryption.test.ts +412 -0
- package/packages/runtime/src/offline/encryption.ts +397 -0
- package/packages/runtime/src/offline/types.ts +465 -0
- package/packages/runtime/src/predictor.ts +371 -0
- package/packages/runtime/src/registry.ts +351 -0
- package/packages/runtime/src/router/context-extractor.ts +661 -0
- package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
- package/packages/runtime/src/router/esi-control.ts +541 -0
- package/packages/runtime/src/router/esi-cyrano.ts +779 -0
- package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
- package/packages/runtime/src/router/esi-react.tsx +1065 -0
- package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
- package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
- package/packages/runtime/src/router/esi-translate.ts +503 -0
- package/packages/runtime/src/router/esi.ts +666 -0
- package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
- package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
- package/packages/runtime/src/router/index.ts +298 -0
- package/packages/runtime/src/router/merkle-capability.ts +473 -0
- package/packages/runtime/src/router/speculation.ts +451 -0
- package/packages/runtime/src/router/types.ts +630 -0
- package/packages/runtime/src/router.test.ts +470 -0
- package/packages/runtime/src/router.ts +302 -0
- package/packages/runtime/src/server.ts +481 -0
- package/packages/runtime/src/service-worker-push.ts +319 -0
- package/packages/runtime/src/service-worker.ts +553 -0
- package/packages/runtime/src/skeleton-hydrate.ts +237 -0
- package/packages/runtime/src/speculation.test.ts +389 -0
- package/packages/runtime/src/speculation.ts +486 -0
- package/packages/runtime/src/storage.test.ts +1297 -0
- package/packages/runtime/src/storage.ts +1048 -0
- package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
- package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
- package/packages/runtime/src/sync/coordinator.test.ts +608 -0
- package/packages/runtime/src/sync/coordinator.ts +596 -0
- package/packages/runtime/src/tree-compiler.ts +295 -0
- package/packages/runtime/src/types.ts +728 -0
- package/packages/runtime/src/worker.ts +327 -0
- package/packages/runtime/tsconfig.json +20 -0
- package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
- package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
- package/packages/runtime/wasm/package.json +21 -0
- package/packages/runtime/wrangler.toml +41 -0
- package/packages/runtime-wasm/Cargo.lock +436 -0
- package/packages/runtime-wasm/Cargo.toml +29 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
- package/packages/runtime-wasm/pkg/package.json +21 -0
- package/packages/runtime-wasm/src/hydrate.rs +352 -0
- package/packages/runtime-wasm/src/lib.rs +191 -0
- package/packages/runtime-wasm/src/render.rs +629 -0
- package/packages/runtime-wasm/src/router.rs +298 -0
- package/packages/runtime-wasm/src/skeleton.rs +430 -0
- package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pilot Navigation Hook
|
|
3
|
+
*
|
|
4
|
+
* AI-driven navigation with user consent.
|
|
5
|
+
* Cyrano acts as the "pilot" - suggesting navigation destinations,
|
|
6
|
+
* but always with user consent before actually navigating.
|
|
7
|
+
*
|
|
8
|
+
* The pilot metaphor:
|
|
9
|
+
* - User is the captain
|
|
10
|
+
* - AI (Cyrano) is the pilot suggesting routes
|
|
11
|
+
* - Navigation only happens with captain's approval
|
|
12
|
+
*
|
|
13
|
+
* Features:
|
|
14
|
+
* - Pending navigation queue
|
|
15
|
+
* - Consent confirmation UI
|
|
16
|
+
* - History API integration (smooth client-side navigation)
|
|
17
|
+
* - Navigation analytics/tracking
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useState, useCallback, useEffect, useMemo } from 'react';
|
|
21
|
+
import { useAeonNavigation } from './useAeonNavigation';
|
|
22
|
+
import type { NavigationOptions } from '@affectively/aeon-pages-runtime/navigation';
|
|
23
|
+
|
|
24
|
+
export interface PilotNavigationIntent {
|
|
25
|
+
id: string;
|
|
26
|
+
destination: string;
|
|
27
|
+
reason?: string;
|
|
28
|
+
source: 'cyrano' | 'esi' | 'user' | 'system';
|
|
29
|
+
confidence?: number;
|
|
30
|
+
timestamp: number;
|
|
31
|
+
metadata?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PilotNavigationOptions extends NavigationOptions {
|
|
35
|
+
/** Whether to require explicit consent (default: true for AI sources) */
|
|
36
|
+
requireConsent?: boolean;
|
|
37
|
+
/** Reason for navigation (shown to user) */
|
|
38
|
+
reason?: string;
|
|
39
|
+
/** Source of navigation intent */
|
|
40
|
+
source?: PilotNavigationIntent['source'];
|
|
41
|
+
/** Confidence level (0-1) for AI-driven navigation */
|
|
42
|
+
confidence?: number;
|
|
43
|
+
/** Additional metadata */
|
|
44
|
+
metadata?: Record<string, unknown>;
|
|
45
|
+
/** Auto-navigate after delay (ms) if consent not required */
|
|
46
|
+
autoNavigateDelay?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PilotNavigationState {
|
|
50
|
+
/** Current pending navigation intent awaiting consent */
|
|
51
|
+
pendingIntent: PilotNavigationIntent | null;
|
|
52
|
+
/** History of navigation intents */
|
|
53
|
+
intentHistory: PilotNavigationIntent[];
|
|
54
|
+
/** Whether navigation is in progress */
|
|
55
|
+
isNavigating: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type NavigationConsentCallback = (
|
|
59
|
+
intent: PilotNavigationIntent,
|
|
60
|
+
) => Promise<boolean> | boolean;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hook for AI-piloted navigation with user consent
|
|
64
|
+
*/
|
|
65
|
+
export function usePilotNavigation(options?: {
|
|
66
|
+
/** Custom consent handler (if not provided, uses built-in pending state) */
|
|
67
|
+
onConsentRequired?: NavigationConsentCallback;
|
|
68
|
+
/** Maximum intent history to keep */
|
|
69
|
+
maxHistory?: number;
|
|
70
|
+
}) {
|
|
71
|
+
const { onConsentRequired, maxHistory = 50 } = options ?? {};
|
|
72
|
+
|
|
73
|
+
const navigation = useAeonNavigation();
|
|
74
|
+
const [pendingIntent, setPendingIntent] =
|
|
75
|
+
useState<PilotNavigationIntent | null>(null);
|
|
76
|
+
const [intentHistory, setIntentHistory] = useState<PilotNavigationIntent[]>(
|
|
77
|
+
[],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Request navigation with optional consent
|
|
82
|
+
*/
|
|
83
|
+
const pilot = useCallback(
|
|
84
|
+
async (
|
|
85
|
+
destination: string,
|
|
86
|
+
pilotOptions?: PilotNavigationOptions,
|
|
87
|
+
): Promise<boolean> => {
|
|
88
|
+
const {
|
|
89
|
+
requireConsent = true,
|
|
90
|
+
reason,
|
|
91
|
+
source = 'user',
|
|
92
|
+
confidence,
|
|
93
|
+
metadata,
|
|
94
|
+
autoNavigateDelay,
|
|
95
|
+
...navOptions
|
|
96
|
+
} = pilotOptions ?? {};
|
|
97
|
+
|
|
98
|
+
// Create intent
|
|
99
|
+
const intent: PilotNavigationIntent = {
|
|
100
|
+
id: `nav-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
101
|
+
destination,
|
|
102
|
+
reason,
|
|
103
|
+
source,
|
|
104
|
+
confidence,
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
metadata,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Add to history
|
|
110
|
+
setIntentHistory((prev) => [...prev.slice(-maxHistory + 1), intent]);
|
|
111
|
+
|
|
112
|
+
// Check if consent is required
|
|
113
|
+
const needsConsent =
|
|
114
|
+
requireConsent && (source === 'cyrano' || source === 'esi');
|
|
115
|
+
|
|
116
|
+
if (!needsConsent) {
|
|
117
|
+
// Navigate immediately
|
|
118
|
+
await navigation.navigate(destination, navOptions);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// If custom consent handler provided, use it
|
|
123
|
+
if (onConsentRequired) {
|
|
124
|
+
const consented = await onConsentRequired(intent);
|
|
125
|
+
if (consented) {
|
|
126
|
+
await navigation.navigate(destination, navOptions);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Otherwise, set pending intent for UI to handle
|
|
133
|
+
setPendingIntent(intent);
|
|
134
|
+
|
|
135
|
+
// Auto-navigate after delay if specified
|
|
136
|
+
if (autoNavigateDelay && autoNavigateDelay > 0) {
|
|
137
|
+
setTimeout(async () => {
|
|
138
|
+
// Only navigate if this intent is still pending
|
|
139
|
+
setPendingIntent((current) => {
|
|
140
|
+
if (current?.id === intent.id) {
|
|
141
|
+
navigation.navigate(destination, navOptions);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return current;
|
|
145
|
+
});
|
|
146
|
+
}, autoNavigateDelay);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return false; // Pending consent
|
|
150
|
+
},
|
|
151
|
+
[navigation, onConsentRequired, maxHistory],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Approve pending navigation
|
|
156
|
+
*/
|
|
157
|
+
const approve = useCallback(async () => {
|
|
158
|
+
if (!pendingIntent) return false;
|
|
159
|
+
|
|
160
|
+
const destination = pendingIntent.destination;
|
|
161
|
+
setPendingIntent(null);
|
|
162
|
+
await navigation.navigate(destination);
|
|
163
|
+
return true;
|
|
164
|
+
}, [pendingIntent, navigation]);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Reject pending navigation
|
|
168
|
+
*/
|
|
169
|
+
const reject = useCallback(() => {
|
|
170
|
+
if (!pendingIntent) return;
|
|
171
|
+
setPendingIntent(null);
|
|
172
|
+
}, [pendingIntent]);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Clear all pending intents
|
|
176
|
+
*/
|
|
177
|
+
const clearPending = useCallback(() => {
|
|
178
|
+
setPendingIntent(null);
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Navigate without consent (for user-initiated navigation)
|
|
183
|
+
*/
|
|
184
|
+
const navigateDirect = useCallback(
|
|
185
|
+
async (destination: string, navOptions?: NavigationOptions) => {
|
|
186
|
+
await navigation.navigate(destination, navOptions);
|
|
187
|
+
},
|
|
188
|
+
[navigation],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return useMemo(
|
|
192
|
+
() => ({
|
|
193
|
+
// State
|
|
194
|
+
pendingIntent,
|
|
195
|
+
intentHistory,
|
|
196
|
+
isNavigating: navigation.isNavigating,
|
|
197
|
+
current: navigation.current,
|
|
198
|
+
|
|
199
|
+
// Actions
|
|
200
|
+
pilot, // AI-driven navigation with consent
|
|
201
|
+
approve, // Approve pending navigation
|
|
202
|
+
reject, // Reject pending navigation
|
|
203
|
+
clearPending, // Clear pending intent
|
|
204
|
+
navigateDirect, // Navigate without consent (user-initiated)
|
|
205
|
+
|
|
206
|
+
// Pass through navigation utilities
|
|
207
|
+
prefetch: navigation.prefetch,
|
|
208
|
+
back: navigation.back,
|
|
209
|
+
isPreloaded: navigation.isPreloaded,
|
|
210
|
+
}),
|
|
211
|
+
[
|
|
212
|
+
pendingIntent,
|
|
213
|
+
intentHistory,
|
|
214
|
+
navigation.isNavigating,
|
|
215
|
+
navigation.current,
|
|
216
|
+
pilot,
|
|
217
|
+
approve,
|
|
218
|
+
reject,
|
|
219
|
+
clearPending,
|
|
220
|
+
navigateDirect,
|
|
221
|
+
navigation.prefetch,
|
|
222
|
+
navigation.back,
|
|
223
|
+
navigation.isPreloaded,
|
|
224
|
+
],
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Parse navigation tags from AI response
|
|
230
|
+
* Returns array of destinations extracted from [navigate:/path] tags
|
|
231
|
+
*/
|
|
232
|
+
export function parseNavigationTags(
|
|
233
|
+
text: string,
|
|
234
|
+
): { destination: string; fullMatch: string }[] {
|
|
235
|
+
const regex = /\[navigate:([^\]]+)\]/g;
|
|
236
|
+
const matches: { destination: string; fullMatch: string }[] = [];
|
|
237
|
+
|
|
238
|
+
let match;
|
|
239
|
+
while ((match = regex.exec(text)) !== null) {
|
|
240
|
+
matches.push({
|
|
241
|
+
destination: match[1],
|
|
242
|
+
fullMatch: match[0],
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return matches;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Remove navigation tags from text for display
|
|
251
|
+
*/
|
|
252
|
+
export function stripNavigationTags(text: string): string {
|
|
253
|
+
return text.replace(/\[navigate:[^\]]+\]/g, '').trim();
|
|
254
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Worker Hooks - Client communication with Aeon SW
|
|
3
|
+
*
|
|
4
|
+
* Provides React hooks for:
|
|
5
|
+
* - Total preload progress tracking
|
|
6
|
+
* - Cache status monitoring
|
|
7
|
+
* - Manual preload triggers
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
11
|
+
|
|
12
|
+
export interface PreloadProgress {
|
|
13
|
+
loaded: number;
|
|
14
|
+
total: number;
|
|
15
|
+
percentage: number;
|
|
16
|
+
isComplete: boolean;
|
|
17
|
+
cachedRoutes: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CacheStatus {
|
|
21
|
+
cached: number;
|
|
22
|
+
total: number;
|
|
23
|
+
routes: string[];
|
|
24
|
+
isReady: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hook to register and track the Aeon service worker
|
|
29
|
+
*/
|
|
30
|
+
export function useAeonServiceWorker() {
|
|
31
|
+
const [isRegistered, setIsRegistered] = useState(false);
|
|
32
|
+
const [isActive, setIsActive] = useState(false);
|
|
33
|
+
const [error, setError] = useState<Error | null>(null);
|
|
34
|
+
const registrationRef = useRef<ServiceWorkerRegistration | null>(null);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const registerSW = async () => {
|
|
42
|
+
try {
|
|
43
|
+
const registration = await navigator.serviceWorker.register(
|
|
44
|
+
'/.aeon/sw.js',
|
|
45
|
+
{ scope: '/' },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
registrationRef.current = registration;
|
|
49
|
+
setIsRegistered(true);
|
|
50
|
+
|
|
51
|
+
// Check if active
|
|
52
|
+
if (registration.active) {
|
|
53
|
+
setIsActive(true);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Listen for state changes
|
|
57
|
+
registration.addEventListener('updatefound', () => {
|
|
58
|
+
const newWorker = registration.installing;
|
|
59
|
+
if (newWorker) {
|
|
60
|
+
newWorker.addEventListener('statechange', () => {
|
|
61
|
+
if (newWorker.state === 'activated') {
|
|
62
|
+
setIsActive(true);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
setError(
|
|
69
|
+
err instanceof Error ? err : new Error('Failed to register SW'),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
registerSW();
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const update = useCallback(async () => {
|
|
78
|
+
if (registrationRef.current) {
|
|
79
|
+
await registrationRef.current.update();
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const unregister = useCallback(async () => {
|
|
84
|
+
if (registrationRef.current) {
|
|
85
|
+
await registrationRef.current.unregister();
|
|
86
|
+
setIsRegistered(false);
|
|
87
|
+
setIsActive(false);
|
|
88
|
+
}
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
isRegistered,
|
|
93
|
+
isActive,
|
|
94
|
+
error,
|
|
95
|
+
update,
|
|
96
|
+
unregister,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Hook to track total preload progress
|
|
102
|
+
*/
|
|
103
|
+
export function usePreloadProgress(): PreloadProgress {
|
|
104
|
+
const [progress, setProgress] = useState<PreloadProgress>({
|
|
105
|
+
loaded: 0,
|
|
106
|
+
total: 0,
|
|
107
|
+
percentage: 0,
|
|
108
|
+
isComplete: false,
|
|
109
|
+
cachedRoutes: [],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const handleMessage = (event: MessageEvent) => {
|
|
118
|
+
const data = event.data;
|
|
119
|
+
|
|
120
|
+
if (data.type === 'PRELOAD_PROGRESS') {
|
|
121
|
+
setProgress({
|
|
122
|
+
loaded: data.loaded,
|
|
123
|
+
total: data.total,
|
|
124
|
+
percentage: data.percentage,
|
|
125
|
+
isComplete: false,
|
|
126
|
+
cachedRoutes: [],
|
|
127
|
+
});
|
|
128
|
+
} else if (data.type === 'PRELOAD_COMPLETE') {
|
|
129
|
+
setProgress({
|
|
130
|
+
loaded: data.loaded,
|
|
131
|
+
total: data.total,
|
|
132
|
+
percentage: 100,
|
|
133
|
+
isComplete: true,
|
|
134
|
+
cachedRoutes: data.cachedRoutes || [],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
navigator.serviceWorker.addEventListener('message', handleMessage);
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
navigator.serviceWorker.removeEventListener('message', handleMessage);
|
|
143
|
+
};
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
return progress;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Hook to get current cache status
|
|
151
|
+
*/
|
|
152
|
+
export function useCacheStatus(): CacheStatus & { refresh: () => void } {
|
|
153
|
+
const [status, setStatus] = useState<CacheStatus>({
|
|
154
|
+
cached: 0,
|
|
155
|
+
total: 0,
|
|
156
|
+
routes: [],
|
|
157
|
+
isReady: false,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const refresh = useCallback(async () => {
|
|
161
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const controller = navigator.serviceWorker.controller;
|
|
166
|
+
if (!controller) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Use MessageChannel for request/response
|
|
171
|
+
const channel = new MessageChannel();
|
|
172
|
+
|
|
173
|
+
channel.port1.onmessage = (event: MessageEvent) => {
|
|
174
|
+
const data = event.data;
|
|
175
|
+
setStatus({
|
|
176
|
+
cached: data.cached,
|
|
177
|
+
total: data.total,
|
|
178
|
+
routes: data.routes,
|
|
179
|
+
isReady: data.cached === data.total && data.total > 0,
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
controller.postMessage({ type: 'GET_CACHE_STATUS' }, [channel.port2]);
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
// Initial fetch
|
|
188
|
+
refresh();
|
|
189
|
+
|
|
190
|
+
// Refresh periodically
|
|
191
|
+
const interval = setInterval(refresh, 5000);
|
|
192
|
+
return () => clearInterval(interval);
|
|
193
|
+
}, [refresh]);
|
|
194
|
+
|
|
195
|
+
return { ...status, refresh };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Hook to trigger manual preload
|
|
200
|
+
*/
|
|
201
|
+
export function useManualPreload() {
|
|
202
|
+
const [isPreloading, setIsPreloading] = useState(false);
|
|
203
|
+
const progress = usePreloadProgress();
|
|
204
|
+
|
|
205
|
+
const triggerPreload = useCallback(() => {
|
|
206
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const controller = navigator.serviceWorker.controller;
|
|
211
|
+
if (!controller) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
setIsPreloading(true);
|
|
216
|
+
controller.postMessage({ type: 'TRIGGER_PRELOAD' });
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
// Reset isPreloading when complete
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (progress.isComplete) {
|
|
222
|
+
setIsPreloading(false);
|
|
223
|
+
}
|
|
224
|
+
}, [progress.isComplete]);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
triggerPreload,
|
|
228
|
+
isPreloading,
|
|
229
|
+
progress,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Hook to prefetch a specific route
|
|
235
|
+
*/
|
|
236
|
+
export function usePrefetchRoute() {
|
|
237
|
+
const prefetch = useCallback((route: string) => {
|
|
238
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const controller = navigator.serviceWorker.controller;
|
|
243
|
+
if (!controller) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
controller.postMessage({ type: 'PREFETCH_ROUTE', route });
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
return prefetch;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Hook to clear the cache
|
|
255
|
+
*/
|
|
256
|
+
export function useClearCache() {
|
|
257
|
+
const [isClearing, setIsClearing] = useState(false);
|
|
258
|
+
|
|
259
|
+
const clearCache = useCallback(async () => {
|
|
260
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const controller = navigator.serviceWorker.controller;
|
|
265
|
+
if (!controller) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
setIsClearing(true);
|
|
270
|
+
controller.postMessage({ type: 'CLEAR_CACHE' });
|
|
271
|
+
|
|
272
|
+
// Wait a bit for cache to clear
|
|
273
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
274
|
+
setIsClearing(false);
|
|
275
|
+
}, []);
|
|
276
|
+
|
|
277
|
+
return { clearCache, isClearing };
|
|
278
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Additional React hooks for Aeon Pages
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef, useCallback, useState } from 'react';
|
|
6
|
+
import { useAeonPage, type PresenceUser } from './provider';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* useAeonVersion - Access version and migration tools
|
|
10
|
+
*/
|
|
11
|
+
export function useAeonVersion() {
|
|
12
|
+
const { version, migrate } = useAeonPage();
|
|
13
|
+
return { ...version, migrate };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* useAeonTree - Access the component tree for advanced manipulation
|
|
18
|
+
*/
|
|
19
|
+
export function useAeonTree() {
|
|
20
|
+
const { tree, updateTree } = useAeonPage();
|
|
21
|
+
return { tree, updateTree };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* useCursorTracking - Automatically track cursor movement
|
|
26
|
+
*/
|
|
27
|
+
export function useCursorTracking(enabled = true) {
|
|
28
|
+
const { updateCursor } = useAeonPage();
|
|
29
|
+
const throttleRef = useRef<number | null>(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!enabled) return;
|
|
33
|
+
|
|
34
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
35
|
+
// Throttle to ~60fps
|
|
36
|
+
if (throttleRef.current) return;
|
|
37
|
+
|
|
38
|
+
throttleRef.current = window.requestAnimationFrame(() => {
|
|
39
|
+
updateCursor({ x: e.clientX, y: e.clientY });
|
|
40
|
+
throttleRef.current = null;
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
45
|
+
return () => {
|
|
46
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
47
|
+
if (throttleRef.current) {
|
|
48
|
+
window.cancelAnimationFrame(throttleRef.current);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}, [enabled, updateCursor]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* useEditableElement - Make an element collaboratively editable
|
|
56
|
+
*/
|
|
57
|
+
export function useEditableElement(elementPath: string) {
|
|
58
|
+
const { updateEditing, updateTree, localUser } = useAeonPage();
|
|
59
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
60
|
+
|
|
61
|
+
const onFocus = useCallback(() => {
|
|
62
|
+
setIsFocused(true);
|
|
63
|
+
updateEditing(elementPath);
|
|
64
|
+
}, [elementPath, updateEditing]);
|
|
65
|
+
|
|
66
|
+
const onBlur = useCallback(() => {
|
|
67
|
+
setIsFocused(false);
|
|
68
|
+
updateEditing(null);
|
|
69
|
+
}, [updateEditing]);
|
|
70
|
+
|
|
71
|
+
const onChange = useCallback(
|
|
72
|
+
(value: unknown) => {
|
|
73
|
+
updateTree(elementPath, value);
|
|
74
|
+
},
|
|
75
|
+
[elementPath, updateTree],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
isFocused,
|
|
80
|
+
isBeingEditedByOther: false, // Would check presence
|
|
81
|
+
onFocus,
|
|
82
|
+
onBlur,
|
|
83
|
+
onChange,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* useOtherCursors - Get cursors of other users (excluding self)
|
|
89
|
+
*/
|
|
90
|
+
export function useOtherCursors(): PresenceUser[] {
|
|
91
|
+
const { presence, localUser } = useAeonPage();
|
|
92
|
+
return presence.filter((user) => user.userId !== localUser?.userId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* useOfflineStatus - Track offline status and pending operations
|
|
97
|
+
*/
|
|
98
|
+
export function useOfflineStatus() {
|
|
99
|
+
const { sync } = useAeonPage();
|
|
100
|
+
return {
|
|
101
|
+
isOffline: !sync.isOnline,
|
|
102
|
+
isSyncing: sync.isSyncing,
|
|
103
|
+
pendingOperations: sync.pendingOperations,
|
|
104
|
+
lastSyncAt: sync.lastSyncAt,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* useCollaborativeInput - Hook for collaborative text input
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```tsx
|
|
113
|
+
* function EditableTitle() {
|
|
114
|
+
* const { value, onChange, onFocus, onBlur, isEditing, editingBy } = useCollaborativeInput('title');
|
|
115
|
+
*
|
|
116
|
+
* return (
|
|
117
|
+
* <input
|
|
118
|
+
* value={value}
|
|
119
|
+
* onChange={(e) => onChange(e.target.value)}
|
|
120
|
+
* onFocus={onFocus}
|
|
121
|
+
* onBlur={onBlur}
|
|
122
|
+
* style={{ borderColor: editingBy ? 'blue' : undefined }}
|
|
123
|
+
* />
|
|
124
|
+
* );
|
|
125
|
+
* }
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
export function useCollaborativeInput(key: string) {
|
|
129
|
+
const { data, setData, presence, localUser, updateEditing } = useAeonPage();
|
|
130
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
131
|
+
|
|
132
|
+
const value = (data[key] as string) ?? '';
|
|
133
|
+
|
|
134
|
+
// Find if someone else is editing this field
|
|
135
|
+
const editingBy = presence.find(
|
|
136
|
+
(user) => user.editing === key && user.userId !== localUser?.userId,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const onChange = useCallback(
|
|
140
|
+
(newValue: string) => {
|
|
141
|
+
setData(key, newValue);
|
|
142
|
+
},
|
|
143
|
+
[key, setData],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const onFocus = useCallback(() => {
|
|
147
|
+
setIsEditing(true);
|
|
148
|
+
updateEditing(key);
|
|
149
|
+
}, [key, updateEditing]);
|
|
150
|
+
|
|
151
|
+
const onBlur = useCallback(() => {
|
|
152
|
+
setIsEditing(false);
|
|
153
|
+
updateEditing(null);
|
|
154
|
+
}, [updateEditing]);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
value,
|
|
158
|
+
onChange,
|
|
159
|
+
onFocus,
|
|
160
|
+
onBlur,
|
|
161
|
+
isEditing,
|
|
162
|
+
editingBy,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* useAeonEffect - Run effect when Aeon data changes
|
|
168
|
+
*/
|
|
169
|
+
export function useAeonEffect(
|
|
170
|
+
key: string,
|
|
171
|
+
effect: (value: unknown) => void | (() => void),
|
|
172
|
+
) {
|
|
173
|
+
const { data } = useAeonPage();
|
|
174
|
+
const value = data[key];
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
return effect(value);
|
|
178
|
+
}, [value, effect]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* useSessionId - Get the current session ID
|
|
183
|
+
*/
|
|
184
|
+
export function useSessionId(): string {
|
|
185
|
+
const { sessionId } = useAeonPage();
|
|
186
|
+
return sessionId;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* useRoute - Get the current route
|
|
191
|
+
*/
|
|
192
|
+
export function useRoute(): string {
|
|
193
|
+
const { route } = useAeonPage();
|
|
194
|
+
return route;
|
|
195
|
+
}
|