@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,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aeon Link Component
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for <a> with superpowers:
|
|
5
|
+
* - Visibility-based prefetch
|
|
6
|
+
* - Hover prefetch
|
|
7
|
+
* - Intent detection (cursor trajectory)
|
|
8
|
+
* - View transitions
|
|
9
|
+
* - Presence awareness
|
|
10
|
+
* - Total preload support
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, {
|
|
14
|
+
forwardRef,
|
|
15
|
+
useEffect,
|
|
16
|
+
useRef,
|
|
17
|
+
useCallback,
|
|
18
|
+
useState,
|
|
19
|
+
type ReactNode,
|
|
20
|
+
type MouseEvent,
|
|
21
|
+
type AnchorHTMLAttributes,
|
|
22
|
+
} from 'react';
|
|
23
|
+
import { useAeonNavigation, useRoutePresence } from './hooks/useAeonNavigation';
|
|
24
|
+
|
|
25
|
+
export type TransitionType = 'slide' | 'fade' | 'morph' | 'none';
|
|
26
|
+
export type PrefetchStrategy = 'hover' | 'visible' | 'intent' | 'none';
|
|
27
|
+
|
|
28
|
+
export interface PresenceRenderProps {
|
|
29
|
+
count: number;
|
|
30
|
+
editing: number;
|
|
31
|
+
hot: boolean;
|
|
32
|
+
users?: { userId: string; name?: string }[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LinkProps
|
|
36
|
+
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'children'> {
|
|
37
|
+
href: string;
|
|
38
|
+
prefetch?: PrefetchStrategy;
|
|
39
|
+
transition?: TransitionType;
|
|
40
|
+
showPresence?: boolean;
|
|
41
|
+
preloadData?: boolean;
|
|
42
|
+
replace?: boolean;
|
|
43
|
+
children?:
|
|
44
|
+
| ReactNode
|
|
45
|
+
| ((props: { presence: PresenceRenderProps | null }) => ReactNode);
|
|
46
|
+
onNavigateStart?: () => void;
|
|
47
|
+
onNavigateEnd?: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
|
|
51
|
+
(
|
|
52
|
+
{
|
|
53
|
+
href,
|
|
54
|
+
prefetch = 'visible',
|
|
55
|
+
transition = 'fade',
|
|
56
|
+
showPresence = false,
|
|
57
|
+
preloadData = true,
|
|
58
|
+
replace = false,
|
|
59
|
+
children,
|
|
60
|
+
onNavigateStart,
|
|
61
|
+
onNavigateEnd,
|
|
62
|
+
onClick,
|
|
63
|
+
onMouseEnter,
|
|
64
|
+
onMouseMove,
|
|
65
|
+
className,
|
|
66
|
+
...props
|
|
67
|
+
},
|
|
68
|
+
ref,
|
|
69
|
+
) => {
|
|
70
|
+
const internalRef = useRef<HTMLAnchorElement>(null);
|
|
71
|
+
const linkRef = (ref as React.RefObject<HTMLAnchorElement>) ?? internalRef;
|
|
72
|
+
const trajectoryRef = useRef<{ x: number; y: number; time: number }[]>([]);
|
|
73
|
+
const intentTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
74
|
+
|
|
75
|
+
const {
|
|
76
|
+
navigate,
|
|
77
|
+
prefetch: doPrefetch,
|
|
78
|
+
isPreloaded,
|
|
79
|
+
isNavigating,
|
|
80
|
+
} = useAeonNavigation();
|
|
81
|
+
const { getPresence, subscribePresence } = useRoutePresence();
|
|
82
|
+
|
|
83
|
+
const [presence, setPresence] = useState<PresenceRenderProps | null>(null);
|
|
84
|
+
const [isPrefetched, setIsPrefetched] = useState(false);
|
|
85
|
+
|
|
86
|
+
// Check initial prefetch state
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
setIsPrefetched(isPreloaded(href));
|
|
89
|
+
}, [href, isPreloaded]);
|
|
90
|
+
|
|
91
|
+
// Visibility-based prefetch
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (
|
|
94
|
+
prefetch !== 'visible' ||
|
|
95
|
+
typeof IntersectionObserver === 'undefined'
|
|
96
|
+
) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const observer = new IntersectionObserver(
|
|
101
|
+
([entry]) => {
|
|
102
|
+
if (entry.isIntersecting) {
|
|
103
|
+
doPrefetch(href, { data: preloadData, presence: showPresence });
|
|
104
|
+
setIsPrefetched(true);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{ rootMargin: '100px' },
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const element = linkRef.current;
|
|
111
|
+
if (element) observer.observe(element);
|
|
112
|
+
|
|
113
|
+
return () => observer.disconnect();
|
|
114
|
+
}, [href, prefetch, preloadData, showPresence, doPrefetch, linkRef]);
|
|
115
|
+
|
|
116
|
+
// Presence subscription
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!showPresence) return;
|
|
119
|
+
|
|
120
|
+
// Get initial presence
|
|
121
|
+
const initialPresence = getPresence(href);
|
|
122
|
+
if (initialPresence) {
|
|
123
|
+
const { count, editing, hot, users } = initialPresence;
|
|
124
|
+
setPresence({ count, editing, hot, users });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Subscribe to updates
|
|
128
|
+
const unsubscribe = subscribePresence((route, info) => {
|
|
129
|
+
if (route === href) {
|
|
130
|
+
const { count, editing, hot, users } = info;
|
|
131
|
+
setPresence({ count, editing, hot, users });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return unsubscribe;
|
|
136
|
+
}, [href, showPresence, getPresence, subscribePresence]);
|
|
137
|
+
|
|
138
|
+
// Hover prefetch handler
|
|
139
|
+
const handleMouseEnter = useCallback(
|
|
140
|
+
(e: MouseEvent<HTMLAnchorElement>) => {
|
|
141
|
+
onMouseEnter?.(e);
|
|
142
|
+
|
|
143
|
+
if (prefetch === 'hover' || prefetch === 'intent') {
|
|
144
|
+
doPrefetch(href, { data: preloadData, presence: showPresence });
|
|
145
|
+
setIsPrefetched(true);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
[href, prefetch, preloadData, showPresence, doPrefetch, onMouseEnter],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Intent detection (cursor trajectory prediction)
|
|
152
|
+
const handleMouseMove = useCallback(
|
|
153
|
+
(e: MouseEvent<HTMLAnchorElement>) => {
|
|
154
|
+
onMouseMove?.(e);
|
|
155
|
+
|
|
156
|
+
if (prefetch !== 'intent') return;
|
|
157
|
+
|
|
158
|
+
// Track cursor trajectory
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
trajectoryRef.current.push({ x: e.clientX, y: e.clientY, time: now });
|
|
161
|
+
|
|
162
|
+
// Keep only last 5 points
|
|
163
|
+
if (trajectoryRef.current.length > 5) {
|
|
164
|
+
trajectoryRef.current.shift();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Clear previous timeout
|
|
168
|
+
if (intentTimeoutRef.current) {
|
|
169
|
+
clearTimeout(intentTimeoutRef.current);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Predict intent after short delay
|
|
173
|
+
intentTimeoutRef.current = setTimeout(() => {
|
|
174
|
+
const points = trajectoryRef.current;
|
|
175
|
+
if (points.length < 2) return;
|
|
176
|
+
|
|
177
|
+
const element = linkRef.current;
|
|
178
|
+
if (!element) return;
|
|
179
|
+
|
|
180
|
+
// Calculate if cursor is approaching the link
|
|
181
|
+
const rect = element.getBoundingClientRect();
|
|
182
|
+
const centerX = rect.left + rect.width / 2;
|
|
183
|
+
const centerY = rect.top + rect.height / 2;
|
|
184
|
+
|
|
185
|
+
const lastPoint = points[points.length - 1];
|
|
186
|
+
const prevPoint = points[points.length - 2];
|
|
187
|
+
|
|
188
|
+
const velocityX = lastPoint.x - prevPoint.x;
|
|
189
|
+
const velocityY = lastPoint.y - prevPoint.y;
|
|
190
|
+
|
|
191
|
+
// Project cursor position
|
|
192
|
+
const projectedX = lastPoint.x + velocityX * 10;
|
|
193
|
+
const projectedY = lastPoint.y + velocityY * 10;
|
|
194
|
+
|
|
195
|
+
// Check if projected position is closer to link
|
|
196
|
+
const currentDist = Math.hypot(
|
|
197
|
+
lastPoint.x - centerX,
|
|
198
|
+
lastPoint.y - centerY,
|
|
199
|
+
);
|
|
200
|
+
const projectedDist = Math.hypot(
|
|
201
|
+
projectedX - centerX,
|
|
202
|
+
projectedY - centerY,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (projectedDist < currentDist) {
|
|
206
|
+
// Cursor is approaching - prefetch with high priority
|
|
207
|
+
doPrefetch(href, {
|
|
208
|
+
data: preloadData,
|
|
209
|
+
presence: showPresence,
|
|
210
|
+
priority: 'high',
|
|
211
|
+
});
|
|
212
|
+
setIsPrefetched(true);
|
|
213
|
+
}
|
|
214
|
+
}, 50);
|
|
215
|
+
},
|
|
216
|
+
[
|
|
217
|
+
href,
|
|
218
|
+
prefetch,
|
|
219
|
+
preloadData,
|
|
220
|
+
showPresence,
|
|
221
|
+
doPrefetch,
|
|
222
|
+
onMouseMove,
|
|
223
|
+
linkRef,
|
|
224
|
+
],
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// Click navigation with view transition
|
|
228
|
+
const handleClick = useCallback(
|
|
229
|
+
async (e: MouseEvent<HTMLAnchorElement>) => {
|
|
230
|
+
// Call original onClick if provided
|
|
231
|
+
onClick?.(e);
|
|
232
|
+
|
|
233
|
+
// Don't handle if default prevented or modified
|
|
234
|
+
if (
|
|
235
|
+
e.defaultPrevented ||
|
|
236
|
+
e.metaKey ||
|
|
237
|
+
e.ctrlKey ||
|
|
238
|
+
e.shiftKey ||
|
|
239
|
+
e.altKey ||
|
|
240
|
+
e.button !== 0
|
|
241
|
+
) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
|
|
247
|
+
onNavigateStart?.();
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
await navigate(href, { transition, replace });
|
|
251
|
+
} finally {
|
|
252
|
+
onNavigateEnd?.();
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
[
|
|
256
|
+
href,
|
|
257
|
+
transition,
|
|
258
|
+
replace,
|
|
259
|
+
navigate,
|
|
260
|
+
onClick,
|
|
261
|
+
onNavigateStart,
|
|
262
|
+
onNavigateEnd,
|
|
263
|
+
],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Cleanup
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
return () => {
|
|
269
|
+
if (intentTimeoutRef.current) {
|
|
270
|
+
clearTimeout(intentTimeoutRef.current);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
275
|
+
// Render children
|
|
276
|
+
const renderChildren = () => {
|
|
277
|
+
if (typeof children === 'function') {
|
|
278
|
+
return children({ presence });
|
|
279
|
+
}
|
|
280
|
+
return (
|
|
281
|
+
<>
|
|
282
|
+
{children}
|
|
283
|
+
{showPresence && presence && presence.count > 0 && (
|
|
284
|
+
<span
|
|
285
|
+
className="aeon-presence-badge"
|
|
286
|
+
aria-label={`${presence.count} active`}
|
|
287
|
+
>
|
|
288
|
+
{presence.hot ? '\uD83D\uDD25' : '\uD83D\uDC65'} {presence.count}
|
|
289
|
+
{presence.editing > 0 && ` (${presence.editing} editing)`}
|
|
290
|
+
</span>
|
|
291
|
+
)}
|
|
292
|
+
</>
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<a
|
|
298
|
+
ref={linkRef}
|
|
299
|
+
href={href}
|
|
300
|
+
onClick={handleClick}
|
|
301
|
+
onMouseEnter={handleMouseEnter}
|
|
302
|
+
onMouseMove={handleMouseMove}
|
|
303
|
+
className={className}
|
|
304
|
+
data-preloaded={isPrefetched ? '' : undefined}
|
|
305
|
+
data-navigating={isNavigating ? '' : undefined}
|
|
306
|
+
data-transition={transition}
|
|
307
|
+
aria-busy={isNavigating}
|
|
308
|
+
{...props}
|
|
309
|
+
>
|
|
310
|
+
{renderChildren()}
|
|
311
|
+
</a>
|
|
312
|
+
);
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
Link.displayName = 'Link';
|
|
317
|
+
|
|
318
|
+
// CSS for presence badge (can be overridden)
|
|
319
|
+
if (typeof document !== 'undefined') {
|
|
320
|
+
const style = document.createElement('style');
|
|
321
|
+
style.textContent = `
|
|
322
|
+
.aeon-presence-badge {
|
|
323
|
+
display: inline-flex;
|
|
324
|
+
align-items: center;
|
|
325
|
+
gap: 0.25rem;
|
|
326
|
+
font-size: 0.75rem;
|
|
327
|
+
padding: 0.125rem 0.375rem;
|
|
328
|
+
margin-left: 0.5rem;
|
|
329
|
+
background: rgba(0, 0, 0, 0.05);
|
|
330
|
+
border-radius: 9999px;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
[data-preloaded]::after {
|
|
334
|
+
content: '';
|
|
335
|
+
display: inline-block;
|
|
336
|
+
width: 4px;
|
|
337
|
+
height: 4px;
|
|
338
|
+
margin-left: 0.25rem;
|
|
339
|
+
background: #10b981;
|
|
340
|
+
border-radius: 50%;
|
|
341
|
+
opacity: 0.5;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* View transition styles */
|
|
345
|
+
::view-transition-old(aeon-page) {
|
|
346
|
+
animation: aeon-fade-out 200ms ease-out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
::view-transition-new(aeon-page) {
|
|
350
|
+
animation: aeon-fade-in 300ms ease-out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
@keyframes aeon-fade-out {
|
|
354
|
+
from { opacity: 1; }
|
|
355
|
+
to { opacity: 0; }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
@keyframes aeon-fade-in {
|
|
359
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
360
|
+
to { opacity: 1; transform: translateY(0); }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* Slide transition */
|
|
364
|
+
[data-transition="slide"]::view-transition-old(aeon-page) {
|
|
365
|
+
animation: aeon-slide-out 200ms ease-out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
[data-transition="slide"]::view-transition-new(aeon-page) {
|
|
369
|
+
animation: aeon-slide-in 300ms ease-out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
@keyframes aeon-slide-out {
|
|
373
|
+
from { transform: translateX(0); opacity: 1; }
|
|
374
|
+
to { transform: translateX(-20px); opacity: 0; }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
@keyframes aeon-slide-in {
|
|
378
|
+
from { transform: translateX(20px); opacity: 0; }
|
|
379
|
+
to { transform: translateX(0); opacity: 1; }
|
|
380
|
+
}
|
|
381
|
+
`;
|
|
382
|
+
|
|
383
|
+
// Only inject once
|
|
384
|
+
if (!document.getElementById('aeon-link-styles')) {
|
|
385
|
+
style.id = 'aeon-link-styles';
|
|
386
|
+
document.head.appendChild(style);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InstallPrompt Component
|
|
3
|
+
*
|
|
4
|
+
* PWA install prompt component for web applications.
|
|
5
|
+
* Handles beforeinstallprompt event and iOS-specific instructions.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Cross-browser install prompt handling
|
|
9
|
+
* - iOS-specific installation instructions
|
|
10
|
+
* - Standalone mode detection
|
|
11
|
+
* - Customizable UI via render props
|
|
12
|
+
* - Headless hook export
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use client';
|
|
16
|
+
|
|
17
|
+
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Types
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
interface BeforeInstallPromptEvent extends Event {
|
|
24
|
+
prompt: () => Promise<void>;
|
|
25
|
+
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InstallPromptState {
|
|
29
|
+
/** Whether the app can be installed */
|
|
30
|
+
canInstall: boolean;
|
|
31
|
+
/** Whether the app is already installed (standalone mode) */
|
|
32
|
+
isInstalled: boolean;
|
|
33
|
+
/** Whether on iOS */
|
|
34
|
+
isIOS: boolean;
|
|
35
|
+
/** Trigger the install prompt (Chrome/Edge) */
|
|
36
|
+
install: () => Promise<'accepted' | 'dismissed' | 'unavailable'>;
|
|
37
|
+
/** Dismiss the install prompt */
|
|
38
|
+
dismiss: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface InstallPromptProps {
|
|
42
|
+
/** Render when app is already installed */
|
|
43
|
+
renderInstalled?: () => ReactNode;
|
|
44
|
+
/** Render install prompt (receives install function) */
|
|
45
|
+
renderPrompt?: (state: InstallPromptState) => ReactNode;
|
|
46
|
+
/** Render iOS-specific instructions */
|
|
47
|
+
renderIOSInstructions?: () => ReactNode;
|
|
48
|
+
/** Only show when install is available */
|
|
49
|
+
showOnlyWhenInstallable?: boolean;
|
|
50
|
+
/** CSS class for container */
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// useInstallPrompt Hook
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hook for managing PWA install prompt
|
|
60
|
+
*/
|
|
61
|
+
export function useInstallPrompt(): InstallPromptState {
|
|
62
|
+
const [isIOS, setIsIOS] = useState(false);
|
|
63
|
+
const [isInstalled, setIsInstalled] = useState(false);
|
|
64
|
+
const [deferredPrompt, setDeferredPrompt] =
|
|
65
|
+
useState<BeforeInstallPromptEvent | null>(null);
|
|
66
|
+
const [canInstall, setCanInstall] = useState(false);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Detect iOS
|
|
74
|
+
const iOS =
|
|
75
|
+
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
|
|
76
|
+
!(window as unknown as { MSStream?: unknown }).MSStream;
|
|
77
|
+
setIsIOS(iOS);
|
|
78
|
+
|
|
79
|
+
// Detect standalone mode (already installed)
|
|
80
|
+
const standalone =
|
|
81
|
+
window.matchMedia('(display-mode: standalone)').matches ||
|
|
82
|
+
(window.navigator as unknown as { standalone?: boolean }).standalone ===
|
|
83
|
+
true;
|
|
84
|
+
setIsInstalled(standalone);
|
|
85
|
+
|
|
86
|
+
// For iOS, show instructions even if can't trigger prompt
|
|
87
|
+
if (iOS && !standalone) {
|
|
88
|
+
setCanInstall(true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Listen for beforeinstallprompt event
|
|
92
|
+
const handleBeforeInstallPrompt = (e: Event) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
|
95
|
+
setCanInstall(true);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
99
|
+
|
|
100
|
+
// Listen for app installed event
|
|
101
|
+
const handleAppInstalled = () => {
|
|
102
|
+
setIsInstalled(true);
|
|
103
|
+
setCanInstall(false);
|
|
104
|
+
setDeferredPrompt(null);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
window.addEventListener('appinstalled', handleAppInstalled);
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
window.removeEventListener(
|
|
111
|
+
'beforeinstallprompt',
|
|
112
|
+
handleBeforeInstallPrompt,
|
|
113
|
+
);
|
|
114
|
+
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const install = useCallback(async (): Promise<
|
|
119
|
+
'accepted' | 'dismissed' | 'unavailable'
|
|
120
|
+
> => {
|
|
121
|
+
if (!deferredPrompt) {
|
|
122
|
+
return 'unavailable';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await deferredPrompt.prompt();
|
|
126
|
+
const { outcome } = await deferredPrompt.userChoice;
|
|
127
|
+
|
|
128
|
+
if (outcome === 'accepted') {
|
|
129
|
+
setDeferredPrompt(null);
|
|
130
|
+
setCanInstall(false);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return outcome;
|
|
134
|
+
}, [deferredPrompt]);
|
|
135
|
+
|
|
136
|
+
const dismiss = useCallback(() => {
|
|
137
|
+
setCanInstall(false);
|
|
138
|
+
setDeferredPrompt(null);
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
canInstall,
|
|
143
|
+
isInstalled,
|
|
144
|
+
isIOS,
|
|
145
|
+
install,
|
|
146
|
+
dismiss,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// InstallPrompt Component
|
|
152
|
+
// ============================================================================
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* PWA install prompt component
|
|
156
|
+
*/
|
|
157
|
+
export function InstallPrompt({
|
|
158
|
+
renderInstalled,
|
|
159
|
+
renderPrompt,
|
|
160
|
+
renderIOSInstructions,
|
|
161
|
+
showOnlyWhenInstallable = true,
|
|
162
|
+
className,
|
|
163
|
+
}: InstallPromptProps): ReactNode {
|
|
164
|
+
const state = useInstallPrompt();
|
|
165
|
+
|
|
166
|
+
// Don't render if already installed
|
|
167
|
+
if (state.isInstalled) {
|
|
168
|
+
return renderInstalled?.() || null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Don't render if not installable and only showing when installable
|
|
172
|
+
if (showOnlyWhenInstallable && !state.canInstall) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Custom render for iOS
|
|
177
|
+
if (state.isIOS) {
|
|
178
|
+
if (renderIOSInstructions) {
|
|
179
|
+
return renderIOSInstructions();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
className={className}
|
|
185
|
+
role="region"
|
|
186
|
+
aria-label="Install app instructions"
|
|
187
|
+
>
|
|
188
|
+
<h3
|
|
189
|
+
style={{
|
|
190
|
+
fontSize: '1.125rem',
|
|
191
|
+
fontWeight: 600,
|
|
192
|
+
marginBottom: '0.5rem',
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
Install App
|
|
196
|
+
</h3>
|
|
197
|
+
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
|
198
|
+
To install this app on your iOS device:
|
|
199
|
+
</p>
|
|
200
|
+
<ol
|
|
201
|
+
style={{
|
|
202
|
+
fontSize: '0.875rem',
|
|
203
|
+
paddingLeft: '1.5rem',
|
|
204
|
+
listStyleType: 'decimal',
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
<li>Tap the share button in Safari</li>
|
|
208
|
+
<li>Scroll down and tap "Add to Home Screen"</li>
|
|
209
|
+
<li>Tap "Add" to confirm</li>
|
|
210
|
+
</ol>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Custom render for install prompt
|
|
216
|
+
if (renderPrompt) {
|
|
217
|
+
return renderPrompt(state);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Default install prompt
|
|
221
|
+
if (!state.canInstall) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<div className={className} role="region" aria-label="Install app prompt">
|
|
227
|
+
<h3
|
|
228
|
+
style={{
|
|
229
|
+
fontSize: '1.125rem',
|
|
230
|
+
fontWeight: 600,
|
|
231
|
+
marginBottom: '0.5rem',
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
Install App
|
|
235
|
+
</h3>
|
|
236
|
+
<p style={{ fontSize: '0.875rem', marginBottom: '1rem' }}>
|
|
237
|
+
Install this app on your device for a better experience.
|
|
238
|
+
</p>
|
|
239
|
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
240
|
+
<button
|
|
241
|
+
onClick={() => state.install()}
|
|
242
|
+
style={{
|
|
243
|
+
padding: '0.5rem 1rem',
|
|
244
|
+
backgroundColor: '#0d9488',
|
|
245
|
+
color: 'white',
|
|
246
|
+
border: 'none',
|
|
247
|
+
borderRadius: '0.375rem',
|
|
248
|
+
cursor: 'pointer',
|
|
249
|
+
fontSize: '0.875rem',
|
|
250
|
+
}}
|
|
251
|
+
aria-label="Install application"
|
|
252
|
+
>
|
|
253
|
+
Add to Home Screen
|
|
254
|
+
</button>
|
|
255
|
+
<button
|
|
256
|
+
onClick={state.dismiss}
|
|
257
|
+
style={{
|
|
258
|
+
padding: '0.5rem 1rem',
|
|
259
|
+
backgroundColor: 'transparent',
|
|
260
|
+
color: '#6b7280',
|
|
261
|
+
border: '1px solid #d1d5db',
|
|
262
|
+
borderRadius: '0.375rem',
|
|
263
|
+
cursor: 'pointer',
|
|
264
|
+
fontSize: '0.875rem',
|
|
265
|
+
}}
|
|
266
|
+
aria-label="Dismiss install prompt"
|
|
267
|
+
>
|
|
268
|
+
Not now
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============================================================================
|
|
276
|
+
// Type declarations for global events
|
|
277
|
+
// ============================================================================
|
|
278
|
+
|
|
279
|
+
declare global {
|
|
280
|
+
interface WindowEventMap {
|
|
281
|
+
beforeinstallprompt: BeforeInstallPromptEvent;
|
|
282
|
+
appinstalled: Event;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export default InstallPrompt;
|