mvc-kit 2.13.0 → 2.13.2

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 (218) hide show
  1. package/BEST_PRACTICES.md +1390 -0
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +8 -3
  3. package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +10 -1
  4. package/agent-config/lib/install-claude.mjs +39 -110
  5. package/examples/primitive/channel.ts +109 -0
  6. package/examples/primitive/collection.ts +118 -0
  7. package/examples/primitive/controller.ts +118 -0
  8. package/examples/primitive/counter.ts +108 -0
  9. package/examples/primitive/env.d.ts +1 -0
  10. package/examples/primitive/eventbus.ts +77 -0
  11. package/examples/primitive/feed.ts +162 -0
  12. package/examples/primitive/model.ts +82 -0
  13. package/examples/primitive/pagination.ts +91 -0
  14. package/examples/primitive/pending.ts +189 -0
  15. package/examples/primitive/persistent-collection.ts +116 -0
  16. package/examples/primitive/resource.ts +114 -0
  17. package/examples/primitive/selection.ts +96 -0
  18. package/examples/primitive/sorting.ts +112 -0
  19. package/examples/primitive/timer.ts +58 -0
  20. package/examples/primitive/trackable.ts +225 -0
  21. package/examples/primitive/tsconfig.json +20 -0
  22. package/examples/primitive/viewmodel-service.ts +161 -0
  23. package/examples/react/AuthExample/index.html +12 -0
  24. package/examples/react/AuthExample/src/App.tsx +29 -0
  25. package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
  26. package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
  27. package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
  28. package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
  29. package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
  30. package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
  31. package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
  32. package/examples/react/AuthExample/src/env.d.ts +10 -0
  33. package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
  34. package/examples/react/AuthExample/src/main.tsx +10 -0
  35. package/examples/react/AuthExample/src/mock/api.ts +78 -0
  36. package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
  37. package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
  38. package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
  39. package/examples/react/AuthExample/src/styles.css +445 -0
  40. package/examples/react/AuthExample/src/types/auth.ts +12 -0
  41. package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
  42. package/examples/react/AuthExample/tsconfig.json +22 -0
  43. package/examples/react/AuthExample/vite.config.ts +18 -0
  44. package/examples/react/ComplexApp/index.html +12 -0
  45. package/examples/react/ComplexApp/src/App.tsx +17 -0
  46. package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
  47. package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
  48. package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
  49. package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
  50. package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
  51. package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
  52. package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
  53. package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
  54. package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
  55. package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
  56. package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
  57. package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
  58. package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
  59. package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
  60. package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
  61. package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
  62. package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
  63. package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
  64. package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
  65. package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
  66. package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
  67. package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
  68. package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
  69. package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
  70. package/examples/react/ComplexApp/src/env.d.ts +10 -0
  71. package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
  72. package/examples/react/ComplexApp/src/main.tsx +10 -0
  73. package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
  74. package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
  75. package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
  76. package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
  77. package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
  78. package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
  79. package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
  80. package/examples/react/ComplexApp/src/styles.css +463 -0
  81. package/examples/react/ComplexApp/src/types/activity.ts +8 -0
  82. package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
  83. package/examples/react/ComplexApp/src/types/social.ts +8 -0
  84. package/examples/react/ComplexApp/src/types/users.ts +6 -0
  85. package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
  86. package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
  87. package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
  88. package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
  89. package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
  90. package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
  91. package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
  92. package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
  93. package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
  94. package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
  95. package/examples/react/ComplexApp/tsconfig.json +22 -0
  96. package/examples/react/ComplexApp/vite.config.ts +18 -0
  97. package/examples/react/FullApp/index.html +12 -0
  98. package/examples/react/FullApp/src/App.tsx +28 -0
  99. package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
  100. package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
  101. package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
  102. package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
  103. package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
  104. package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
  105. package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
  106. package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
  107. package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
  108. package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
  109. package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
  110. package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
  111. package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
  112. package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
  113. package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
  114. package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
  115. package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
  116. package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
  117. package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
  118. package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
  119. package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
  120. package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
  121. package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
  122. package/examples/react/FullApp/src/env.d.ts +10 -0
  123. package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
  124. package/examples/react/FullApp/src/main.tsx +10 -0
  125. package/examples/react/FullApp/src/mock/delay.ts +21 -0
  126. package/examples/react/FullApp/src/mock/locations.ts +76 -0
  127. package/examples/react/FullApp/src/mock/messages.ts +237 -0
  128. package/examples/react/FullApp/src/mock/users.ts +84 -0
  129. package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
  130. package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
  131. package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
  132. package/examples/react/FullApp/src/services/AuthService.ts +18 -0
  133. package/examples/react/FullApp/src/services/LocationService.ts +23 -0
  134. package/examples/react/FullApp/src/services/MessageService.ts +65 -0
  135. package/examples/react/FullApp/src/services/UserService.ts +23 -0
  136. package/examples/react/FullApp/src/styles.css +767 -0
  137. package/examples/react/FullApp/src/types/conversation.ts +7 -0
  138. package/examples/react/FullApp/src/types/location.ts +12 -0
  139. package/examples/react/FullApp/src/types/message.ts +7 -0
  140. package/examples/react/FullApp/src/types/user.ts +10 -0
  141. package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
  142. package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
  143. package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
  144. package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
  145. package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
  146. package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
  147. package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
  148. package/examples/react/FullApp/tsconfig.json +22 -0
  149. package/examples/react/FullApp/vite.config.ts +18 -0
  150. package/examples/react/WorkerApp/index.html +12 -0
  151. package/examples/react/WorkerApp/src/App.tsx +24 -0
  152. package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
  153. package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
  154. package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
  155. package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
  156. package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
  157. package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
  158. package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
  159. package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
  160. package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
  161. package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
  162. package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
  163. package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
  164. package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
  165. package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
  166. package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
  167. package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
  168. package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
  169. package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
  170. package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
  171. package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
  172. package/examples/react/WorkerApp/src/env.d.ts +10 -0
  173. package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
  174. package/examples/react/WorkerApp/src/main.tsx +10 -0
  175. package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
  176. package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
  177. package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
  178. package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
  179. package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
  180. package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
  181. package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
  182. package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
  183. package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
  184. package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
  185. package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
  186. package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
  187. package/examples/react/WorkerApp/src/styles.css +756 -0
  188. package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
  189. package/examples/react/WorkerApp/src/types/message.ts +7 -0
  190. package/examples/react/WorkerApp/src/types/shift.ts +13 -0
  191. package/examples/react/WorkerApp/src/types/site.ts +8 -0
  192. package/examples/react/WorkerApp/src/types/worker.ts +8 -0
  193. package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
  194. package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
  195. package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
  196. package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
  197. package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
  198. package/examples/react/WorkerApp/tsconfig.json +22 -0
  199. package/examples/react/WorkerApp/vite.config.ts +18 -0
  200. package/package.json +4 -2
  201. /package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +0 -0
  202. /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
  203. /package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +0 -0
  204. /package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +0 -0
  205. /package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +0 -0
  206. /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
  207. /package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +0 -0
  208. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
  209. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
  210. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
  211. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
  212. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
  213. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
  214. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
  215. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
  216. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
  217. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
  218. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
@@ -0,0 +1,39 @@
1
+ import { ViewModel, singleton } from 'mvc-kit';
2
+ import type { SocialPost } from '../types/social';
3
+ import { SocialFeedResource } from '../resources/SocialFeedResource';
4
+
5
+ interface SocialFeedState {
6
+ page: number;
7
+ }
8
+
9
+ export class SocialFeedViewModel extends ViewModel<SocialFeedState> {
10
+ private resource = singleton(SocialFeedResource);
11
+
12
+ // --- Computed getters ---
13
+
14
+ get posts(): SocialPost[] {
15
+ return this.resource.items;
16
+ }
17
+
18
+ get hasMore(): boolean {
19
+ return this.resource.hasMore;
20
+ }
21
+
22
+ get postCount(): number {
23
+ return this.resource.length;
24
+ }
25
+
26
+ // --- Lifecycle ---
27
+
28
+ protected onInit() {
29
+ if (this.resource.length === 0) this.loadMore();
30
+ }
31
+
32
+ // --- Actions ---
33
+
34
+ async loadMore() {
35
+ const page = this.state.page;
36
+ this.set({ page: page + 1 });
37
+ await this.resource.loadPage(page);
38
+ }
39
+ }
@@ -0,0 +1,9 @@
1
+ import { singleton } from 'mvc-kit';
2
+ import { DashboardCardViewModel } from './DashboardCardViewModel';
3
+ import { TrafficChannel } from '../channels/TrafficChannel';
4
+ import { TrafficCollection } from '../collections/TrafficCollection';
5
+
6
+ export class TrafficCardViewModel extends DashboardCardViewModel {
7
+ protected channel = singleton(TrafficChannel);
8
+ protected collection = singleton(TrafficCollection);
9
+ }
@@ -0,0 +1,9 @@
1
+ import { singleton } from 'mvc-kit';
2
+ import { DashboardCardViewModel } from './DashboardCardViewModel';
3
+ import { UsersMetricChannel } from '../channels/UsersMetricChannel';
4
+ import { UsersMetricCollection } from '../collections/UsersMetricCollection';
5
+
6
+ export class UsersMetricCardViewModel extends DashboardCardViewModel {
7
+ protected channel = singleton(UsersMetricChannel);
8
+ protected collection = singleton(UsersMetricCollection);
9
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true,
10
+ "allowImportingTsExtensions": true,
11
+ "verbatimModuleSyntax": true,
12
+ "erasableSyntaxOnly": false,
13
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
14
+ "types": [],
15
+ "baseUrl": ".",
16
+ "paths": {
17
+ "mvc-kit": ["../../../src/index.ts"],
18
+ "mvc-kit/react": ["../../../src/react/index.ts"]
19
+ }
20
+ },
21
+ "include": ["src"]
22
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from 'vite';
2
+ import { resolve } from 'node:path';
3
+
4
+ export default defineConfig({
5
+ root: import.meta.dirname,
6
+ define: {
7
+ __MVC_KIT_DEV__: true,
8
+ },
9
+ resolve: {
10
+ alias: {
11
+ 'mvc-kit/react': resolve(import.meta.dirname, '../../../src/react/index.ts'),
12
+ 'mvc-kit': resolve(import.meta.dirname, '../../../src/index.ts'),
13
+ },
14
+ },
15
+ server: {
16
+ port: 3001,
17
+ },
18
+ });
@@ -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 Full 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,28 @@
1
+ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
2
+ import { AppShell } from './components/layout/AppShell';
3
+ import { LoginPage } from './components/auth/LoginPage';
4
+ import { DashboardPage } from './components/dashboard/DashboardPage';
5
+ import { UsersPage } from './components/users/UsersPage';
6
+ import { LocationsPage } from './components/locations/LocationsPage';
7
+ import { LocationProfilePage } from './components/locations/LocationProfilePage';
8
+ import { MessagingPage } from './components/messaging/MessagingPage';
9
+ import { Toast } from './components/shared/Toast';
10
+
11
+ export function App() {
12
+ return (
13
+ <BrowserRouter>
14
+ <Routes>
15
+ <Route path="/login" element={<LoginPage />} />
16
+ <Route element={<AppShell />}>
17
+ <Route path="/dashboard" element={<DashboardPage />} />
18
+ <Route path="/users" element={<UsersPage />} />
19
+ <Route path="/locations" element={<LocationsPage />} />
20
+ <Route path="/locations/:id" element={<LocationProfilePage />} />
21
+ <Route path="/messaging" element={<MessagingPage />} />
22
+ </Route>
23
+ <Route path="*" element={<Navigate to="/dashboard" replace />} />
24
+ </Routes>
25
+ <Toast />
26
+ </BrowserRouter>
27
+ );
28
+ }
@@ -0,0 +1,4 @@
1
+ import { Collection } from 'mvc-kit';
2
+ import type { ConversationState } from '../types/conversation';
3
+
4
+ export class ConversationsCollection extends Collection<ConversationState> {}
@@ -0,0 +1,4 @@
1
+ import { Collection } from 'mvc-kit';
2
+ import type { LocationState } from '../types/location';
3
+
4
+ export class LocationsCollection extends Collection<LocationState> {}
@@ -0,0 +1,80 @@
1
+ import { useEffect } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { useSingleton, useEvent, useModel } from 'mvc-kit/react';
4
+ import { AuthViewModel } from '../../viewmodels/AuthViewModel';
5
+ import { LoginFormModel } from '../../models/LoginFormModel';
6
+
7
+ export function LoginPage() {
8
+ const [authState, authVM] = useSingleton(AuthViewModel);
9
+ const { loading, error } = authVM.async.login;
10
+ const { state, errors, valid, dirty, model } = useModel(
11
+ () => new LoginFormModel({ email: '', password: '' }),
12
+ );
13
+ const navigate = useNavigate();
14
+
15
+ // Redirect if already authenticated
16
+ useEffect(() => {
17
+ if (authState.isAuthenticated) {
18
+ navigate('/dashboard');
19
+ }
20
+ }, [authState.isAuthenticated, navigate]);
21
+
22
+ // Show inline error from loginFailed event
23
+ useEvent(authVM, 'loginFailed', () => {
24
+ // Error is already shown via vm.async.login error state
25
+ });
26
+
27
+ const handleSubmit = (e: React.FormEvent) => {
28
+ e.preventDefault();
29
+ if (!valid) return;
30
+ authVM.login(state.email, state.password);
31
+ };
32
+
33
+ return (
34
+ <div className="login-page">
35
+ <div className="login-card">
36
+ <h1 className="login-title">mvc-kit Demo</h1>
37
+ <p className="login-subtitle">
38
+ Enter any email and password (6+ chars) to log in
39
+ </p>
40
+
41
+ {error && <div className="error-banner">{error}</div>}
42
+
43
+ <form onSubmit={handleSubmit}>
44
+ <div className="form-group">
45
+ <label className="form-label">Email</label>
46
+ <input
47
+ className={`form-input ${errors.email && dirty ? 'error' : ''}`}
48
+ type="text"
49
+ value={state.email}
50
+ onChange={e => model.setEmail(e.target.value)}
51
+ placeholder="alice@example.com"
52
+ />
53
+ {errors.email && dirty && <div className="form-error">{errors.email}</div>}
54
+ </div>
55
+
56
+ <div className="form-group">
57
+ <label className="form-label">Password</label>
58
+ <input
59
+ className={`form-input ${errors.password && dirty ? 'error' : ''}`}
60
+ type="password"
61
+ value={state.password}
62
+ onChange={e => model.setPassword(e.target.value)}
63
+ placeholder="6+ characters"
64
+ />
65
+ {errors.password && dirty && <div className="form-error">{errors.password}</div>}
66
+ </div>
67
+
68
+ <button
69
+ type="submit"
70
+ className="btn btn-primary"
71
+ style={{ width: '100%', marginTop: '0.5rem' }}
72
+ disabled={!valid || loading}
73
+ >
74
+ {loading ? 'Signing in...' : 'Sign In'}
75
+ </button>
76
+ </form>
77
+ </div>
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,29 @@
1
+ import { useLocal } from 'mvc-kit/react';
2
+ import { DashboardViewModel } from '../../viewmodels/DashboardViewModel';
3
+ import { StatsCard } from './StatsCard';
4
+ import { RecentActivityCard } from './RecentActivityCard';
5
+ import { Spinner } from '../shared/Spinner';
6
+ import { ErrorBanner } from '../shared/ErrorBanner';
7
+
8
+ export function DashboardPage() {
9
+ const [, vm] = useLocal(DashboardViewModel, {});
10
+ const { loading, error } = vm.async.load;
11
+
12
+ return (
13
+ <div>
14
+ <h1 className="page-title">Dashboard</h1>
15
+
16
+ {loading && <Spinner />}
17
+ {error && <ErrorBanner message={error} />}
18
+
19
+ <div className="stats-grid">
20
+ <StatsCard title="Total Users" value={vm.totalUsers} subtitle={`${vm.activeUsers} active`} />
21
+ <StatsCard title="Total Locations" value={vm.totalLocations} subtitle={`${vm.activeLocations} active`} />
22
+ <StatsCard title="Admins" value={vm.usersByRole['admin'] ?? 0} />
23
+ <StatsCard title="Managers" value={vm.usersByRole['manager'] ?? 0} />
24
+ </div>
25
+
26
+ <RecentActivityCard users={vm.recentUsers} />
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,35 @@
1
+ import type { UserState } from '../../types/user';
2
+
3
+ interface RecentActivityCardProps {
4
+ users: UserState[];
5
+ }
6
+
7
+ export function RecentActivityCard({ users }: RecentActivityCardProps) {
8
+ return (
9
+ <div className="card">
10
+ <h3 style={{ marginBottom: '0.75rem', fontSize: '1rem', fontWeight: 600 }}>
11
+ Recent Team Members
12
+ </h3>
13
+ <ul className="activity-list">
14
+ {users.map(user => (
15
+ <li key={user.id} className="activity-item">
16
+ <div className="avatar">
17
+ {user.firstName[0]}{user.lastName[0]}
18
+ </div>
19
+ <div>
20
+ <div style={{ fontWeight: 500 }}>
21
+ {user.firstName} {user.lastName}
22
+ </div>
23
+ <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
24
+ {user.role} &middot; Joined {new Date(user.createdAt).toLocaleDateString()}
25
+ </div>
26
+ </div>
27
+ <span className={`badge badge-${user.status}`} style={{ marginLeft: 'auto' }}>
28
+ {user.status}
29
+ </span>
30
+ </li>
31
+ ))}
32
+ </ul>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,19 @@
1
+ interface StatsCardProps {
2
+ title: string;
3
+ value: number | string;
4
+ subtitle?: string;
5
+ }
6
+
7
+ export function StatsCard({ title, value, subtitle }: StatsCardProps) {
8
+ return (
9
+ <div className="stat-card">
10
+ <div className="stat-value">{value}</div>
11
+ <div className="stat-label">{title}</div>
12
+ {subtitle && (
13
+ <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginTop: '0.25rem' }}>
14
+ {subtitle}
15
+ </div>
16
+ )}
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,31 @@
1
+ import { useEffect } from 'react';
2
+ import { Outlet, useNavigate } from 'react-router-dom';
3
+ import { useSingleton } from 'mvc-kit/react';
4
+ import { AuthViewModel } from '../../viewmodels/AuthViewModel';
5
+ import { Sidebar } from './Sidebar';
6
+ import { Header } from './Header';
7
+
8
+ export function AppShell() {
9
+ const [state, vm] = useSingleton(AuthViewModel);
10
+ const navigate = useNavigate();
11
+
12
+ useEffect(() => {
13
+ if (!state.isAuthenticated) {
14
+ navigate('/login');
15
+ }
16
+ }, [state.isAuthenticated, navigate]);
17
+
18
+ if (!state.isAuthenticated || !state.user) return null;
19
+
20
+ return (
21
+ <div className="app-shell">
22
+ <Sidebar />
23
+ <div className="main-area">
24
+ <Header user={state.user} onLogout={() => vm.logout()} />
25
+ <div className="page-content">
26
+ <Outlet />
27
+ </div>
28
+ </div>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,25 @@
1
+ import type { UserState } from '../../types/user';
2
+
3
+ interface HeaderProps {
4
+ user: UserState;
5
+ onLogout: () => void;
6
+ }
7
+
8
+ export function Header({ user, onLogout }: HeaderProps) {
9
+ return (
10
+ <header className="header">
11
+ <div />
12
+ <div className="header-user">
13
+ <span style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
14
+ {user.firstName} {user.lastName}
15
+ </span>
16
+ <div className="avatar">
17
+ {user.firstName[0]}{user.lastName[0]}
18
+ </div>
19
+ <button className="btn btn-secondary btn-sm" onClick={onLogout}>
20
+ Logout
21
+ </button>
22
+ </div>
23
+ </header>
24
+ );
25
+ }
@@ -0,0 +1,29 @@
1
+ import { Link, useLocation } from 'react-router-dom';
2
+
3
+ const navItems = [
4
+ { path: '/dashboard', label: 'Dashboard' },
5
+ { path: '/users', label: 'Users' },
6
+ { path: '/locations', label: 'Locations' },
7
+ { path: '/messaging', label: 'Messaging' },
8
+ ];
9
+
10
+ export function Sidebar() {
11
+ const { pathname } = useLocation();
12
+
13
+ return (
14
+ <aside className="sidebar">
15
+ <div className="sidebar-logo">mvc-kit</div>
16
+ <nav className="sidebar-nav">
17
+ {navItems.map(item => (
18
+ <Link
19
+ key={item.path}
20
+ to={item.path}
21
+ className={`sidebar-link ${pathname.startsWith(item.path) ? 'active' : ''}`}
22
+ >
23
+ {item.label}
24
+ </Link>
25
+ ))}
26
+ </nav>
27
+ </aside>
28
+ );
29
+ }
@@ -0,0 +1,60 @@
1
+ import type { LocationState } from '../../types/location';
2
+
3
+ interface LocationFiltersProps {
4
+ search: string;
5
+ typeFilter: 'all' | LocationState['type'];
6
+ statusFilter: 'all' | LocationState['status'];
7
+ onSearchChange: (value: string) => void;
8
+ onTypeFilterChange: (value: 'all' | LocationState['type']) => void;
9
+ onStatusFilterChange: (value: 'all' | LocationState['status']) => void;
10
+ }
11
+
12
+ export function LocationFilters({
13
+ search,
14
+ typeFilter,
15
+ statusFilter,
16
+ onSearchChange,
17
+ onTypeFilterChange,
18
+ onStatusFilterChange,
19
+ }: LocationFiltersProps) {
20
+ return (
21
+ <div className="filters">
22
+ <div className="filter-group">
23
+ <label className="filter-label">Search</label>
24
+ <input
25
+ className="filter-input"
26
+ type="text"
27
+ value={search}
28
+ onChange={e => onSearchChange(e.target.value)}
29
+ placeholder="Search by name or city..."
30
+ />
31
+ </div>
32
+ <div className="filter-group">
33
+ <label className="filter-label">Type</label>
34
+ <select
35
+ className="filter-select"
36
+ value={typeFilter}
37
+ onChange={e => onTypeFilterChange(e.target.value as 'all' | LocationState['type'])}
38
+ >
39
+ <option value="all">All Types</option>
40
+ <option value="office">Office</option>
41
+ <option value="warehouse">Warehouse</option>
42
+ <option value="retail">Retail</option>
43
+ </select>
44
+ </div>
45
+ <div className="filter-group">
46
+ <label className="filter-label">Status</label>
47
+ <select
48
+ className="filter-select"
49
+ value={statusFilter}
50
+ onChange={e => onStatusFilterChange(e.target.value as 'all' | LocationState['status'])}
51
+ >
52
+ <option value="all">All Statuses</option>
53
+ <option value="active">Active</option>
54
+ <option value="inactive">Inactive</option>
55
+ <option value="maintenance">Maintenance</option>
56
+ </select>
57
+ </div>
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,112 @@
1
+ import { useField } from 'mvc-kit/react';
2
+ import type { LocationFormModel } from '../../models/LocationFormModel';
3
+
4
+ interface LocationFormProps {
5
+ model: LocationFormModel;
6
+ onSave: () => void;
7
+ saving: boolean;
8
+ }
9
+
10
+ export function LocationForm({ model, onSave, saving }: LocationFormProps) {
11
+ const name = useField(model, 'name');
12
+ const type = useField(model, 'type');
13
+ const city = useField(model, 'city');
14
+ const stateName = useField(model, 'state');
15
+ const address = useField(model, 'address');
16
+ const capacity = useField(model, 'capacity');
17
+
18
+ const handleSubmit = (e: React.FormEvent) => {
19
+ e.preventDefault();
20
+ onSave();
21
+ };
22
+
23
+ return (
24
+ <form onSubmit={handleSubmit}>
25
+ <div className="form-group">
26
+ <label className="form-label">Name</label>
27
+ <input
28
+ className={`form-input ${name.error ? 'error' : ''}`}
29
+ value={name.value}
30
+ onChange={e => model.setName(e.target.value)}
31
+ />
32
+ {name.error && <div className="form-error">{name.error}</div>}
33
+ </div>
34
+
35
+ <div className="form-group">
36
+ <label className="form-label">Type</label>
37
+ <select
38
+ className="form-select"
39
+ value={type.value}
40
+ onChange={e => model.setType(e.target.value as 'office' | 'warehouse' | 'retail')}
41
+ >
42
+ <option value="office">Office</option>
43
+ <option value="warehouse">Warehouse</option>
44
+ <option value="retail">Retail</option>
45
+ </select>
46
+ </div>
47
+
48
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
49
+ <div className="form-group">
50
+ <label className="form-label">City</label>
51
+ <input
52
+ className={`form-input ${city.error ? 'error' : ''}`}
53
+ value={city.value}
54
+ onChange={e => model.setCity(e.target.value)}
55
+ />
56
+ {city.error && <div className="form-error">{city.error}</div>}
57
+ </div>
58
+
59
+ <div className="form-group">
60
+ <label className="form-label">State</label>
61
+ <input
62
+ className={`form-input ${stateName.error ? 'error' : ''}`}
63
+ value={stateName.value}
64
+ onChange={e => model.setStateName(e.target.value)}
65
+ />
66
+ {stateName.error && <div className="form-error">{stateName.error}</div>}
67
+ </div>
68
+ </div>
69
+
70
+ <div className="form-group">
71
+ <label className="form-label">Address</label>
72
+ <input
73
+ className={`form-input ${address.error ? 'error' : ''}`}
74
+ value={address.value}
75
+ onChange={e => model.setAddress(e.target.value)}
76
+ />
77
+ {address.error && <div className="form-error">{address.error}</div>}
78
+ </div>
79
+
80
+ <div className="form-group">
81
+ <label className="form-label">Capacity</label>
82
+ <input
83
+ className={`form-input ${capacity.error ? 'error' : ''}`}
84
+ type="number"
85
+ value={capacity.value}
86
+ onChange={e => model.setCapacity(Number(e.target.value))}
87
+ />
88
+ {capacity.error && <div className="form-error">{capacity.error}</div>}
89
+ </div>
90
+
91
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '1rem' }}>
92
+ <button
93
+ type="submit"
94
+ className="btn btn-primary"
95
+ disabled={!model.valid || !model.dirty || saving}
96
+ >
97
+ {saving ? 'Saving...' : 'Save Changes'}
98
+ </button>
99
+ {model.dirty && <span className="dirty-indicator">Unsaved changes</span>}
100
+ {model.dirty && (
101
+ <button
102
+ type="button"
103
+ className="btn btn-secondary btn-sm"
104
+ onClick={() => model.rollback()}
105
+ >
106
+ Discard
107
+ </button>
108
+ )}
109
+ </div>
110
+ </form>
111
+ );
112
+ }
@@ -0,0 +1,81 @@
1
+ import { useParams, Link } from 'react-router-dom';
2
+ import { useLocal, useEvent } from 'mvc-kit/react';
3
+ import { LocationProfileViewModel } from '../../viewmodels/LocationProfileViewModel';
4
+ import { LocationForm } from './LocationForm';
5
+ import { Spinner } from '../shared/Spinner';
6
+ import { ErrorBanner } from '../shared/ErrorBanner';
7
+
8
+ function LocationProfileContent({ locationId }: { locationId: string }) {
9
+ const [state, vm] = useLocal(LocationProfileViewModel, {
10
+ location: null,
11
+ locationId,
12
+ });
13
+ const loadState = vm.async.load;
14
+ const saveState = vm.async.save;
15
+
16
+ useEvent(vm, 'saved', () => {
17
+ // Toast is handled in the ViewModel via AppEventBus
18
+ });
19
+
20
+ if (loadState.loading) return <Spinner large />;
21
+ if (loadState.error) return <ErrorBanner message={loadState.error} />;
22
+ if (!state.location || !vm.model) return null;
23
+
24
+ return (
25
+ <div>
26
+ <Link to="/locations" className="back-link">
27
+ &larr; Back to Locations
28
+ </Link>
29
+ <h1 className="page-title">{state.location.name}</h1>
30
+
31
+ <div className="profile-layout">
32
+ <div>
33
+ <div className="card">
34
+ <h3 className="profile-section-title">Details</h3>
35
+ <div className="detail-row">
36
+ <span className="detail-label">Type</span>
37
+ <span>{state.location.type}</span>
38
+ </div>
39
+ <div className="detail-row">
40
+ <span className="detail-label">Status</span>
41
+ <span className={`badge badge-${state.location.status}`}>
42
+ {state.location.status}
43
+ </span>
44
+ </div>
45
+ <div className="detail-row">
46
+ <span className="detail-label">City</span>
47
+ <span>{state.location.city}, {state.location.state}</span>
48
+ </div>
49
+ <div className="detail-row">
50
+ <span className="detail-label">Manager</span>
51
+ <span>{vm.managerName}</span>
52
+ </div>
53
+ <div className="detail-row">
54
+ <span className="detail-label">Capacity</span>
55
+ <span>{state.location.capacity}</span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <div>
61
+ <div className="card">
62
+ <h3 className="profile-section-title">Edit Location</h3>
63
+ {saveState.error && <ErrorBanner message={saveState.error} />}
64
+ <LocationForm
65
+ model={vm.model}
66
+ onSave={() => vm.save()}
67
+ saving={saveState.loading}
68
+ />
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ export function LocationProfilePage() {
77
+ const { id } = useParams<{ id: string }>();
78
+ if (!id) return null;
79
+ // key={id} remounts when navigating between different locations
80
+ return <LocationProfileContent key={id} locationId={id} />;
81
+ }