mvc-kit 2.12.5 → 2.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BEST_PRACTICES.md +1390 -0
- package/agent-config/bin/postinstall.mjs +4 -3
- package/agent-config/bin/setup.mjs +5 -1
- package/agent-config/claude-code/agents/mvc-kit-architect.md +16 -8
- package/agent-config/claude-code/skills/guide/SKILL.md +29 -7
- package/agent-config/claude-code/skills/guide/patterns.md +12 -0
- package/agent-config/claude-code/skills/guide/recipes.md +510 -0
- package/agent-config/claude-code/skills/guide/testing.md +297 -0
- package/agent-config/claude-code/skills/review/SKILL.md +3 -13
- package/agent-config/claude-code/skills/review/checklist.md +30 -5
- package/agent-config/claude-code/skills/scaffold/SKILL.md +4 -13
- package/agent-config/lib/install-claude.mjs +90 -25
- package/dist/Channel.cjs +276 -300
- package/dist/Channel.cjs.map +1 -1
- package/dist/Channel.js +275 -299
- package/dist/Channel.js.map +1 -1
- package/dist/Collection.cjs +424 -504
- package/dist/Collection.cjs.map +1 -1
- package/dist/Collection.js +423 -503
- package/dist/Collection.js.map +1 -1
- package/dist/Controller.cjs +70 -67
- package/dist/Controller.cjs.map +1 -1
- package/dist/Controller.js +69 -66
- package/dist/Controller.js.map +1 -1
- package/dist/EventBus.cjs +77 -88
- package/dist/EventBus.cjs.map +1 -1
- package/dist/EventBus.js +76 -87
- package/dist/EventBus.js.map +1 -1
- package/dist/Feed.cjs +81 -77
- package/dist/Feed.cjs.map +1 -1
- package/dist/Feed.js +80 -76
- package/dist/Feed.js.map +1 -1
- package/dist/Model.cjs +181 -207
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.js +179 -205
- package/dist/Model.js.map +1 -1
- package/dist/Pagination.cjs +75 -73
- package/dist/Pagination.cjs.map +1 -1
- package/dist/Pagination.js +74 -72
- package/dist/Pagination.js.map +1 -1
- package/dist/Pending.cjs +255 -287
- package/dist/Pending.cjs.map +1 -1
- package/dist/Pending.js +253 -285
- package/dist/Pending.js.map +1 -1
- package/dist/PersistentCollection.cjs +242 -285
- package/dist/PersistentCollection.cjs.map +1 -1
- package/dist/PersistentCollection.js +241 -284
- package/dist/PersistentCollection.js.map +1 -1
- package/dist/Resource.cjs +166 -174
- package/dist/Resource.cjs.map +1 -1
- package/dist/Resource.js +164 -172
- package/dist/Resource.js.map +1 -1
- package/dist/Selection.cjs +84 -94
- package/dist/Selection.cjs.map +1 -1
- package/dist/Selection.js +83 -93
- package/dist/Selection.js.map +1 -1
- package/dist/Service.cjs +54 -55
- package/dist/Service.cjs.map +1 -1
- package/dist/Service.js +53 -54
- package/dist/Service.js.map +1 -1
- package/dist/Sorting.cjs +102 -101
- package/dist/Sorting.cjs.map +1 -1
- package/dist/Sorting.js +102 -101
- package/dist/Sorting.js.map +1 -1
- package/dist/Trackable.cjs +112 -80
- package/dist/Trackable.cjs.map +1 -1
- package/dist/Trackable.js +111 -79
- package/dist/Trackable.js.map +1 -1
- package/dist/ViewModel.cjs +528 -576
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.js +525 -573
- package/dist/ViewModel.js.map +1 -1
- package/dist/bindPublicMethods.cjs +43 -24
- package/dist/bindPublicMethods.cjs.map +1 -1
- package/dist/bindPublicMethods.js +43 -24
- package/dist/bindPublicMethods.js.map +1 -1
- package/dist/errors.cjs +67 -68
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.js +68 -71
- package/dist/errors.js.map +1 -1
- package/dist/mvc-kit.cjs +44 -46
- package/dist/mvc-kit.js +5 -32
- package/dist/produceDraft.cjs +105 -95
- package/dist/produceDraft.cjs.map +1 -1
- package/dist/produceDraft.js +106 -97
- package/dist/produceDraft.js.map +1 -1
- package/dist/react/components/CardList.cjs +30 -40
- package/dist/react/components/CardList.cjs.map +1 -1
- package/dist/react/components/CardList.js +31 -41
- package/dist/react/components/CardList.js.map +1 -1
- package/dist/react/components/DataTable.cjs +146 -169
- package/dist/react/components/DataTable.cjs.map +1 -1
- package/dist/react/components/DataTable.js +147 -170
- package/dist/react/components/DataTable.js.map +1 -1
- package/dist/react/components/InfiniteScroll.cjs +51 -42
- package/dist/react/components/InfiniteScroll.cjs.map +1 -1
- package/dist/react/components/InfiniteScroll.js +52 -43
- package/dist/react/components/InfiniteScroll.js.map +1 -1
- package/dist/react/components/types.cjs +10 -6
- package/dist/react/components/types.cjs.map +1 -1
- package/dist/react/components/types.js +11 -9
- package/dist/react/components/types.js.map +1 -1
- package/dist/react/guards.cjs +10 -6
- package/dist/react/guards.cjs.map +1 -1
- package/dist/react/guards.js +11 -9
- package/dist/react/guards.js.map +1 -1
- package/dist/react/provider.cjs +23 -20
- package/dist/react/provider.cjs.map +1 -1
- package/dist/react/provider.js +23 -21
- package/dist/react/provider.js.map +1 -1
- package/dist/react/use-event-bus.cjs +24 -20
- package/dist/react/use-event-bus.cjs.map +1 -1
- package/dist/react/use-event-bus.js +24 -21
- package/dist/react/use-event-bus.js.map +1 -1
- package/dist/react/use-instance.cjs +43 -36
- package/dist/react/use-instance.cjs.map +1 -1
- package/dist/react/use-instance.js +43 -36
- package/dist/react/use-instance.js.map +1 -1
- package/dist/react/use-local.cjs +48 -64
- package/dist/react/use-local.cjs.map +1 -1
- package/dist/react/use-local.js +47 -63
- package/dist/react/use-local.js.map +1 -1
- package/dist/react/use-model.cjs +84 -98
- package/dist/react/use-model.cjs.map +1 -1
- package/dist/react/use-model.js +84 -100
- package/dist/react/use-model.js.map +1 -1
- package/dist/react/use-singleton.cjs +19 -23
- package/dist/react/use-singleton.cjs.map +1 -1
- package/dist/react/use-singleton.js +16 -20
- package/dist/react/use-singleton.js.map +1 -1
- package/dist/react/use-subscribe-only.cjs +28 -22
- package/dist/react/use-subscribe-only.cjs.map +1 -1
- package/dist/react/use-subscribe-only.js +28 -22
- package/dist/react/use-subscribe-only.js.map +1 -1
- package/dist/react/use-teardown.cjs +20 -19
- package/dist/react/use-teardown.cjs.map +1 -1
- package/dist/react/use-teardown.js +20 -19
- package/dist/react/use-teardown.js.map +1 -1
- package/dist/react-native/NativeCollection.cjs +98 -78
- package/dist/react-native/NativeCollection.cjs.map +1 -1
- package/dist/react-native/NativeCollection.js +97 -77
- package/dist/react-native/NativeCollection.js.map +1 -1
- package/dist/react-native.cjs +2 -4
- package/dist/react-native.js +1 -4
- package/dist/react.cjs +24 -26
- package/dist/react.js +1 -17
- package/dist/singleton.cjs +28 -22
- package/dist/singleton.cjs.map +1 -1
- package/dist/singleton.js +29 -26
- package/dist/singleton.js.map +1 -1
- package/dist/walkPrototypeChain.cjs +20 -12
- package/dist/walkPrototypeChain.cjs.map +1 -1
- package/dist/walkPrototypeChain.js +21 -13
- package/dist/walkPrototypeChain.js.map +1 -1
- package/dist/web/IndexedDBCollection.cjs +53 -36
- package/dist/web/IndexedDBCollection.cjs.map +1 -1
- package/dist/web/IndexedDBCollection.js +52 -35
- package/dist/web/IndexedDBCollection.js.map +1 -1
- package/dist/web/WebStorageCollection.cjs +82 -84
- package/dist/web/WebStorageCollection.cjs.map +1 -1
- package/dist/web/WebStorageCollection.js +81 -83
- package/dist/web/WebStorageCollection.js.map +1 -1
- package/dist/web/idb.cjs +107 -99
- package/dist/web/idb.cjs.map +1 -1
- package/dist/web/idb.js +108 -105
- package/dist/web/idb.js.map +1 -1
- package/dist/web.cjs +4 -6
- package/dist/web.js +1 -5
- package/dist/wrapAsyncMethods.cjs +141 -168
- package/dist/wrapAsyncMethods.cjs.map +1 -1
- package/dist/wrapAsyncMethods.js +141 -168
- package/dist/wrapAsyncMethods.js.map +1 -1
- package/examples/primitive/channel.ts +109 -0
- package/examples/primitive/collection.ts +118 -0
- package/examples/primitive/controller.ts +118 -0
- package/examples/primitive/counter.ts +108 -0
- package/examples/primitive/env.d.ts +1 -0
- package/examples/primitive/eventbus.ts +77 -0
- package/examples/primitive/feed.ts +162 -0
- package/examples/primitive/model.ts +82 -0
- package/examples/primitive/pagination.ts +91 -0
- package/examples/primitive/pending.ts +189 -0
- package/examples/primitive/persistent-collection.ts +116 -0
- package/examples/primitive/resource.ts +114 -0
- package/examples/primitive/selection.ts +96 -0
- package/examples/primitive/sorting.ts +112 -0
- package/examples/primitive/timer.ts +58 -0
- package/examples/primitive/trackable.ts +225 -0
- package/examples/primitive/tsconfig.json +20 -0
- package/examples/primitive/viewmodel-service.ts +161 -0
- package/examples/react/AuthExample/index.html +12 -0
- package/examples/react/AuthExample/src/App.tsx +29 -0
- package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
- package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
- package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
- package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
- package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
- package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
- package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
- package/examples/react/AuthExample/src/env.d.ts +10 -0
- package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
- package/examples/react/AuthExample/src/main.tsx +10 -0
- package/examples/react/AuthExample/src/mock/api.ts +78 -0
- package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
- package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
- package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
- package/examples/react/AuthExample/src/styles.css +445 -0
- package/examples/react/AuthExample/src/types/auth.ts +12 -0
- package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
- package/examples/react/AuthExample/tsconfig.json +22 -0
- package/examples/react/AuthExample/vite.config.ts +18 -0
- package/examples/react/ComplexApp/index.html +12 -0
- package/examples/react/ComplexApp/src/App.tsx +17 -0
- package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
- package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
- package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
- package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
- package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
- package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
- package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
- package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
- package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
- package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
- package/examples/react/ComplexApp/src/env.d.ts +10 -0
- package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/ComplexApp/src/main.tsx +10 -0
- package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
- package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
- package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
- package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
- package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
- package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
- package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
- package/examples/react/ComplexApp/src/styles.css +463 -0
- package/examples/react/ComplexApp/src/types/activity.ts +8 -0
- package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
- package/examples/react/ComplexApp/src/types/social.ts +8 -0
- package/examples/react/ComplexApp/src/types/users.ts +6 -0
- package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
- package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
- package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
- package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
- package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/tsconfig.json +22 -0
- package/examples/react/ComplexApp/vite.config.ts +18 -0
- package/examples/react/FullApp/index.html +12 -0
- package/examples/react/FullApp/src/App.tsx +28 -0
- package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
- package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
- package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
- package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
- package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
- package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
- package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
- package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
- package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
- package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
- package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
- package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
- package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
- package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
- package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
- package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
- package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
- package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
- package/examples/react/FullApp/src/env.d.ts +10 -0
- package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/FullApp/src/main.tsx +10 -0
- package/examples/react/FullApp/src/mock/delay.ts +21 -0
- package/examples/react/FullApp/src/mock/locations.ts +76 -0
- package/examples/react/FullApp/src/mock/messages.ts +237 -0
- package/examples/react/FullApp/src/mock/users.ts +84 -0
- package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
- package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
- package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
- package/examples/react/FullApp/src/services/AuthService.ts +18 -0
- package/examples/react/FullApp/src/services/LocationService.ts +23 -0
- package/examples/react/FullApp/src/services/MessageService.ts +65 -0
- package/examples/react/FullApp/src/services/UserService.ts +23 -0
- package/examples/react/FullApp/src/styles.css +767 -0
- package/examples/react/FullApp/src/types/conversation.ts +7 -0
- package/examples/react/FullApp/src/types/location.ts +12 -0
- package/examples/react/FullApp/src/types/message.ts +7 -0
- package/examples/react/FullApp/src/types/user.ts +10 -0
- package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
- package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
- package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
- package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
- package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
- package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
- package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
- package/examples/react/FullApp/tsconfig.json +22 -0
- package/examples/react/FullApp/vite.config.ts +18 -0
- package/examples/react/WorkerApp/index.html +12 -0
- package/examples/react/WorkerApp/src/App.tsx +24 -0
- package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
- package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
- package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
- package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
- package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
- package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
- package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
- package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
- package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
- package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
- package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
- package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
- package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
- package/examples/react/WorkerApp/src/env.d.ts +10 -0
- package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/WorkerApp/src/main.tsx +10 -0
- package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
- package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
- package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
- package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
- package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
- package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
- package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
- package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
- package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
- package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
- package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
- package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
- package/examples/react/WorkerApp/src/styles.css +756 -0
- package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
- package/examples/react/WorkerApp/src/types/message.ts +7 -0
- package/examples/react/WorkerApp/src/types/shift.ts +13 -0
- package/examples/react/WorkerApp/src/types/site.ts +8 -0
- package/examples/react/WorkerApp/src/types/worker.ts +8 -0
- package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
- package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
- package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
- package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
- package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
- package/examples/react/WorkerApp/tsconfig.json +22 -0
- package/examples/react/WorkerApp/vite.config.ts +18 -0
- package/package.json +11 -9
- package/src/Pending.test.ts +1 -2
- package/src/Sorting.test.ts +1 -1
- package/src/produceDraft.test.ts +3 -3
- package/src/react/components/CardList.test.tsx +1 -1
- package/src/react/components/DataTable.test.tsx +1 -1
- package/src/react/components/InfiniteScroll.test.tsx +5 -5
- package/dist/mvc-kit.cjs.map +0 -1
- package/dist/mvc-kit.js.map +0 -1
- package/dist/react-native.cjs.map +0 -1
- package/dist/react-native.js.map +0 -1
- package/dist/react.cjs.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/web.cjs.map +0 -1
- package/dist/web.js.map +0 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Trackable, ViewModel, bindPublicMethods } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Trackable: Base class for custom reactive objects
|
|
4
|
+
//
|
|
5
|
+
// Provides the subscribable + disposable + auto-bind foundation that
|
|
6
|
+
// all composable helpers (Sorting, Selection, Feed, Pagination, Pending)
|
|
7
|
+
// are built on. Extend it to build your own reactive objects that
|
|
8
|
+
// integrate with ViewModel's auto-tracking system.
|
|
9
|
+
//
|
|
10
|
+
// What Trackable provides:
|
|
11
|
+
// - subscribe() / notify() for change notifications
|
|
12
|
+
// - dispose() / disposeSignal / addCleanup() for lifecycle
|
|
13
|
+
// - Auto-bound public methods (point-free callbacks)
|
|
14
|
+
//
|
|
15
|
+
// What Trackable does NOT provide:
|
|
16
|
+
// - State management (no set(), no state getter)
|
|
17
|
+
// - Computed getters / memoization
|
|
18
|
+
// - Async tracking (no vm.async)
|
|
19
|
+
// - init() lifecycle (implement Initializable yourself if needed)
|
|
20
|
+
|
|
21
|
+
// --- Example 1: Custom RPC query wrapper ---
|
|
22
|
+
|
|
23
|
+
interface RPCResponse<T> {
|
|
24
|
+
data: T;
|
|
25
|
+
success: boolean;
|
|
26
|
+
code: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class RPCQuery<Args, Data> extends Trackable {
|
|
30
|
+
private _data: Data | undefined = undefined;
|
|
31
|
+
private _loading = false;
|
|
32
|
+
private _error: string | null = null;
|
|
33
|
+
private _callCounter = 0;
|
|
34
|
+
|
|
35
|
+
// Computed getters — auto-tracked when read inside ViewModel getters
|
|
36
|
+
get data(): Data | undefined { return this._data; }
|
|
37
|
+
get loading(): boolean { return this._loading; }
|
|
38
|
+
get error(): string | null { return this._error; }
|
|
39
|
+
|
|
40
|
+
constructor(private _endpoint: string) {
|
|
41
|
+
super();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async call(args?: Args): Promise<RPCResponse<Data>> {
|
|
45
|
+
if (this.disposed) throw new Error('RPCQuery: call() after dispose');
|
|
46
|
+
|
|
47
|
+
const callId = ++this._callCounter;
|
|
48
|
+
this._loading = true;
|
|
49
|
+
this._error = null;
|
|
50
|
+
this.notify();
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// Simulate an RPC call
|
|
54
|
+
const response = await simulateRPC<Data>(this._endpoint, args);
|
|
55
|
+
|
|
56
|
+
// Stale-call guard: only apply if this is still the latest call
|
|
57
|
+
if (!this.disposed && callId === this._callCounter) {
|
|
58
|
+
this._data = response.data;
|
|
59
|
+
this._loading = false;
|
|
60
|
+
this._error = null;
|
|
61
|
+
this.notify();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return response;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!this.disposed && callId === this._callCounter) {
|
|
67
|
+
this._loading = false;
|
|
68
|
+
this._error = (err as Error).message;
|
|
69
|
+
this.notify();
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
clear() {
|
|
76
|
+
this._data = undefined;
|
|
77
|
+
this._error = null;
|
|
78
|
+
this.notify();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Simulate RPC
|
|
83
|
+
async function simulateRPC<T>(_endpoint: string, _args?: unknown): Promise<RPCResponse<T>> {
|
|
84
|
+
await new Promise(r => setTimeout(r, 100));
|
|
85
|
+
return { data: [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }] as T, success: true, code: 200 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Basic usage ---
|
|
89
|
+
|
|
90
|
+
const query = new RPCQuery<{ search: string }, { id: string; name: string }[]>('Users.Search');
|
|
91
|
+
|
|
92
|
+
// Subscribe to changes
|
|
93
|
+
query.subscribe(() => {
|
|
94
|
+
console.log(`Query state: loading=${query.loading}, data=${query.data?.length ?? 0} items, error=${query.error}`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
console.log('Loading:', query.loading); // false
|
|
98
|
+
console.log('Data:', query.data); // undefined
|
|
99
|
+
|
|
100
|
+
await query.call({ search: 'alice' });
|
|
101
|
+
// Query state: loading=true, data=0 items, error=null
|
|
102
|
+
// Query state: loading=false, data=2 items, error=null
|
|
103
|
+
|
|
104
|
+
console.log('Data after call:', query.data); // [{ id: '1', name: 'Alice' }, ...]
|
|
105
|
+
|
|
106
|
+
// --- Method binding (point-free) ---
|
|
107
|
+
|
|
108
|
+
const { call, clear, dispose } = query;
|
|
109
|
+
// These work without wrapper functions — auto-bound by Trackable
|
|
110
|
+
clear(); // Resets data
|
|
111
|
+
|
|
112
|
+
// --- Dispose lifecycle ---
|
|
113
|
+
|
|
114
|
+
console.log('Disposed:', query.disposed); // false
|
|
115
|
+
console.log('Signal aborted:', query.disposeSignal.aborted); // false
|
|
116
|
+
|
|
117
|
+
dispose();
|
|
118
|
+
|
|
119
|
+
console.log('Disposed:', query.disposed); // true
|
|
120
|
+
console.log('Signal aborted:', query.disposeSignal.aborted); // true
|
|
121
|
+
|
|
122
|
+
// --- Example 2: ViewModel auto-tracking integration ---
|
|
123
|
+
|
|
124
|
+
interface User {
|
|
125
|
+
id: string;
|
|
126
|
+
name: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class UsersViewModel extends ViewModel {
|
|
130
|
+
// RPCQuery as a property — auto-tracked by ViewModel's dependency system
|
|
131
|
+
readonly users = new RPCQuery<void, User[]>('Users.List');
|
|
132
|
+
|
|
133
|
+
// This getter auto-invalidates when users.notify() fires
|
|
134
|
+
get userCount(): number {
|
|
135
|
+
return this.users.data?.length ?? 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get isLoading(): boolean {
|
|
139
|
+
return this.users.loading;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get userNames(): string[] {
|
|
143
|
+
return (this.users.data ?? []).map(u => u.name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async onInit() {
|
|
147
|
+
await this.users.call();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const vm = new UsersViewModel();
|
|
152
|
+
vm.init();
|
|
153
|
+
|
|
154
|
+
// After init, the RPCQuery fires and getters update automatically
|
|
155
|
+
await new Promise(r => setTimeout(r, 200));
|
|
156
|
+
|
|
157
|
+
console.log('User count:', vm.userCount); // 2
|
|
158
|
+
console.log('User names:', vm.userNames); // ['Alice', 'Bob']
|
|
159
|
+
|
|
160
|
+
vm.dispose();
|
|
161
|
+
|
|
162
|
+
// --- Example 3: addCleanup for external subscriptions ---
|
|
163
|
+
|
|
164
|
+
class LivePrice extends Trackable {
|
|
165
|
+
private _price = 0;
|
|
166
|
+
|
|
167
|
+
get price(): number { return this._price; }
|
|
168
|
+
|
|
169
|
+
constructor(symbol: string) {
|
|
170
|
+
super();
|
|
171
|
+
|
|
172
|
+
// Simulate a WebSocket price subscription
|
|
173
|
+
const interval = setInterval(() => {
|
|
174
|
+
this._price = Math.round(Math.random() * 10000) / 100;
|
|
175
|
+
this.notify();
|
|
176
|
+
}, 1000);
|
|
177
|
+
|
|
178
|
+
// addCleanup runs on dispose — auto-cleanup
|
|
179
|
+
this.addCleanup(() => {
|
|
180
|
+
clearInterval(interval);
|
|
181
|
+
console.log(`Unsubscribed from ${symbol} price feed`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
protected onDispose() {
|
|
186
|
+
console.log('LivePrice disposed');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const btcPrice = new LivePrice('BTC');
|
|
191
|
+
|
|
192
|
+
const unsub = btcPrice.subscribe(() => {
|
|
193
|
+
console.log(`BTC: $${btcPrice.price}`);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Let it tick a couple times
|
|
197
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
198
|
+
|
|
199
|
+
btcPrice.dispose(); // Clears interval, logs "Unsubscribed...", logs "LivePrice disposed"
|
|
200
|
+
unsub(); // Safe to call after dispose — no-op
|
|
201
|
+
|
|
202
|
+
// --- Example 4: bindPublicMethods standalone utility ---
|
|
203
|
+
//
|
|
204
|
+
// For classes that don't extend Trackable but still want point-free methods.
|
|
205
|
+
|
|
206
|
+
class Formatter {
|
|
207
|
+
constructor(private locale: string) {
|
|
208
|
+
bindPublicMethods(this);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
formatCurrency(amount: number): string {
|
|
212
|
+
return new Intl.NumberFormat(this.locale, { style: 'currency', currency: 'USD' }).format(amount);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
formatDate(date: Date): string {
|
|
216
|
+
return new Intl.DateTimeFormat(this.locale).format(date);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const fmt = new Formatter('en-US');
|
|
221
|
+
|
|
222
|
+
// Destructured methods work — bound by bindPublicMethods
|
|
223
|
+
const { formatCurrency, formatDate } = fmt;
|
|
224
|
+
console.log(formatCurrency(1234.56)); // $1,234.56
|
|
225
|
+
console.log(formatDate(new Date(2025, 0, 1))); // 1/1/2025
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"erasableSyntaxOnly": false,
|
|
12
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
13
|
+
"types": [],
|
|
14
|
+
"baseUrl": ".",
|
|
15
|
+
"paths": {
|
|
16
|
+
"mvc-kit": ["../../src/index.ts"]
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"include": ["."]
|
|
20
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ViewModel,
|
|
3
|
+
Service,
|
|
4
|
+
Collection,
|
|
5
|
+
HttpError,
|
|
6
|
+
isAbortError,
|
|
7
|
+
singleton,
|
|
8
|
+
teardownAll,
|
|
9
|
+
} from 'mvc-kit';
|
|
10
|
+
|
|
11
|
+
// ViewModel + Service + Collection: Full data-loading pattern
|
|
12
|
+
//
|
|
13
|
+
// This example shows the typical data-loading architecture:
|
|
14
|
+
// - Service: Stateless HTTP adapter (wraps fetch with HttpError handling)
|
|
15
|
+
// - Collection: Shared reactive data cache (singleton)
|
|
16
|
+
// - ViewModel: Orchestrates loading, holds UI state, provides computed getters
|
|
17
|
+
//
|
|
18
|
+
// The ViewModel's async methods are automatically tracked — access
|
|
19
|
+
// loading/error state via vm.async.methodName without manual flags.
|
|
20
|
+
|
|
21
|
+
// --- Entity type ---
|
|
22
|
+
|
|
23
|
+
interface User {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
role: 'admin' | 'member';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Service ---
|
|
30
|
+
|
|
31
|
+
class UserService extends Service {
|
|
32
|
+
async getAll(signal?: AbortSignal): Promise<User[]> {
|
|
33
|
+
// Simulated API call — in production this would be fetch()
|
|
34
|
+
await delay(500, signal);
|
|
35
|
+
return [
|
|
36
|
+
{ id: '1', name: 'Alice', role: 'admin' },
|
|
37
|
+
{ id: '2', name: 'Bob', role: 'member' },
|
|
38
|
+
{ id: '3', name: 'Carol', role: 'member' },
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async save(user: User, signal?: AbortSignal): Promise<User> {
|
|
43
|
+
await delay(300, signal);
|
|
44
|
+
if (!user.name.trim()) throw new HttpError(400, 'Name is required');
|
|
45
|
+
return user;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Collection ---
|
|
50
|
+
|
|
51
|
+
class UsersCollection extends Collection<User> {}
|
|
52
|
+
|
|
53
|
+
// --- ViewModel ---
|
|
54
|
+
|
|
55
|
+
interface UsersState {
|
|
56
|
+
items: User[];
|
|
57
|
+
search: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class UsersViewModel extends ViewModel<UsersState> {
|
|
61
|
+
private service = singleton(UserService);
|
|
62
|
+
private collection = singleton(UsersCollection);
|
|
63
|
+
|
|
64
|
+
// --- Computed getters ---
|
|
65
|
+
get filtered(): User[] {
|
|
66
|
+
const { items, search } = this.state;
|
|
67
|
+
if (!search) return items;
|
|
68
|
+
const q = search.toLowerCase();
|
|
69
|
+
return items.filter(u => u.name.toLowerCase().includes(q));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get total(): number {
|
|
73
|
+
return this.state.items.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get adminCount(): number {
|
|
77
|
+
return this.state.items.filter(u => u.role === 'admin').length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Lifecycle ---
|
|
81
|
+
protected onInit() {
|
|
82
|
+
this.subscribeTo(this.collection, () => {
|
|
83
|
+
this.set({ items: this.collection.items as User[] });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (this.collection.length > 0) {
|
|
87
|
+
this.set({ items: this.collection.items as User[] });
|
|
88
|
+
} else {
|
|
89
|
+
this.load();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Actions ---
|
|
94
|
+
async load() {
|
|
95
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
96
|
+
this.collection.reset(data);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async save(user: User) {
|
|
100
|
+
try {
|
|
101
|
+
const saved = await this.service.save(user, this.disposeSignal);
|
|
102
|
+
this.collection.update(saved.id, saved);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (!isAbortError(e)) {
|
|
105
|
+
console.error('Save failed:', (e as Error).message);
|
|
106
|
+
}
|
|
107
|
+
throw e; // re-throw so async tracking captures it
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Setters ---
|
|
112
|
+
setSearch(search: string) {
|
|
113
|
+
this.set({ search });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Usage ---
|
|
118
|
+
|
|
119
|
+
const vm = new UsersViewModel({ items: [], search: '' });
|
|
120
|
+
vm.init(); // activates getter memoization + async tracking
|
|
121
|
+
|
|
122
|
+
// Async tracking — no manual loading/error state needed
|
|
123
|
+
console.log('Loading:', vm.async.load.loading); // true (load started in onInit)
|
|
124
|
+
|
|
125
|
+
await vm.load();
|
|
126
|
+
|
|
127
|
+
console.log('Loading:', vm.async.load.loading); // false
|
|
128
|
+
console.log('Error:', vm.async.load.error); // null
|
|
129
|
+
console.log('Total users:', vm.total); // 3
|
|
130
|
+
console.log('Admins:', vm.adminCount); // 1
|
|
131
|
+
|
|
132
|
+
// Computed getters recompute when state changes
|
|
133
|
+
vm.setSearch('alice');
|
|
134
|
+
console.log('Filtered:', vm.filtered.length); // 1
|
|
135
|
+
|
|
136
|
+
vm.setSearch('');
|
|
137
|
+
console.log('Filtered:', vm.filtered.length); // 3
|
|
138
|
+
|
|
139
|
+
// Cleanup
|
|
140
|
+
vm.dispose();
|
|
141
|
+
teardownAll();
|
|
142
|
+
|
|
143
|
+
// --- Helper ---
|
|
144
|
+
|
|
145
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
if (signal?.aborted) {
|
|
148
|
+
reject(signal.reason);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const timer = setTimeout(resolve, ms);
|
|
152
|
+
signal?.addEventListener(
|
|
153
|
+
'abort',
|
|
154
|
+
() => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
reject(signal.reason);
|
|
157
|
+
},
|
|
158
|
+
{ once: true },
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>mvc-kit Auth Example</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|
2
|
+
import { AuthGuard } from './components/AuthGuard';
|
|
3
|
+
import { AppHeader } from './components/AppHeader';
|
|
4
|
+
import { DashboardPage } from './components/DashboardPage';
|
|
5
|
+
import { ProfilePage } from './components/ProfilePage';
|
|
6
|
+
import { AdminPage } from './components/AdminPage';
|
|
7
|
+
import { Toast } from './components/Toast';
|
|
8
|
+
|
|
9
|
+
export function App() {
|
|
10
|
+
return (
|
|
11
|
+
<BrowserRouter>
|
|
12
|
+
{/* AuthGuard wraps all authenticated content via composition.
|
|
13
|
+
When not authenticated, it shows the login/register screen
|
|
14
|
+
WITHOUT redirecting — preserving the current URL and any
|
|
15
|
+
query params. After login, children render immediately
|
|
16
|
+
at the original URL. */}
|
|
17
|
+
<AuthGuard>
|
|
18
|
+
<AppHeader />
|
|
19
|
+
<Routes>
|
|
20
|
+
<Route path="/" element={<DashboardPage />} />
|
|
21
|
+
<Route path="/profile" element={<ProfilePage />} />
|
|
22
|
+
<Route path="/admin" element={<AdminPage />} />
|
|
23
|
+
<Route path="*" element={<Navigate to="/" replace />} />
|
|
24
|
+
</Routes>
|
|
25
|
+
</AuthGuard>
|
|
26
|
+
<Toast />
|
|
27
|
+
</BrowserRouter>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
2
|
+
import { AuthViewModel } from '../viewmodels/AuthViewModel';
|
|
3
|
+
|
|
4
|
+
export function AdminPage() {
|
|
5
|
+
const [state, vm] = useSingleton(AuthViewModel);
|
|
6
|
+
|
|
7
|
+
// Role check is done inside the page, not via a route wrapper.
|
|
8
|
+
// This keeps routing simple and the access-denied message inline.
|
|
9
|
+
if (!vm.isAdmin) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="page-content">
|
|
12
|
+
<div className="access-denied">
|
|
13
|
+
<h2>Access Denied</h2>
|
|
14
|
+
<p>
|
|
15
|
+
You are signed in as <strong>{vm.displayName}</strong> with
|
|
16
|
+
the <span className={`badge badge-${state.user!.role}`}>{state.user!.role}</span> role.
|
|
17
|
+
</p>
|
|
18
|
+
<p>This page requires the <span className="badge badge-admin">admin</span> role.</p>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="page-content">
|
|
26
|
+
<h1 className="page-title">Admin Panel</h1>
|
|
27
|
+
|
|
28
|
+
<div className="card" style={{ marginBottom: '1rem' }}>
|
|
29
|
+
<h3 style={{ marginBottom: '0.5rem' }}>System Status</h3>
|
|
30
|
+
<div className="stats-grid">
|
|
31
|
+
<div className="stat-card">
|
|
32
|
+
<div className="stat-label">Active Users</div>
|
|
33
|
+
<div className="stat-value">3</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="stat-card">
|
|
36
|
+
<div className="stat-label">Auth Tokens</div>
|
|
37
|
+
<div className="stat-value">1</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div className="card">
|
|
43
|
+
<h3 style={{ marginBottom: '0.5rem' }}>Admin-Only Content</h3>
|
|
44
|
+
<p style={{ color: 'var(--color-text-secondary)' }}>
|
|
45
|
+
This content is only visible to users with the admin role.
|
|
46
|
+
The role check happens inside the page component, not at the route level.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NavLink } from 'react-router-dom';
|
|
2
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
3
|
+
import { AuthViewModel } from '../viewmodels/AuthViewModel';
|
|
4
|
+
|
|
5
|
+
export function AppHeader() {
|
|
6
|
+
const [, vm] = useSingleton(AuthViewModel);
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<header className="app-header">
|
|
10
|
+
<nav className="header-nav">
|
|
11
|
+
<span className="header-logo">mvc-kit Auth</span>
|
|
12
|
+
<NavLink to="/" end className={({ isActive }) => `header-link ${isActive ? 'active' : ''}`}>
|
|
13
|
+
Dashboard
|
|
14
|
+
</NavLink>
|
|
15
|
+
<NavLink to="/profile" className={({ isActive }) => `header-link ${isActive ? 'active' : ''}`}>
|
|
16
|
+
Profile
|
|
17
|
+
</NavLink>
|
|
18
|
+
<NavLink to="/admin" className={({ isActive }) => `header-link ${isActive ? 'active' : ''}`}>
|
|
19
|
+
Admin
|
|
20
|
+
</NavLink>
|
|
21
|
+
</nav>
|
|
22
|
+
|
|
23
|
+
<div className="header-user">
|
|
24
|
+
<div className="avatar">{vm.initials}</div>
|
|
25
|
+
<span className="header-user-name">{vm.displayName}</span>
|
|
26
|
+
<button type="button" className="btn btn-secondary btn-sm" onClick={vm.logout}>
|
|
27
|
+
Logout
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</header>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
3
|
+
import { AuthViewModel } from '../viewmodels/AuthViewModel';
|
|
4
|
+
import { AuthScreen } from './AuthScreen';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AuthGuard — composition-based authentication wrapper.
|
|
8
|
+
*
|
|
9
|
+
* Instead of using <ProtectedRoute> wrappers or redirect-based guards,
|
|
10
|
+
* this component wraps the entire authenticated app via composition:
|
|
11
|
+
*
|
|
12
|
+
* <AuthGuard>
|
|
13
|
+
* <AppHeader />
|
|
14
|
+
* <Routes>...</Routes>
|
|
15
|
+
* </AuthGuard>
|
|
16
|
+
*
|
|
17
|
+
* How it works:
|
|
18
|
+
* - Reads auth state from the singleton AuthViewModel
|
|
19
|
+
* - While restoring a session (onInit loading), shows a spinner
|
|
20
|
+
* - When not authenticated, renders <AuthScreen /> (login/register UI)
|
|
21
|
+
* WITHOUT navigating away — the current URL and query params are preserved
|
|
22
|
+
* - When authenticated, renders {children} immediately at the current URL
|
|
23
|
+
*
|
|
24
|
+
* Benefits over redirect-based auth:
|
|
25
|
+
* - URL is always preserved — no redirect loops, no lost query params
|
|
26
|
+
* - No coupling between auth state and router configuration
|
|
27
|
+
* - Children mount immediately after login with no extra navigation
|
|
28
|
+
* - Simpler mental model: auth state controls what renders, not where you navigate
|
|
29
|
+
*/
|
|
30
|
+
export function AuthGuard({ children }: { children: ReactNode }) {
|
|
31
|
+
const [, vm] = useSingleton(AuthViewModel);
|
|
32
|
+
|
|
33
|
+
// Session restore in progress — show a loading indicator
|
|
34
|
+
if (vm.async.onInit.loading) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="auth-loading">
|
|
37
|
+
<div className="spinner spinner-lg" />
|
|
38
|
+
<p>Restoring session...</p>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Not authenticated — show login/register UI in place (no redirect)
|
|
44
|
+
if (!vm.isAuthenticated) {
|
|
45
|
+
return <AuthScreen />;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Authenticated — render the app
|
|
49
|
+
return <>{children}</>;
|
|
50
|
+
}
|