Skip to content

Commit ed182ac

Browse files
authored
Merge pull request #223 from objectstack-ai/copilot/add-offline-sync-and-polish-features
2 parents 8ed7944 + 9f70da4 commit ed182ac

30 files changed

Lines changed: 1588 additions & 53 deletions

apps/web/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<meta name="theme-color" content="#1a1a2e" />
8+
<meta name="description" content="ObjectOS — Business Operating System Admin Console" />
9+
<link rel="manifest" href="%BASE_URL%manifest.json" />
710
<title>ObjectOS Console</title>
811
</head>
912
<body class="antialiased">

apps/web/public/manifest.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "ObjectOS Console",
3+
"short_name": "ObjectOS",
4+
"description": "Business Operating System — Admin Console",
5+
"start_url": "/console/",
6+
"scope": "/console/",
7+
"display": "standalone",
8+
"background_color": "#ffffff",
9+
"theme_color": "#1a1a2e",
10+
"icons": [
11+
{
12+
"src": "favicon.svg",
13+
"sizes": "any",
14+
"type": "image/svg+xml"
15+
}
16+
]
17+
}

apps/web/public/sw.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* ObjectOS Service Worker — Offline-first PWA support.
3+
*
4+
* Strategy:
5+
* - Static assets → Cache-first (install-time precache)
6+
* - API calls → Network-first with cache fallback
7+
* - Navigation → Network-first, fall back to cached shell
8+
*/
9+
10+
const CACHE_NAME = 'objectos-v1';
11+
const STATIC_ASSETS = ['/console/', '/console/index.html'];
12+
13+
// ── Install: pre-cache the app shell ────────────────────────────
14+
self.addEventListener('install', (event) => {
15+
event.waitUntil(
16+
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
17+
);
18+
self.skipWaiting();
19+
});
20+
21+
// ── Activate: clean old caches ──────────────────────────────────
22+
self.addEventListener('activate', (event) => {
23+
event.waitUntil(
24+
caches.keys().then((keys) =>
25+
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))),
26+
),
27+
);
28+
self.clients.claim();
29+
});
30+
31+
// ── Fetch: strategy router ──────────────────────────────────────
32+
self.addEventListener('fetch', (event) => {
33+
const { request } = event;
34+
const url = new URL(request.url);
35+
36+
// Skip non-GET requests
37+
if (request.method !== 'GET') return;
38+
39+
// API requests → network-first
40+
if (url.pathname.startsWith('/api/')) {
41+
event.respondWith(networkFirst(request));
42+
return;
43+
}
44+
45+
// Static assets & navigation → cache-first
46+
event.respondWith(cacheFirst(request));
47+
});
48+
49+
async function cacheFirst(request) {
50+
const cached = await caches.match(request);
51+
if (cached) return cached;
52+
try {
53+
const response = await fetch(request);
54+
if (response.ok) {
55+
const cache = await caches.open(CACHE_NAME);
56+
cache.put(request, response.clone());
57+
}
58+
return response;
59+
} catch {
60+
// For navigation requests, fall back to cached shell
61+
if (request.mode === 'navigate') {
62+
const shell = await caches.match('/console/index.html');
63+
if (shell) return shell;
64+
}
65+
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
66+
}
67+
}
68+
69+
async function networkFirst(request) {
70+
try {
71+
const response = await fetch(request);
72+
if (response.ok) {
73+
const cache = await caches.open(CACHE_NAME);
74+
cache.put(request, response.clone());
75+
}
76+
return response;
77+
} catch {
78+
const cached = await caches.match(request);
79+
if (cached) return cached;
80+
return new Response(JSON.stringify({ error: 'offline' }), {
81+
status: 503,
82+
headers: { 'Content-Type': 'application/json' },
83+
});
84+
}
85+
}
86+
87+
// ── Message handler for sync operations ─────────────────────────
88+
self.addEventListener('message', (event) => {
89+
if (event.data?.type === 'SKIP_WAITING') {
90+
self.skipWaiting();
91+
}
92+
});

apps/web/src/App.tsx

Lines changed: 50 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ProtectedRoute } from './components/auth/ProtectedRoute';
44
import { RequireOrgAdmin } from './components/auth/RequireOrgAdmin';
55
import { SettingsLayout } from './components/layouts/SettingsLayout';
66
import { AppLayout } from './components/layouts/AppLayout';
7+
import { SkipLink } from './components/ui/skip-link';
78

89
// ── Public pages ──────────────────────────────────────────────
910
const HomePage = lazy(() => import('./pages/home'));
@@ -44,55 +45,60 @@ export function App() {
4445
);
4546

4647
return (
47-
<Suspense fallback={fallback}>
48-
<Routes>
49-
{/* Public routes */}
50-
<Route path="/" element={<HomePage />} />
51-
<Route path="/sign-in" element={<SignInPage />} />
52-
<Route path="/sign-up" element={<SignUpPage />} />
53-
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
54-
<Route path="/reset-password" element={<ResetPasswordPage />} />
55-
<Route path="/verify-2fa" element={<Verify2FAPage />} />
48+
<>
49+
<SkipLink />
50+
<main id="main-content">
51+
<Suspense fallback={fallback}>
52+
<Routes>
53+
{/* Public routes */}
54+
<Route path="/" element={<HomePage />} />
55+
<Route path="/sign-in" element={<SignInPage />} />
56+
<Route path="/sign-up" element={<SignUpPage />} />
57+
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
58+
<Route path="/reset-password" element={<ResetPasswordPage />} />
59+
<Route path="/verify-2fa" element={<Verify2FAPage />} />
5660

57-
{/* Protected routes */}
58-
<Route element={<ProtectedRoute />}>
61+
{/* Protected routes */}
62+
<Route element={<ProtectedRoute />}>
5963

60-
{/* ── Create Org (accessible to any authenticated user) ── */}
61-
<Route path="/settings/organization/create" element={<CreateOrganizationPage />} />
64+
{/* ── Create Org (accessible to any authenticated user) ── */}
65+
<Route path="/settings/organization/create" element={<CreateOrganizationPage />} />
6266

63-
{/* ── Admin Console (/settings/*) — owner / admin only ── */}
64-
<Route element={<RequireOrgAdmin />}>
65-
<Route element={<SettingsLayout />}>
66-
<Route path="/settings" element={<SettingsOverviewPage />} />
67-
<Route path="/settings/organization" element={<OrganizationSettingsPage />} />
68-
<Route path="/settings/members" element={<MembersPage />} />
69-
<Route path="/settings/teams" element={<TeamsPage />} />
70-
<Route path="/settings/invitations" element={<InvitationsPage />} />
71-
<Route path="/settings/permissions" element={<PermissionsPage />} />
72-
<Route path="/settings/sso" element={<SSOSettingsPage />} />
73-
<Route path="/settings/audit" element={<AuditPage />} />
74-
<Route path="/settings/packages" element={<PackagesPage />} />
75-
<Route path="/settings/jobs" element={<JobsPage />} />
76-
<Route path="/settings/plugins" element={<PluginsPage />} />
77-
<Route path="/settings/metrics" element={<MetricsPage />} />
78-
<Route path="/settings/notifications" element={<NotificationsPage />} />
79-
<Route path="/settings/account" element={<AccountSettingsPage />} />
80-
<Route path="/settings/security" element={<SecuritySettingsPage />} />
81-
</Route>
82-
</Route>
67+
{/* ── Admin Console (/settings/*) — owner / admin only ── */}
68+
<Route element={<RequireOrgAdmin />}>
69+
<Route element={<SettingsLayout />}>
70+
<Route path="/settings" element={<SettingsOverviewPage />} />
71+
<Route path="/settings/organization" element={<OrganizationSettingsPage />} />
72+
<Route path="/settings/members" element={<MembersPage />} />
73+
<Route path="/settings/teams" element={<TeamsPage />} />
74+
<Route path="/settings/invitations" element={<InvitationsPage />} />
75+
<Route path="/settings/permissions" element={<PermissionsPage />} />
76+
<Route path="/settings/sso" element={<SSOSettingsPage />} />
77+
<Route path="/settings/audit" element={<AuditPage />} />
78+
<Route path="/settings/packages" element={<PackagesPage />} />
79+
<Route path="/settings/jobs" element={<JobsPage />} />
80+
<Route path="/settings/plugins" element={<PluginsPage />} />
81+
<Route path="/settings/metrics" element={<MetricsPage />} />
82+
<Route path="/settings/notifications" element={<NotificationsPage />} />
83+
<Route path="/settings/account" element={<AccountSettingsPage />} />
84+
<Route path="/settings/security" element={<SecuritySettingsPage />} />
85+
</Route>
86+
</Route>
8387

84-
{/* ── Business Apps (/apps/:appId/*) ── */}
85-
<Route path="/apps/:appId" element={<AppLayout />}>
86-
<Route index element={<BusinessAppPage />} />
87-
<Route path=":objectName" element={<ObjectListPage />} />
88-
<Route path=":objectName/:recordId" element={<ObjectRecordPage />} />
89-
</Route>
88+
{/* ── Business Apps (/apps/:appId/*) ── */}
89+
<Route path="/apps/:appId" element={<AppLayout />}>
90+
<Route index element={<BusinessAppPage />} />
91+
<Route path=":objectName" element={<ObjectListPage />} />
92+
<Route path=":objectName/:recordId" element={<ObjectRecordPage />} />
93+
</Route>
9094

91-
</Route>
95+
</Route>
9296

93-
{/* Fallback */}
94-
<Route path="*" element={<Navigate to="/" replace />} />
95-
</Routes>
96-
</Suspense>
97+
{/* Fallback */}
98+
<Route path="*" element={<Navigate to="/" replace />} />
99+
</Routes>
100+
</Suspense>
101+
</main>
102+
</>
97103
);
98104
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Tests for UI components (Phase 6).
3+
*/
4+
import { describe, it, expect } from 'vitest';
5+
import { ThemeToggle } from '@/components/ui/theme-toggle';
6+
import { SkipLink } from '@/components/ui/skip-link';
7+
8+
describe('Phase 6 UI component exports', () => {
9+
it('exports ThemeToggle', () => {
10+
expect(ThemeToggle).toBeTypeOf('function');
11+
});
12+
13+
it('exports SkipLink', () => {
14+
expect(SkipLink).toBeTypeOf('function');
15+
});
16+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Tests for sync components.
3+
*/
4+
import { describe, it, expect } from 'vitest';
5+
import { OfflineIndicator, ConflictResolutionDialog } from '@/components/sync';
6+
7+
describe('sync component exports', () => {
8+
it('exports OfflineIndicator', () => {
9+
expect(OfflineIndicator).toBeTypeOf('function');
10+
});
11+
12+
it('exports ConflictResolutionDialog', () => {
13+
expect(ConflictResolutionDialog).toBeTypeOf('function');
14+
});
15+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Tests for use-i18n hook.
3+
*/
4+
import { describe, it, expect } from 'vitest';
5+
import { I18nProvider, useI18n } from '@/hooks/use-i18n';
6+
7+
describe('use-i18n exports', () => {
8+
it('exports I18nProvider as a function', () => {
9+
expect(I18nProvider).toBeTypeOf('function');
10+
});
11+
12+
it('exports useI18n as a function', () => {
13+
expect(useI18n).toBeTypeOf('function');
14+
});
15+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Tests for use-keyboard-shortcuts hook.
3+
*/
4+
import { describe, it, expect } from 'vitest';
5+
import { useKeyboardShortcuts, SHORTCUT_PRESETS } from '@/hooks/use-keyboard-shortcuts';
6+
7+
describe('useKeyboardShortcuts', () => {
8+
it('exports useKeyboardShortcuts as a function', () => {
9+
expect(useKeyboardShortcuts).toBeTypeOf('function');
10+
});
11+
12+
it('exports SHORTCUT_PRESETS with expected keys', () => {
13+
expect(SHORTCUT_PRESETS).toBeDefined();
14+
expect(SHORTCUT_PRESETS.search).toEqual({ key: 'k', ctrl: true, description: 'Open search' });
15+
expect(SHORTCUT_PRESETS.save).toEqual({ key: 's', ctrl: true, description: 'Save' });
16+
expect(SHORTCUT_PRESETS.escape).toEqual({ key: 'Escape', description: 'Close / Cancel' });
17+
expect(SHORTCUT_PRESETS.newRecord).toEqual({ key: 'n', ctrl: true, shift: true, description: 'New record' });
18+
expect(SHORTCUT_PRESETS.goHome).toEqual({ key: 'h', ctrl: true, shift: true, description: 'Go home' });
19+
expect(SHORTCUT_PRESETS.goSettings).toEqual({ key: ',', ctrl: true, description: 'Open settings' });
20+
expect(SHORTCUT_PRESETS.help).toEqual({ key: '?', shift: true, description: 'Show keyboard shortcuts' });
21+
});
22+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Tests for use-offline hook.
3+
*/
4+
import { describe, it, expect } from 'vitest';
5+
import { useOfflineStatus } from '@/hooks/use-offline';
6+
7+
describe('useOfflineStatus', () => {
8+
it('exports useOfflineStatus as a function', () => {
9+
expect(useOfflineStatus).toBeTypeOf('function');
10+
});
11+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Tests for use-sync hook.
3+
*/
4+
import { describe, it, expect } from 'vitest';
5+
import { useSyncEngine } from '@/hooks/use-sync';
6+
7+
describe('useSyncEngine', () => {
8+
it('exports useSyncEngine as a function', () => {
9+
expect(useSyncEngine).toBeTypeOf('function');
10+
});
11+
});

0 commit comments

Comments
 (0)