@affectively/aeon-flux 0.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.
Files changed (72) hide show
  1. package/README.md +438 -0
  2. package/examples/basic/aeon.config.ts +39 -0
  3. package/examples/basic/components/Cursor.tsx +88 -0
  4. package/examples/basic/components/OfflineIndicator.tsx +93 -0
  5. package/examples/basic/components/PresenceBar.tsx +68 -0
  6. package/examples/basic/package.json +20 -0
  7. package/examples/basic/pages/index.tsx +73 -0
  8. package/package.json +90 -0
  9. package/packages/benchmarks/src/benchmark.test.ts +644 -0
  10. package/packages/cli/package.json +43 -0
  11. package/packages/cli/src/commands/build.test.ts +649 -0
  12. package/packages/cli/src/commands/build.ts +853 -0
  13. package/packages/cli/src/commands/dev.ts +463 -0
  14. package/packages/cli/src/commands/init.ts +395 -0
  15. package/packages/cli/src/commands/start.ts +289 -0
  16. package/packages/cli/src/index.ts +102 -0
  17. package/packages/directives/src/use-aeon.ts +266 -0
  18. package/packages/react/package.json +34 -0
  19. package/packages/react/src/Link.tsx +355 -0
  20. package/packages/react/src/hooks/useAeonNavigation.ts +204 -0
  21. package/packages/react/src/hooks/usePilotNavigation.ts +253 -0
  22. package/packages/react/src/hooks/useServiceWorker.ts +276 -0
  23. package/packages/react/src/hooks.ts +192 -0
  24. package/packages/react/src/index.ts +89 -0
  25. package/packages/react/src/provider.tsx +428 -0
  26. package/packages/runtime/package.json +70 -0
  27. package/packages/runtime/schema.sql +40 -0
  28. package/packages/runtime/src/api-routes.ts +453 -0
  29. package/packages/runtime/src/benchmark.ts +145 -0
  30. package/packages/runtime/src/cache.ts +287 -0
  31. package/packages/runtime/src/durable-object.ts +847 -0
  32. package/packages/runtime/src/index.ts +235 -0
  33. package/packages/runtime/src/navigation.test.ts +432 -0
  34. package/packages/runtime/src/navigation.ts +412 -0
  35. package/packages/runtime/src/nextjs-adapter.ts +254 -0
  36. package/packages/runtime/src/predictor.ts +368 -0
  37. package/packages/runtime/src/registry.ts +339 -0
  38. package/packages/runtime/src/router/context-extractor.ts +394 -0
  39. package/packages/runtime/src/router/esi-control-react.tsx +1172 -0
  40. package/packages/runtime/src/router/esi-control.ts +488 -0
  41. package/packages/runtime/src/router/esi-react.tsx +600 -0
  42. package/packages/runtime/src/router/esi.ts +595 -0
  43. package/packages/runtime/src/router/heuristic-adapter.test.ts +272 -0
  44. package/packages/runtime/src/router/heuristic-adapter.ts +544 -0
  45. package/packages/runtime/src/router/index.ts +158 -0
  46. package/packages/runtime/src/router/speculation.ts +442 -0
  47. package/packages/runtime/src/router/types.ts +514 -0
  48. package/packages/runtime/src/router.test.ts +466 -0
  49. package/packages/runtime/src/router.ts +285 -0
  50. package/packages/runtime/src/server.ts +446 -0
  51. package/packages/runtime/src/service-worker.ts +418 -0
  52. package/packages/runtime/src/speculation.test.ts +360 -0
  53. package/packages/runtime/src/speculation.ts +456 -0
  54. package/packages/runtime/src/storage.test.ts +1201 -0
  55. package/packages/runtime/src/storage.ts +1031 -0
  56. package/packages/runtime/src/tree-compiler.ts +252 -0
  57. package/packages/runtime/src/types.ts +444 -0
  58. package/packages/runtime/src/worker.ts +300 -0
  59. package/packages/runtime/tsconfig.json +19 -0
  60. package/packages/runtime/wrangler.toml +41 -0
  61. package/packages/runtime-wasm/Cargo.lock +436 -0
  62. package/packages/runtime-wasm/Cargo.toml +29 -0
  63. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +328 -0
  64. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1267 -0
  65. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  66. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +73 -0
  67. package/packages/runtime-wasm/pkg/package.json +21 -0
  68. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  69. package/packages/runtime-wasm/src/lib.rs +189 -0
  70. package/packages/runtime-wasm/src/render.rs +629 -0
  71. package/packages/runtime-wasm/src/router.rs +298 -0
  72. package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
package/README.md ADDED
@@ -0,0 +1,438 @@
1
+ # @affectively/aeon-flux
2
+
3
+ **The CMS IS the website.** Collaborative page framework with CRDT-based flux state and hyperpersonalized routing.
4
+
5
+ ```
6
+ 'use aeon'; // One directive enables everything
7
+ ```
8
+
9
+ ## What is Aeon Flux?
10
+
11
+ Aeon Flux is a lightweight, collaborative page framework that unifies:
12
+
13
+ - **CMS = Website** - No separate admin panel, edit in place
14
+ - **Editor = Viewer** - Click to edit, changes are live
15
+ - **Backend = Frontend** - One runtime, runs on edge
16
+ - **Pages from D1** - No file system, pure database
17
+ - **AI Everywhere** - Edge Side Inference (ESI) brings AI to any component
18
+
19
+ The name comes from:
20
+ 1. **Flux** - CRDT-based data model for conflict-free collaboration
21
+ 2. **Aeon Flux** - The classic anime (a syzygy of human and machine)
22
+
23
+ ## Key Features
24
+
25
+ | Feature | Description |
26
+ |---------|-------------|
27
+ | **Zero-Dependency Rendering** | Single HTML with inline CSS, assets, fonts |
28
+ | **Hyperpersonalized Routing** | The website comes to the person |
29
+ | **Edge Side Inference (ESI)** | AI inference at render time |
30
+ | **Pages from D1** | No file system in production |
31
+ | **~20KB WASM runtime** | vs 500KB+ Next.js |
32
+ | **Multi-layer caching** | KV (1ms) → D1 (5ms) → Session (50ms) |
33
+ | **Speculative pre-rendering** | Zero-latency navigation |
34
+ | **Built-in collaboration** | Real-time editing with presence |
35
+ | **GitHub PR Publishing** | Visual edits → TSX → Git → Deploy |
36
+ | **Lazy hydration** | Only hydrate interactive components on visibility |
37
+
38
+ ## Live Infrastructure
39
+
40
+ ```
41
+ https://aeon-flux.taylorbuley.workers.dev
42
+ ```
43
+
44
+ | Endpoint | Description |
45
+ |----------|-------------|
46
+ | `/health` | Health check |
47
+ | `/session/:id` | WebSocket for real-time collab |
48
+ | `/session/:id/session` | Session state (GET/PUT) |
49
+ | `/session/:id/presence` | Active users |
50
+ | `/routes` | Route registry |
51
+
52
+ ## Quick Start
53
+
54
+ ```bash
55
+ # Install
56
+ bun add @affectively/aeon-pages-runtime
57
+
58
+ # Or use the worker directly
59
+ curl https://aeon-flux.taylorbuley.workers.dev/health
60
+ ```
61
+
62
+ ## Hyperpersonalized Routing
63
+
64
+ **"The website comes to the person"** - Routes adapt based on user context.
65
+
66
+ ```typescript
67
+ import { HeuristicAdapter, extractUserContext } from '@affectively/aeon-pages-runtime';
68
+
69
+ const adapter = new HeuristicAdapter({
70
+ defaultPaths: ['/', '/chat', '/settings'],
71
+ signals: {
72
+ deriveTheme: (ctx) => ctx.localHour > 18 ? 'dark' : 'light',
73
+ deriveAccent: (ctx) => ctx.emotionState?.primary === 'happy' ? '#FFD700' : '#6366f1',
74
+ },
75
+ });
76
+
77
+ // Route decision includes personalization
78
+ const decision = await adapter.route('/dashboard', userContext, componentTree);
79
+ // { theme: 'dark', accent: '#FFD700', prefetch: ['/chat'], density: 'comfortable', ... }
80
+ ```
81
+
82
+ ### Performance
83
+
84
+ | Operation | 100 nodes | 500 nodes |
85
+ |-----------|-----------|-----------|
86
+ | `route()` | 0.014ms | 0.05ms |
87
+ | `speculate()` | 0.003ms | 0.007ms |
88
+ | `personalizeTree()` | 0.026ms | 0.109ms |
89
+
90
+ Sub-millisecond routing on every request.
91
+
92
+ ## Edge Side Inference (ESI)
93
+
94
+ Bring AI to any component at render time. Like Varnish ESI, but for inference.
95
+
96
+ ```tsx
97
+ import { ESI, ESIProvider } from '@affectively/aeon-pages-runtime/router';
98
+
99
+ // Basic inference
100
+ <ESI.Infer model="llm" cache={300}>
101
+ Summarize this page for {user.name}
102
+ </ESI.Infer>
103
+
104
+ // Structured output with Zod
105
+ <ESI.Structured schema={z.object({ sentiment: z.enum(['positive', 'negative']) })}>
106
+ Analyze: {userMessage}
107
+ </ESI.Structured>
108
+
109
+ // Conditional rendering
110
+ <ESI.If
111
+ prompt="Should we show a discount?"
112
+ schema={z.object({ show: z.boolean() })}
113
+ when={(r) => r.show}
114
+ >
115
+ <DiscountBanner />
116
+ </ESI.If>
117
+
118
+ // Presence-aware (adapts for multiple viewers)
119
+ <ESI.Collaborative schema={summarySchema}>
120
+ Summarize for {presence.length} viewers: {content}
121
+ </ESI.Collaborative>
122
+
123
+ // Self-optimizing (improves when user is alone)
124
+ <ESI.Optimize
125
+ schema={contentSchema}
126
+ maxIterations={3}
127
+ goal="Improve clarity and engagement"
128
+ >
129
+ {draftContent}
130
+ </ESI.Optimize>
131
+ ```
132
+
133
+ ## Zero-Dependency Rendering
134
+
135
+ Every page is a **completely self-contained HTML document**. No external requests needed.
136
+
137
+ ```
138
+ Single Request → Instant Render
139
+ ├── Inline CSS (tree-shaken, only used classes)
140
+ ├── Inline assets (SVG, images as data URIs)
141
+ ├── Inline fonts (@font-face with embedded data)
142
+ ├── Minimal hydration script (lazy, on visibility)
143
+ └── WASM-rendered at edge (~7ms total)
144
+ ```
145
+
146
+ ### Multi-Layer Caching
147
+
148
+ ```
149
+ Request → KV Cache (1ms) → D1 Cache (5ms) → Session Render (50ms)
150
+ ↓ ↓ ↓
151
+ Return HTML Cache to KV Cache to KV + D1
152
+ ```
153
+
154
+ All pages are pre-rendered at build time and cached. First request for any route is a cache hit.
155
+
156
+ ### Speculative Pre-Rendering
157
+
158
+ Pages are pre-rendered **before the user clicks**, based on:
159
+ - Link visibility (IntersectionObserver)
160
+ - Hover intent
161
+ - Navigation prediction (Markov chain, community patterns)
162
+ - Browser Speculation Rules API
163
+
164
+ ```typescript
165
+ import { initSpeculativeRendering } from '@affectively/aeon-pages-runtime';
166
+
167
+ // Enable instant navigation
168
+ initSpeculativeRendering({
169
+ maxCachedPages: 5,
170
+ prerenderOnHover: true,
171
+ });
172
+ ```
173
+
174
+ ### Performance
175
+
176
+ | Metric | Before | After |
177
+ |--------|--------|-------|
178
+ | Requests | 15-30 | 1 |
179
+ | Total bytes | ~655KB | ~110KB |
180
+ | TTFB | 100ms | 50ms |
181
+ | First Paint | 500ms | <100ms |
182
+ | Time to Interactive | 2000ms | <300ms |
183
+ | CLS | 0.05 | 0 |
184
+
185
+ ## GitHub PR Publishing
186
+
187
+ Visual edits compile to TSX and create PRs automatically.
188
+
189
+ ```
190
+ Edit in browser → Durable Object → "Publish" → TSX → PR → CI deploys
191
+ ```
192
+
193
+ ### WebSocket Commands
194
+
195
+ ```javascript
196
+ const ws = new WebSocket('wss://aeon-flux.taylorbuley.workers.dev/session/my-page?userId=user1');
197
+
198
+ // Publish current tree → creates PR
199
+ ws.send(JSON.stringify({ type: 'publish' }));
200
+ // Response: { type: 'publish', payload: { prNumber: 123, autoMerged: false } }
201
+
202
+ // Merge a PR
203
+ ws.send(JSON.stringify({ type: 'merge', payload: { prNumber: 123 } }));
204
+ ```
205
+
206
+ ### Generated TSX
207
+
208
+ ```tsx
209
+ 'use aeon';
210
+
211
+ /**
212
+ * AboutPage
213
+ * Route: /about
214
+ *
215
+ * @generated by aeon-flux visual editor
216
+ */
217
+
218
+ import type { FC } from 'react';
219
+ import { Hero } from '@/components/Hero';
220
+ import { Section } from '@/components/Section';
221
+
222
+ const AboutPage: FC = () => {
223
+ return (
224
+ <Page>
225
+ <Hero title="About Us" />
226
+ <Section>
227
+ <Text>We believe in...</Text>
228
+ </Section>
229
+ </Page>
230
+ );
231
+ };
232
+
233
+ export default AboutPage;
234
+ ```
235
+
236
+ ### Configuration
237
+
238
+ ```toml
239
+ # wrangler.toml
240
+ [vars]
241
+ GITHUB_REPO = "owner/repo"
242
+ GITHUB_TREE_PATH = "apps/web/pages"
243
+ GITHUB_BASE_BRANCH = "staging"
244
+ GITHUB_DEV_BRANCH = "development"
245
+ GITHUB_AUTO_MERGE = "false"
246
+ ```
247
+
248
+ ## Production Architecture
249
+
250
+ ```
251
+ ┌─────────────────────────────────────────────────────────────────────────┐
252
+ │ AEON FLUX ARCHITECTURE │
253
+ ├─────────────────────────────────────────────────────────────────────────┤
254
+ │ │
255
+ │ Request + User Context │
256
+ │ │ │
257
+ │ ▼ │
258
+ │ ┌──────────────────┐ ┌──────────────────┐ │
259
+ │ │ HeuristicAdapter │ │ ESI │ │
260
+ │ │ (personalize) │ │ (inference) │ │
261
+ │ └────────┬─────────┘ └────────┬─────────┘ │
262
+ │ │ │ │
263
+ │ ▼ ▼ │
264
+ │ ┌──────────────────────────────────────────────────┐ │
265
+ │ │ Durable Objects │ │
266
+ │ │ (sessions, presence, real-time sync) │ │
267
+ │ └──────────────────────────────────────────────────┘ │
268
+ │ │ │ │
269
+ │ ▼ ▼ │
270
+ │ ┌──────────────┐ ┌──────────────┐ │
271
+ │ │ D1 │ │ GitHub │ │
272
+ │ │ (storage) │ │ (publish) │ │
273
+ │ └──────────────┘ └──────────────┘ │
274
+ │ │
275
+ │ Edit visually → Sync via DO → Publish → TSX → PR → Deploy │
276
+ │ │
277
+ └─────────────────────────────────────────────────────────────────────────┘
278
+ ```
279
+
280
+ ## Local-First with Dash
281
+
282
+ Data lives client-side. No backend services needed.
283
+
284
+ ```typescript
285
+ import { DashStorageAdapter } from '@affectively/aeon-pages-runtime';
286
+
287
+ const storage = new DashStorageAdapter(dashClient, {
288
+ routesCollection: 'aeon-routes',
289
+ sessionsCollection: 'aeon-sessions',
290
+ });
291
+
292
+ // Reads: instant (local)
293
+ // Writes: instant (local), syncs via DO
294
+ // Collab: Durable Objects
295
+ // AI: ESI at edge
296
+ ```
297
+
298
+ ## Speculation & Prefetching
299
+
300
+ Predict and prefetch likely next pages.
301
+
302
+ ```typescript
303
+ import { SpeculationManager, createSpeculationHook } from '@affectively/aeon-pages-runtime/router';
304
+
305
+ // Client-side
306
+ const manager = new SpeculationManager({
307
+ maxPrefetch: 5,
308
+ hoverDelay: 100,
309
+ });
310
+
311
+ // Prefetch on hover
312
+ manager.observeLinks();
313
+
314
+ // Or use the hook
315
+ const useSpeculation = createSpeculationHook(manager);
316
+ ```
317
+
318
+ Supports [Speculation Rules API](https://developer.chrome.com/docs/web-platform/prerender-pages) with link prefetch fallback.
319
+
320
+ ## The `'use aeon'` Directive
321
+
322
+ ```tsx
323
+ 'use aeon';
324
+
325
+ export default function Page() {
326
+ const {
327
+ presence, // Who's viewing/editing
328
+ sync, // Sync state and controls
329
+ version, // Schema version info
330
+ data, // Collaborative data store
331
+ setData, // Update data
332
+ } = useAeonPage();
333
+
334
+ return <div>...</div>;
335
+ }
336
+ ```
337
+
338
+ ## Hooks
339
+
340
+ | Hook | Purpose |
341
+ |------|---------|
342
+ | `useAeonPage()` | Full page context |
343
+ | `usePresence()` | Who's here, cursors, editing |
344
+ | `useAeonData<T>(key)` | Collaborative typed data |
345
+ | `useCollaborativeInput(key)` | Ready-to-use collab input |
346
+ | `useOfflineStatus()` | Offline awareness |
347
+ | `useESI()` | ESI context |
348
+ | `useESIInfer()` | Programmatic inference |
349
+
350
+ ## Configuration
351
+
352
+ ```typescript
353
+ // aeon.config.ts
354
+ export default {
355
+ pagesDir: './pages',
356
+ runtime: 'cloudflare',
357
+
358
+ router: {
359
+ adapter: 'heuristic',
360
+ speculation: {
361
+ enabled: true,
362
+ depth: 2,
363
+ prerenderTop: 1,
364
+ },
365
+ personalization: {
366
+ featureGating: true,
367
+ emotionTheming: true,
368
+ componentOrdering: true,
369
+ },
370
+ },
371
+
372
+ esi: {
373
+ enabled: true,
374
+ endpoint: process.env.ESI_ENDPOINT,
375
+ timeout: 5000,
376
+ defaultCacheTtl: 300,
377
+ },
378
+
379
+ github: {
380
+ repo: 'owner/repo',
381
+ treePath: 'apps/web/pages',
382
+ baseBranch: 'staging',
383
+ devBranch: 'development',
384
+ autoMerge: false,
385
+ },
386
+ };
387
+ ```
388
+
389
+ ## Packages
390
+
391
+ | Package | Description |
392
+ |---------|-------------|
393
+ | `@affectively/aeon-pages-runtime` | Runtime (npm) |
394
+ | `@affectively/aeon-pages-runtime/router` | Personalized routing + ESI |
395
+ | `@affectively/aeon-pages-runtime/server` | Server utilities |
396
+
397
+ ## Deploy Your Own
398
+
399
+ ```bash
400
+ cd packages/runtime
401
+
402
+ # Deploy worker
403
+ wrangler deploy
404
+
405
+ # Set GitHub token
406
+ wrangler secret put GITHUB_TOKEN
407
+
408
+ # Create D1 database (optional)
409
+ wrangler d1 create aeon-flux
410
+ wrangler d1 execute aeon-flux --file=./schema.sql
411
+ ```
412
+
413
+ ## vs Next.js
414
+
415
+ | | Next.js | Aeon Flux |
416
+ |-|---------|-----------|
417
+ | **Runtime** | ~500KB | ~187KB |
418
+ | **Routing** | Static | Personalized |
419
+ | **AI** | Add-on | ESI built-in |
420
+ | **CMS** | Separate | It IS the site |
421
+ | **Collaboration** | Add-on | Built-in |
422
+ | **Publish** | Manual | Visual → PR |
423
+ | **Edge** | Partial | Full |
424
+
425
+ ## Requirements
426
+
427
+ - Bun >= 1.0.0
428
+ - React >= 18.0.0
429
+ - Zod >= 3.0.0
430
+ - Cloudflare account (for production)
431
+
432
+ ## License
433
+
434
+ MIT
435
+
436
+ ---
437
+
438
+ *The CMS is the website. The website comes to the person. Visual edits become code.*
@@ -0,0 +1,39 @@
1
+ import type { AeonConfig } from '@affectively/aeon-pages';
2
+
3
+ export default {
4
+ pagesDir: './pages',
5
+ componentsDir: './components',
6
+ runtime: 'bun',
7
+ port: 3000,
8
+
9
+ aeon: {
10
+ sync: {
11
+ mode: 'distributed',
12
+ replicationFactor: 3,
13
+ consistencyLevel: 'strong',
14
+ },
15
+ versioning: {
16
+ enabled: true,
17
+ autoMigrate: true,
18
+ },
19
+ presence: {
20
+ enabled: true,
21
+ cursorTracking: true,
22
+ inactivityTimeout: 60000,
23
+ },
24
+ offline: {
25
+ enabled: true,
26
+ maxQueueSize: 1000,
27
+ },
28
+ },
29
+
30
+ components: {
31
+ autoDiscover: true,
32
+ },
33
+
34
+ nextCompat: true,
35
+
36
+ output: {
37
+ dir: '.aeon',
38
+ },
39
+ } satisfies AeonConfig;
@@ -0,0 +1,88 @@
1
+ import type { PresenceUser } from '@affectively/aeon-pages/react';
2
+
3
+ interface CursorProps {
4
+ user: PresenceUser;
5
+ }
6
+
7
+ // Generate a color from user ID
8
+ function userColor(userId: string): string {
9
+ const colors = [
10
+ '#ef4444', // red
11
+ '#f97316', // orange
12
+ '#eab308', // yellow
13
+ '#22c55e', // green
14
+ '#06b6d4', // cyan
15
+ '#3b82f6', // blue
16
+ '#8b5cf6', // violet
17
+ '#ec4899', // pink
18
+ ];
19
+ let hash = 0;
20
+ for (let i = 0; i < userId.length; i++) {
21
+ hash = ((hash << 5) - hash) + userId.charCodeAt(i);
22
+ hash = hash & hash;
23
+ }
24
+ return colors[Math.abs(hash) % colors.length];
25
+ }
26
+
27
+ export function Cursor({ user }: CursorProps) {
28
+ if (!user.cursor || user.status === 'offline') {
29
+ return null;
30
+ }
31
+
32
+ const color = userColor(user.userId);
33
+
34
+ return (
35
+ <div
36
+ style={{
37
+ position: 'fixed',
38
+ left: user.cursor.x,
39
+ top: user.cursor.y,
40
+ pointerEvents: 'none',
41
+ zIndex: 9999,
42
+ transition: 'left 0.1s ease-out, top 0.1s ease-out',
43
+ }}
44
+ >
45
+ {/* Cursor arrow */}
46
+ <svg
47
+ width="24"
48
+ height="24"
49
+ viewBox="0 0 24 24"
50
+ fill="none"
51
+ style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }}
52
+ >
53
+ <path
54
+ d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87c.48 0 .72-.58.38-.92L5.92 2.53a.5.5 0 0 0-.42.68z"
55
+ fill={color}
56
+ stroke="white"
57
+ strokeWidth="1.5"
58
+ />
59
+ </svg>
60
+
61
+ {/* User label */}
62
+ <div
63
+ style={{
64
+ marginLeft: '16px',
65
+ marginTop: '-4px',
66
+ padding: '2px 8px',
67
+ backgroundColor: color,
68
+ color: 'white',
69
+ fontSize: '12px',
70
+ fontWeight: 500,
71
+ borderRadius: '4px',
72
+ whiteSpace: 'nowrap',
73
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
74
+ }}
75
+ >
76
+ {user.role === 'assistant' ? '🤖 ' : ''}
77
+ {user.userId.slice(0, 8)}
78
+ {user.editing && (
79
+ <span style={{ marginLeft: '4px', opacity: 0.8 }}>
80
+ editing
81
+ </span>
82
+ )}
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ export default Cursor;
@@ -0,0 +1,93 @@
1
+ import { useOfflineStatus } from '@affectively/aeon-pages/react';
2
+
3
+ export function OfflineIndicator() {
4
+ const { isOffline, isSyncing, pendingOperations, lastSyncAt } = useOfflineStatus();
5
+
6
+ if (!isOffline && !isSyncing && pendingOperations === 0) {
7
+ return null;
8
+ }
9
+
10
+ return (
11
+ <div className="fixed bottom-4 right-4 flex flex-col gap-2">
12
+ {/* Offline banner */}
13
+ {isOffline && (
14
+ <div className="flex items-center gap-2 bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-2 shadow-lg">
15
+ <svg
16
+ className="w-5 h-5 text-yellow-500"
17
+ fill="none"
18
+ viewBox="0 0 24 24"
19
+ stroke="currentColor"
20
+ >
21
+ <path
22
+ strokeLinecap="round"
23
+ strokeLinejoin="round"
24
+ strokeWidth={2}
25
+ d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3"
26
+ />
27
+ </svg>
28
+ <div>
29
+ <p className="text-sm font-medium text-yellow-800">You're offline</p>
30
+ <p className="text-xs text-yellow-600">Changes will sync when back online</p>
31
+ </div>
32
+ </div>
33
+ )}
34
+
35
+ {/* Pending operations */}
36
+ {pendingOperations > 0 && (
37
+ <div className="flex items-center gap-2 bg-blue-50 border border-blue-200 rounded-lg px-4 py-2 shadow-lg">
38
+ <div className="w-5 h-5">
39
+ <svg className="animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
40
+ <circle
41
+ className="opacity-25"
42
+ cx="12"
43
+ cy="12"
44
+ r="10"
45
+ stroke="currentColor"
46
+ strokeWidth="4"
47
+ />
48
+ <path
49
+ className="opacity-75"
50
+ fill="currentColor"
51
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
52
+ />
53
+ </svg>
54
+ </div>
55
+ <div>
56
+ <p className="text-sm font-medium text-blue-800">
57
+ {isSyncing ? 'Syncing...' : `${pendingOperations} pending`}
58
+ </p>
59
+ <p className="text-xs text-blue-600">
60
+ {pendingOperations} {pendingOperations === 1 ? 'change' : 'changes'} to sync
61
+ </p>
62
+ </div>
63
+ </div>
64
+ )}
65
+
66
+ {/* Last sync time */}
67
+ {lastSyncAt && !isOffline && pendingOperations === 0 && (
68
+ <div className="text-xs text-gray-400 text-right">
69
+ Last synced: {formatRelativeTime(lastSyncAt)}
70
+ </div>
71
+ )}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ function formatRelativeTime(isoString: string): string {
77
+ const date = new Date(isoString);
78
+ const now = new Date();
79
+ const diff = now.getTime() - date.getTime();
80
+
81
+ const seconds = Math.floor(diff / 1000);
82
+ const minutes = Math.floor(seconds / 60);
83
+ const hours = Math.floor(minutes / 60);
84
+
85
+ if (seconds < 10) return 'just now';
86
+ if (seconds < 60) return `${seconds}s ago`;
87
+ if (minutes < 60) return `${minutes}m ago`;
88
+ if (hours < 24) return `${hours}h ago`;
89
+
90
+ return date.toLocaleDateString();
91
+ }
92
+
93
+ export default OfflineIndicator;