mvc-kit 2.13.0 → 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.
Files changed (200) hide show
  1. package/BEST_PRACTICES.md +1390 -0
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
  3. package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
  4. package/agent-config/lib/install-claude.mjs +8 -2
  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
@@ -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
+ }
@@ -0,0 +1,181 @@
1
+ import { useState } from 'react';
2
+ import { useSingleton, useModel } from 'mvc-kit/react';
3
+ import { AuthViewModel } from '../viewmodels/AuthViewModel';
4
+ import { LoginFormModel } from '../models/LoginFormModel';
5
+ import { RegisterFormModel } from '../models/RegisterFormModel';
6
+
7
+ type AuthView = 'login' | 'register';
8
+
9
+ export function AuthScreen() {
10
+ // Ephemeral UI state — which form to show. This is acceptable as plain useState
11
+ // because it's purely presentational (not domain state).
12
+ const [view, setView] = useState<AuthView>('login');
13
+
14
+ return (
15
+ <div className="auth-page">
16
+ <div className="auth-card">
17
+ <h1 className="auth-title">mvc-kit Auth</h1>
18
+ {view === 'login' ? (
19
+ <LoginView onSwitchToRegister={() => setView('register')} />
20
+ ) : (
21
+ <RegisterView onSwitchToLogin={() => setView('login')} />
22
+ )}
23
+ </div>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ function LoginView({ onSwitchToRegister }: { onSwitchToRegister: () => void }) {
29
+ const [, authVM] = useSingleton(AuthViewModel);
30
+ const { loading, error } = authVM.async.login;
31
+ const { state, errors, valid, dirty, model } = useModel(
32
+ () => new LoginFormModel({ email: '', password: '' }),
33
+ );
34
+
35
+ const handleSubmit = (e: React.FormEvent) => {
36
+ e.preventDefault();
37
+ if (!valid) return;
38
+ authVM.login(state.email, state.password);
39
+ };
40
+
41
+ return (
42
+ <>
43
+ <p className="auth-subtitle">
44
+ Sign in with <code>admin@example.com</code>, <code>manager@example.com</code>,
45
+ or <code>user@example.com</code> (password: <code>password</code>)
46
+ </p>
47
+
48
+ {error && <div className="error-banner">{error}</div>}
49
+
50
+ <form onSubmit={handleSubmit}>
51
+ <div className="form-group">
52
+ <label className="form-label">Email</label>
53
+ <input
54
+ className={`form-input ${errors.email && dirty ? 'error' : ''}`}
55
+ type="text"
56
+ value={state.email}
57
+ onChange={e => model.setEmail(e.target.value)}
58
+ placeholder="admin@example.com"
59
+ />
60
+ {errors.email && dirty && <div className="form-error">{errors.email}</div>}
61
+ </div>
62
+
63
+ <div className="form-group">
64
+ <label className="form-label">Password</label>
65
+ <input
66
+ className={`form-input ${errors.password && dirty ? 'error' : ''}`}
67
+ type="password"
68
+ value={state.password}
69
+ onChange={e => model.setPassword(e.target.value)}
70
+ placeholder="6+ characters"
71
+ />
72
+ {errors.password && dirty && <div className="form-error">{errors.password}</div>}
73
+ </div>
74
+
75
+ <button
76
+ type="submit"
77
+ className="btn btn-primary"
78
+ style={{ width: '100%', marginTop: '0.5rem' }}
79
+ disabled={!valid || loading}
80
+ >
81
+ {loading ? 'Signing in...' : 'Sign In'}
82
+ </button>
83
+ </form>
84
+
85
+ <p className="auth-toggle">
86
+ Don't have an account?{' '}
87
+ <button type="button" className="link" onClick={onSwitchToRegister}>
88
+ Register
89
+ </button>
90
+ </p>
91
+ </>
92
+ );
93
+ }
94
+
95
+ function RegisterView({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
96
+ const [, authVM] = useSingleton(AuthViewModel);
97
+ const { loading, error } = authVM.async.register;
98
+ const { state, errors, valid, dirty, model } = useModel(
99
+ () => new RegisterFormModel({ name: '', email: '', password: '', confirmPassword: '' }),
100
+ );
101
+
102
+ const handleSubmit = (e: React.FormEvent) => {
103
+ e.preventDefault();
104
+ if (!valid) return;
105
+ authVM.register(state.name, state.email, state.password);
106
+ };
107
+
108
+ return (
109
+ <>
110
+ <p className="auth-subtitle">Create a new account</p>
111
+
112
+ {error && <div className="error-banner">{error}</div>}
113
+
114
+ <form onSubmit={handleSubmit}>
115
+ <div className="form-group">
116
+ <label className="form-label">Name</label>
117
+ <input
118
+ className={`form-input ${errors.name && dirty ? 'error' : ''}`}
119
+ type="text"
120
+ value={state.name}
121
+ onChange={e => model.setName(e.target.value)}
122
+ placeholder="Your name"
123
+ />
124
+ {errors.name && dirty && <div className="form-error">{errors.name}</div>}
125
+ </div>
126
+
127
+ <div className="form-group">
128
+ <label className="form-label">Email</label>
129
+ <input
130
+ className={`form-input ${errors.email && dirty ? 'error' : ''}`}
131
+ type="text"
132
+ value={state.email}
133
+ onChange={e => model.setEmail(e.target.value)}
134
+ placeholder="you@example.com"
135
+ />
136
+ {errors.email && dirty && <div className="form-error">{errors.email}</div>}
137
+ </div>
138
+
139
+ <div className="form-group">
140
+ <label className="form-label">Password</label>
141
+ <input
142
+ className={`form-input ${errors.password && dirty ? 'error' : ''}`}
143
+ type="password"
144
+ value={state.password}
145
+ onChange={e => model.setPassword(e.target.value)}
146
+ placeholder="6+ characters"
147
+ />
148
+ {errors.password && dirty && <div className="form-error">{errors.password}</div>}
149
+ </div>
150
+
151
+ <div className="form-group">
152
+ <label className="form-label">Confirm Password</label>
153
+ <input
154
+ className={`form-input ${errors.confirmPassword && dirty ? 'error' : ''}`}
155
+ type="password"
156
+ value={state.confirmPassword}
157
+ onChange={e => model.setConfirmPassword(e.target.value)}
158
+ placeholder="Repeat password"
159
+ />
160
+ {errors.confirmPassword && dirty && <div className="form-error">{errors.confirmPassword}</div>}
161
+ </div>
162
+
163
+ <button
164
+ type="submit"
165
+ className="btn btn-primary"
166
+ style={{ width: '100%', marginTop: '0.5rem' }}
167
+ disabled={!valid || loading}
168
+ >
169
+ {loading ? 'Creating account...' : 'Register'}
170
+ </button>
171
+ </form>
172
+
173
+ <p className="auth-toggle">
174
+ Already have an account?{' '}
175
+ <button type="button" className="link" onClick={onSwitchToLogin}>
176
+ Sign in
177
+ </button>
178
+ </p>
179
+ </>
180
+ );
181
+ }
@@ -0,0 +1,41 @@
1
+ import { useSingleton } from 'mvc-kit/react';
2
+ import { AuthViewModel } from '../viewmodels/AuthViewModel';
3
+
4
+ export function DashboardPage() {
5
+ const [state, vm] = useSingleton(AuthViewModel);
6
+
7
+ return (
8
+ <div className="page-content">
9
+ <h1 className="page-title">Dashboard</h1>
10
+
11
+ <div className="card" style={{ marginBottom: '1.5rem' }}>
12
+ <h2 style={{ marginBottom: '0.5rem' }}>Welcome, {vm.displayName}!</h2>
13
+ <p style={{ color: 'var(--color-text-secondary)' }}>
14
+ You are signed in as <strong>{state.user!.email}</strong> with
15
+ the <span className={`badge badge-${state.user!.role}`}>{state.user!.role}</span> role.
16
+ </p>
17
+ </div>
18
+
19
+ <div className="stats-grid">
20
+ <div className="stat-card">
21
+ <div className="stat-label">Role</div>
22
+ <div className="stat-value" style={{ fontSize: '1.25rem' }}>{vm.userRole}</div>
23
+ </div>
24
+ <div className="stat-card">
25
+ <div className="stat-label">Admin Access</div>
26
+ <div className="stat-value" style={{ fontSize: '1.25rem' }}>{vm.isAdmin ? 'Yes' : 'No'}</div>
27
+ </div>
28
+ </div>
29
+
30
+ <div className="card">
31
+ <h3 style={{ marginBottom: '0.75rem' }}>Auth Pattern Highlights</h3>
32
+ <ul style={{ paddingLeft: '1.25rem', color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
33
+ <li><code>isAuthenticated</code> is a getter (derived from state), not stored state</li>
34
+ <li>Session restored via <code>onInit()</code>, not useEffect</li>
35
+ <li><code>&lt;AuthGuard&gt;</code> uses composition — no redirects, URL preserved</li>
36
+ <li>Navigate to <code>/admin</code> to see role-based access (inline, not route-guarded)</li>
37
+ </ul>
38
+ </div>
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,44 @@
1
+ import { useSingleton } from 'mvc-kit/react';
2
+ import { AuthViewModel } from '../viewmodels/AuthViewModel';
3
+
4
+ export function ProfilePage() {
5
+ const [state, vm] = useSingleton(AuthViewModel);
6
+ const user = state.user!;
7
+
8
+ return (
9
+ <div className="page-content">
10
+ <h1 className="page-title">Profile</h1>
11
+
12
+ <div className="card profile-card">
13
+ <div className="profile-header">
14
+ <div className="avatar avatar-lg">{vm.initials}</div>
15
+ <div>
16
+ <h2>{vm.displayName}</h2>
17
+ <p style={{ color: 'var(--color-text-secondary)' }}>{user.email}</p>
18
+ </div>
19
+ </div>
20
+
21
+ <div className="profile-details">
22
+ <div className="detail-row">
23
+ <span className="detail-label">Role</span>
24
+ <span className={`badge badge-${user.role}`}>{user.role}</span>
25
+ </div>
26
+ <div className="detail-row">
27
+ <span className="detail-label">Member Since</span>
28
+ <span>{new Date(user.createdAt).toLocaleDateString()}</span>
29
+ </div>
30
+ <div className="detail-row">
31
+ <span className="detail-label">User ID</span>
32
+ <span style={{ fontFamily: 'monospace' }}>{user.id}</span>
33
+ </div>
34
+ </div>
35
+
36
+ <div style={{ marginTop: '1.5rem' }}>
37
+ <button type="button" className="btn btn-danger" onClick={vm.logout}>
38
+ Logout
39
+ </button>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,41 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { useSingleton } from 'mvc-kit/react';
3
+ import { useEvent } from 'mvc-kit/react';
4
+ import { AppEventBus } from '../events/AppEventBus';
5
+
6
+ interface ToastItem {
7
+ id: number;
8
+ message: string;
9
+ severity: 'success' | 'error' | 'info';
10
+ }
11
+
12
+ let nextId = 0;
13
+
14
+ export function Toast() {
15
+ const bus = useSingleton(AppEventBus);
16
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
17
+
18
+ const addToast = useCallback((item: Omit<ToastItem, 'id'>) => {
19
+ const id = nextId++;
20
+ setToasts(prev => [...prev, { ...item, id }]);
21
+ setTimeout(() => {
22
+ setToasts(prev => prev.filter(t => t.id !== id));
23
+ }, 3000);
24
+ }, []);
25
+
26
+ useEvent(bus, 'toast:show', ({ message, severity }) => {
27
+ addToast({ message, severity });
28
+ });
29
+
30
+ if (toasts.length === 0) return null;
31
+
32
+ return (
33
+ <div className="toast-container">
34
+ {toasts.map(t => (
35
+ <div key={t.id} className={`toast toast-${t.severity}`}>
36
+ {t.message}
37
+ </div>
38
+ ))}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,10 @@
1
+ declare const __MVC_KIT_DEV__: boolean;
2
+
3
+ declare module 'react-dom/client' {
4
+ import type { ReactNode } from 'react';
5
+ interface Root {
6
+ render(children: ReactNode): void;
7
+ unmount(): void;
8
+ }
9
+ export function createRoot(container: Element): Root;
10
+ }
@@ -0,0 +1,7 @@
1
+ import { EventBus } from 'mvc-kit';
2
+
3
+ export interface AppEvents {
4
+ 'toast:show': { message: string; severity: 'success' | 'error' | 'info' };
5
+ }
6
+
7
+ export class AppEventBus extends EventBus<AppEvents> {}
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { App } from './App';
4
+ import './styles.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );