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.
- package/BEST_PRACTICES.md +1390 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
- package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
- package/agent-config/lib/install-claude.mjs +8 -2
- 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 +4 -2
|
@@ -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: 3002,
|
|
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 Complex 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,17 @@
|
|
|
1
|
+
import { Navbar } from './components/layout/Navbar';
|
|
2
|
+
import { DashboardPage } from './components/dashboard/DashboardPage';
|
|
3
|
+
import { ActivityFeed } from './components/activity/ActivityFeed';
|
|
4
|
+
import { Toast } from './components/shared/Toast';
|
|
5
|
+
|
|
6
|
+
export function App() {
|
|
7
|
+
return (
|
|
8
|
+
<div className="app">
|
|
9
|
+
<Navbar />
|
|
10
|
+
<main className="main-content">
|
|
11
|
+
<DashboardPage />
|
|
12
|
+
<ActivityFeed />
|
|
13
|
+
</main>
|
|
14
|
+
<Toast />
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Channel } from 'mvc-kit';
|
|
2
|
+
import { MockWebSocket } from '../mock-remote/MockWebSocket';
|
|
3
|
+
import { getActivityWSConfig } from '../mock-remote/activity-api';
|
|
4
|
+
import type { ActivityItem } from '../types/activity';
|
|
5
|
+
|
|
6
|
+
export interface ActivityMessages {
|
|
7
|
+
activity: ActivityItem;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ActivityChannel extends Channel<ActivityMessages> {
|
|
11
|
+
static override MAX_ATTEMPTS = 5;
|
|
12
|
+
|
|
13
|
+
private ws: MockWebSocket | null = null;
|
|
14
|
+
|
|
15
|
+
protected open(signal: AbortSignal): void {
|
|
16
|
+
this.ws = new MockWebSocket(getActivityWSConfig());
|
|
17
|
+
this.ws.connect((data) => this.receive('activity', data), signal);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected close(): void {
|
|
21
|
+
this.ws?.close();
|
|
22
|
+
this.ws = null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Channel } from 'mvc-kit';
|
|
2
|
+
import { MockWebSocket } from '../mock-remote/MockWebSocket';
|
|
3
|
+
import { getDashboardConfig } from '../mock-remote/dashboard-generators';
|
|
4
|
+
import type { DashboardDataPoint } from '../types/dashboard';
|
|
5
|
+
|
|
6
|
+
export interface DashboardMessages {
|
|
7
|
+
data: DashboardDataPoint;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export abstract class DashboardChannel extends Channel<DashboardMessages> {
|
|
11
|
+
static SERVICE_ID: string;
|
|
12
|
+
static override MAX_ATTEMPTS = 5;
|
|
13
|
+
|
|
14
|
+
private ws: MockWebSocket | null = null;
|
|
15
|
+
|
|
16
|
+
protected open(signal: AbortSignal): void {
|
|
17
|
+
const serviceId = (this.constructor as typeof DashboardChannel).SERVICE_ID;
|
|
18
|
+
this.ws = new MockWebSocket(getDashboardConfig(serviceId));
|
|
19
|
+
this.ws.connect((data) => this.receive('data', data), signal);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
protected close(): void {
|
|
23
|
+
this.ws?.close();
|
|
24
|
+
this.ws = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useLocal } from 'mvc-kit/react';
|
|
2
|
+
import { ActivityFeedViewModel } from '../../viewmodels/ActivityFeedViewModel';
|
|
3
|
+
import { ActivityItemRow } from './ActivityItemRow';
|
|
4
|
+
import { StatusIndicator } from '../shared/StatusIndicator';
|
|
5
|
+
import { Spinner } from '../shared/Spinner';
|
|
6
|
+
|
|
7
|
+
export function ActivityFeed() {
|
|
8
|
+
const [, vm] = useLocal(ActivityFeedViewModel);
|
|
9
|
+
const { loading, error } = vm.async.load;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="activity-feed">
|
|
13
|
+
<div className="section-header">
|
|
14
|
+
<h2>Activity Feed</h2>
|
|
15
|
+
<StatusIndicator connected={vm.isConnected} label={vm.isOnline ? 'Live' : 'Offline'} />
|
|
16
|
+
<span className="item-count">{vm.itemCount} items</span>
|
|
17
|
+
</div>
|
|
18
|
+
{loading && vm.itemCount === 0 && (
|
|
19
|
+
<div className="loading-center"><Spinner /></div>
|
|
20
|
+
)}
|
|
21
|
+
{error && (
|
|
22
|
+
<div className="error-banner">{error}</div>
|
|
23
|
+
)}
|
|
24
|
+
<div className="activity-list">
|
|
25
|
+
{vm.items.map(item => (
|
|
26
|
+
<ActivityItemRow key={item.id} item={item} />
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ActivityItem } from '../../types/activity';
|
|
2
|
+
|
|
3
|
+
interface ActivityItemRowProps {
|
|
4
|
+
item: ActivityItem;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function ActivityItemRow({ item }: ActivityItemRowProps) {
|
|
8
|
+
const timeAgo = getTimeAgo(item.timestamp);
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="activity-row">
|
|
12
|
+
<img
|
|
13
|
+
className={`activity-avatar ${item.userOnline ? '' : 'avatar-offline'}`}
|
|
14
|
+
src={item.userAvatar}
|
|
15
|
+
alt={item.userName}
|
|
16
|
+
width={36}
|
|
17
|
+
height={36}
|
|
18
|
+
/>
|
|
19
|
+
<div className="activity-content">
|
|
20
|
+
<span className="activity-text">{item.text}</span>
|
|
21
|
+
<span className="activity-time">{timeAgo}</span>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getTimeAgo(timestamp: string): string {
|
|
28
|
+
const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000);
|
|
29
|
+
if (seconds < 5) return 'just now';
|
|
30
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
31
|
+
const minutes = Math.floor(seconds / 60);
|
|
32
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
33
|
+
const hours = Math.floor(minutes / 60);
|
|
34
|
+
return `${hours}h ago`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useLocal } from 'mvc-kit/react';
|
|
2
|
+
import type { DashboardCardViewModel } from '../../viewmodels/DashboardCardViewModel';
|
|
3
|
+
import { StatusIndicator } from '../shared/StatusIndicator';
|
|
4
|
+
|
|
5
|
+
interface DashboardCardProps {
|
|
6
|
+
VMClass: new () => DashboardCardViewModel;
|
|
7
|
+
title: string;
|
|
8
|
+
icon: string;
|
|
9
|
+
unit?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function DashboardCard({ VMClass, title, icon, unit }: DashboardCardProps) {
|
|
13
|
+
const [, vm] = useLocal(VMClass);
|
|
14
|
+
|
|
15
|
+
const trendArrow = vm.trend === 'up' ? '\u2191' : vm.trend === 'down' ? '\u2193' : '\u2192';
|
|
16
|
+
const trendClass = `trend-${vm.trend}`;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className={`card ${vm.isOnline ? '' : 'card-disconnected'}`}>
|
|
20
|
+
<div className="card-header">
|
|
21
|
+
<span className="card-icon">{icon}</span>
|
|
22
|
+
<span className="card-title">{title}</span>
|
|
23
|
+
<StatusIndicator connected={vm.isConnected} />
|
|
24
|
+
</div>
|
|
25
|
+
<div className="card-value">
|
|
26
|
+
<span className="card-number">
|
|
27
|
+
{vm.latestValue.toLocaleString()}
|
|
28
|
+
</span>
|
|
29
|
+
{unit && <span className="card-unit">{unit}</span>}
|
|
30
|
+
<span className={`card-trend ${trendClass}`}>{trendArrow}</span>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="card-footer">
|
|
33
|
+
{vm.dataPointCount} data points
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { DashboardCard } from './DashboardCard';
|
|
2
|
+
import type { DashboardCardViewModel } from '../../viewmodels/DashboardCardViewModel';
|
|
3
|
+
import { OrdersCardViewModel } from '../../viewmodels/OrdersCardViewModel';
|
|
4
|
+
import { RevenueCardViewModel } from '../../viewmodels/RevenueCardViewModel';
|
|
5
|
+
import { UsersMetricCardViewModel } from '../../viewmodels/UsersMetricCardViewModel';
|
|
6
|
+
import { ErrorsCardViewModel } from '../../viewmodels/ErrorsCardViewModel';
|
|
7
|
+
import { LatencyCardViewModel } from '../../viewmodels/LatencyCardViewModel';
|
|
8
|
+
import { TrafficCardViewModel } from '../../viewmodels/TrafficCardViewModel';
|
|
9
|
+
|
|
10
|
+
interface CardConfig {
|
|
11
|
+
VMClass: new () => DashboardCardViewModel;
|
|
12
|
+
title: string;
|
|
13
|
+
icon: string;
|
|
14
|
+
unit?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const cards: CardConfig[] = [
|
|
18
|
+
{ VMClass: OrdersCardViewModel, title: 'Orders', icon: '\uD83D\uDCE6', unit: '/hr' },
|
|
19
|
+
{ VMClass: RevenueCardViewModel, title: 'Revenue', icon: '\uD83D\uDCB0', unit: '$' },
|
|
20
|
+
{ VMClass: UsersMetricCardViewModel, title: 'Active Users', icon: '\uD83D\uDC65' },
|
|
21
|
+
{ VMClass: ErrorsCardViewModel, title: 'Errors', icon: '\u26A0\uFE0F', unit: '/min' },
|
|
22
|
+
{ VMClass: LatencyCardViewModel, title: 'Latency', icon: '\u23F1\uFE0F', unit: 'ms' },
|
|
23
|
+
{ VMClass: TrafficCardViewModel, title: 'Traffic', icon: '\uD83C\uDF10', unit: 'req/s' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function DashboardPage() {
|
|
27
|
+
return (
|
|
28
|
+
<div className="dashboard-grid">
|
|
29
|
+
{cards.map(({ VMClass, title, icon, unit }) => (
|
|
30
|
+
<DashboardCard key={title} VMClass={VMClass} title={title} icon={icon} unit={unit} />
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
2
|
+
import { AppStateViewModel } from '../../viewmodels/AppStateViewModel';
|
|
3
|
+
import { StatusIndicator } from '../shared/StatusIndicator';
|
|
4
|
+
import { SocialFeedPanel } from './SocialFeedPanel';
|
|
5
|
+
|
|
6
|
+
export function Navbar() {
|
|
7
|
+
const [state, vm] = useSingleton(AppStateViewModel);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<nav className="navbar">
|
|
12
|
+
<span className="navbar-brand">ComplexApp</span>
|
|
13
|
+
<div className="navbar-actions">
|
|
14
|
+
<StatusIndicator connected={state.online} label={state.online ? 'Online' : 'Offline'} />
|
|
15
|
+
<button
|
|
16
|
+
className={`btn ${state.online ? 'btn-danger' : 'btn-success'}`}
|
|
17
|
+
onClick={() => vm.toggleOnline()}
|
|
18
|
+
>
|
|
19
|
+
{state.online ? 'Go Offline' : 'Go Online'}
|
|
20
|
+
</button>
|
|
21
|
+
<button
|
|
22
|
+
className={`btn btn-secondary ${state.socialPanelOpen ? 'btn-active' : ''}`}
|
|
23
|
+
onClick={() => vm.toggleSocialPanel()}
|
|
24
|
+
>
|
|
25
|
+
Social Feed
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
</nav>
|
|
29
|
+
{state.socialPanelOpen && <SocialFeedPanel />}
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useLocal } from 'mvc-kit/react';
|
|
2
|
+
import { InfiniteScroll, CardList } from 'mvc-kit/react';
|
|
3
|
+
import { SocialFeedViewModel } from '../../viewmodels/SocialFeedViewModel';
|
|
4
|
+
import { Spinner } from '../shared/Spinner';
|
|
5
|
+
import type { SocialPost } from '../../types/social';
|
|
6
|
+
|
|
7
|
+
function PostCard({ post }: { post: SocialPost }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="social-post">
|
|
10
|
+
<div className="social-post-header">
|
|
11
|
+
<img
|
|
12
|
+
className="social-avatar"
|
|
13
|
+
src={post.avatarUrl}
|
|
14
|
+
alt={post.author}
|
|
15
|
+
width={32}
|
|
16
|
+
height={32}
|
|
17
|
+
/>
|
|
18
|
+
<span className="social-author">{post.author}</span>
|
|
19
|
+
</div>
|
|
20
|
+
<p className="social-content">{post.content}</p>
|
|
21
|
+
<div className="social-footer">
|
|
22
|
+
<span className="social-likes">{'\u2764\uFE0F'} {post.likes}</span>
|
|
23
|
+
<span className="social-time">
|
|
24
|
+
{new Date(post.timestamp).toLocaleTimeString()}
|
|
25
|
+
</span>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SocialFeedPanel() {
|
|
32
|
+
const [state, vm] = useLocal(SocialFeedViewModel, { page: 0 });
|
|
33
|
+
const { loading } = vm.async.loadMore;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="social-feed-panel">
|
|
37
|
+
<h3 className="social-feed-title">Social Feed</h3>
|
|
38
|
+
<InfiniteScroll
|
|
39
|
+
hasMore={vm.hasMore}
|
|
40
|
+
loading={loading}
|
|
41
|
+
onLoadMore={() => vm.loadMore()}
|
|
42
|
+
renderLoading={() => (
|
|
43
|
+
<div className="loading-center"><Spinner /></div>
|
|
44
|
+
)}
|
|
45
|
+
renderEnd={() =>
|
|
46
|
+
vm.postCount > 0 ? <p className="end-of-feed">No more posts</p> : null
|
|
47
|
+
}
|
|
48
|
+
>
|
|
49
|
+
<CardList
|
|
50
|
+
items={vm.posts}
|
|
51
|
+
keyOf={(post: SocialPost) => post.id}
|
|
52
|
+
renderItem={(post: SocialPost) => <PostCard post={post} />}
|
|
53
|
+
/>
|
|
54
|
+
</InfiniteScroll>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface StatusIndicatorProps {
|
|
2
|
+
connected: boolean;
|
|
3
|
+
label?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function StatusIndicator({ connected, label }: StatusIndicatorProps) {
|
|
7
|
+
return (
|
|
8
|
+
<span className="status-indicator">
|
|
9
|
+
<span className={`status-dot ${connected ? 'status-dot-online' : 'status-dot-offline'}`} />
|
|
10
|
+
{label && <span className="status-label">{label}</span>}
|
|
11
|
+
</span>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { useSingleton, useEvent } from 'mvc-kit/react';
|
|
3
|
+
import { AppEventBus } from '../../events/AppEventBus';
|
|
4
|
+
|
|
5
|
+
interface ToastItem {
|
|
6
|
+
id: number;
|
|
7
|
+
message: string;
|
|
8
|
+
type: 'info' | 'success' | 'error';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let nextId = 0;
|
|
12
|
+
|
|
13
|
+
export function Toast() {
|
|
14
|
+
const bus = useSingleton(AppEventBus);
|
|
15
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
16
|
+
|
|
17
|
+
const addToast = useCallback((item: Omit<ToastItem, 'id'>) => {
|
|
18
|
+
const id = nextId++;
|
|
19
|
+
setToasts(prev => [...prev, { ...item, id }]);
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
setToasts(prev => prev.filter(t => t.id !== id));
|
|
22
|
+
}, 3000);
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
useEvent(bus, 'toast:show', ({ message, type }) => {
|
|
26
|
+
addToast({ message, type: type ?? 'info' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (toasts.length === 0) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="toast-container">
|
|
33
|
+
{toasts.map(t => (
|
|
34
|
+
<div key={t.id} className={`toast toast-${t.type}`}>
|
|
35
|
+
{t.message}
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -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,38 @@
|
|
|
1
|
+
export interface MockWSConfig {
|
|
2
|
+
minInterval: number;
|
|
3
|
+
maxInterval: number;
|
|
4
|
+
generator: () => any;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class MockWebSocket {
|
|
8
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
9
|
+
private closed = false;
|
|
10
|
+
|
|
11
|
+
constructor(private config: MockWSConfig) {}
|
|
12
|
+
|
|
13
|
+
connect(onMessage: (data: any) => void, signal?: AbortSignal): void {
|
|
14
|
+
if (signal?.aborted) return;
|
|
15
|
+
signal?.addEventListener('abort', () => this.close(), { once: true });
|
|
16
|
+
this.scheduleNext(onMessage);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
close(): void {
|
|
20
|
+
if (this.closed) return;
|
|
21
|
+
this.closed = true;
|
|
22
|
+
if (this.timer !== null) {
|
|
23
|
+
clearTimeout(this.timer);
|
|
24
|
+
this.timer = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private scheduleNext(onMessage: (data: any) => void): void {
|
|
29
|
+
if (this.closed) return;
|
|
30
|
+
const { minInterval, maxInterval, generator } = this.config;
|
|
31
|
+
const interval = minInterval + Math.random() * (maxInterval - minInterval);
|
|
32
|
+
this.timer = setTimeout(() => {
|
|
33
|
+
if (this.closed) return;
|
|
34
|
+
onMessage(generator());
|
|
35
|
+
this.scheduleNext(onMessage);
|
|
36
|
+
}, interval);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ActivityItem } from '../types/activity';
|
|
2
|
+
import type { MockWSConfig } from './MockWebSocket';
|
|
3
|
+
import { mockFetch } from './delay';
|
|
4
|
+
|
|
5
|
+
const NAMES = [
|
|
6
|
+
'Alice Johnson', 'Bob Smith', 'Carol White', 'Dave Brown', 'Eve Davis',
|
|
7
|
+
'Frank Wilson', 'Grace Lee', 'Hank Taylor', 'Ivy Martin', 'Jack Anderson',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const ACTIONS = [
|
|
11
|
+
'created a new order', 'updated their profile', 'submitted a report',
|
|
12
|
+
'closed a ticket', 'uploaded a document', 'left a comment',
|
|
13
|
+
'approved a request', 'joined the team', 'completed a task', 'shared a file',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function randomItem<T>(arr: T[]): T {
|
|
17
|
+
return arr[Math.floor(Math.random() * arr.length)]!;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let activitySeq = 0;
|
|
21
|
+
|
|
22
|
+
function generateActivity(): ActivityItem {
|
|
23
|
+
const name = randomItem(NAMES);
|
|
24
|
+
return {
|
|
25
|
+
id: `act-${++activitySeq}`,
|
|
26
|
+
text: `${name} ${randomItem(ACTIONS)}`,
|
|
27
|
+
userName: name,
|
|
28
|
+
userAvatar: `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`,
|
|
29
|
+
userOnline: Math.random() > 0.3,
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function fetchInitialActivity(signal?: AbortSignal): Promise<ActivityItem[]> {
|
|
35
|
+
const items: ActivityItem[] = [];
|
|
36
|
+
for (let i = 0; i < 50; i++) {
|
|
37
|
+
items.push(generateActivity());
|
|
38
|
+
}
|
|
39
|
+
return mockFetch(items, 600, signal);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getActivityWSConfig(): MockWSConfig {
|
|
43
|
+
return {
|
|
44
|
+
minInterval: 1000,
|
|
45
|
+
maxInterval: 3000,
|
|
46
|
+
generator: generateActivity,
|
|
47
|
+
};
|
|
48
|
+
}
|