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.
- package/BEST_PRACTICES.md +1390 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +8 -3
- package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +10 -1
- package/agent-config/lib/install-claude.mjs +39 -110
- 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
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +0 -0
- /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Channel } from 'mvc-kit';
|
|
2
|
+
import { MockWebSocket } from '../mock/MockWebSocket';
|
|
3
|
+
import { MOCK_WORKERS } from '../mock/workers';
|
|
4
|
+
import type { MessageState } from '../types/message';
|
|
5
|
+
|
|
6
|
+
export interface MessagingMessages {
|
|
7
|
+
newMessage: MessageState;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class MessagingChannel extends Channel<MessagingMessages> {
|
|
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({
|
|
17
|
+
minInterval: 8000,
|
|
18
|
+
maxInterval: 20000,
|
|
19
|
+
generator: () => {
|
|
20
|
+
const senders = MOCK_WORKERS.filter(w => w.id !== 'w1');
|
|
21
|
+
const sender = senders[Math.floor(Math.random() * senders.length)]!;
|
|
22
|
+
const texts = [
|
|
23
|
+
'Heading to the site now',
|
|
24
|
+
'Need help with the wiring on floor 2',
|
|
25
|
+
'Supplies just arrived',
|
|
26
|
+
'Taking a quick break',
|
|
27
|
+
'All clear on the safety check',
|
|
28
|
+
'Can someone cover my shift tomorrow?',
|
|
29
|
+
];
|
|
30
|
+
return {
|
|
31
|
+
id: `live-${Date.now()}`,
|
|
32
|
+
conversationId: 'conv1',
|
|
33
|
+
senderId: sender.id,
|
|
34
|
+
text: texts[Math.floor(Math.random() * texts.length)]!,
|
|
35
|
+
sentAt: Date.now(),
|
|
36
|
+
} satisfies MessageState;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
this.ws.connect((data) => this.receive('newMessage', data), signal);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
protected close(): void {
|
|
43
|
+
this.ws?.close();
|
|
44
|
+
this.ws = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Channel } from 'mvc-kit';
|
|
2
|
+
import { MockWebSocket } from '../mock/MockWebSocket';
|
|
3
|
+
import { MOCK_WORKERS } from '../mock/workers';
|
|
4
|
+
import type { WorkerState } from '../types/worker';
|
|
5
|
+
|
|
6
|
+
export interface WorkerStatusMessages {
|
|
7
|
+
statusChange: { workerId: string; status: WorkerState['status'] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class WorkerStatusChannel extends Channel<WorkerStatusMessages> {
|
|
11
|
+
static override MAX_ATTEMPTS = 5;
|
|
12
|
+
|
|
13
|
+
private ws: MockWebSocket | null = null;
|
|
14
|
+
|
|
15
|
+
protected open(signal: AbortSignal): void {
|
|
16
|
+
const statuses: WorkerState['status'][] = ['available', 'on-shift', 'on-break', 'offline'];
|
|
17
|
+
this.ws = new MockWebSocket({
|
|
18
|
+
minInterval: 10000,
|
|
19
|
+
maxInterval: 30000,
|
|
20
|
+
generator: () => {
|
|
21
|
+
const worker = MOCK_WORKERS[Math.floor(Math.random() * MOCK_WORKERS.length)]!;
|
|
22
|
+
return {
|
|
23
|
+
workerId: worker.id,
|
|
24
|
+
status: statuses[Math.floor(Math.random() * statuses.length)]!,
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
this.ws.connect((data) => this.receive('statusChange', data), signal);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
protected close(): void {
|
|
32
|
+
this.ws?.close();
|
|
33
|
+
this.ws = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
4
|
+
import { AuthViewModel } from '../../viewmodels/AuthViewModel';
|
|
5
|
+
import { MOCK_WORKERS } from '../../mock/workers';
|
|
6
|
+
|
|
7
|
+
export function LoginPage() {
|
|
8
|
+
const [authState, authVM] = useSingleton(AuthViewModel);
|
|
9
|
+
const { loading } = authVM.async.login;
|
|
10
|
+
const [selectedEmail, setSelectedEmail] = useState(MOCK_WORKERS[0]!.email);
|
|
11
|
+
const navigate = useNavigate();
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (authState.isAuthenticated) {
|
|
15
|
+
navigate('/shift');
|
|
16
|
+
}
|
|
17
|
+
}, [authState.isAuthenticated, navigate]);
|
|
18
|
+
|
|
19
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
authVM.login(selectedEmail);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="login-page">
|
|
26
|
+
<div className="login-card">
|
|
27
|
+
<h1 className="login-title">WorkerApp</h1>
|
|
28
|
+
<p className="login-subtitle">
|
|
29
|
+
Select a worker to log in as
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
<form onSubmit={handleSubmit}>
|
|
33
|
+
<div className="form-group">
|
|
34
|
+
<label className="form-label">Worker</label>
|
|
35
|
+
<select
|
|
36
|
+
className="form-select"
|
|
37
|
+
value={selectedEmail}
|
|
38
|
+
onChange={e => setSelectedEmail(e.target.value)}
|
|
39
|
+
>
|
|
40
|
+
{MOCK_WORKERS.map(w => (
|
|
41
|
+
<option key={w.id} value={w.email}>
|
|
42
|
+
{w.name} — {w.role}
|
|
43
|
+
</option>
|
|
44
|
+
))}
|
|
45
|
+
</select>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<button
|
|
49
|
+
type="submit"
|
|
50
|
+
className="btn btn-primary"
|
|
51
|
+
style={{ width: '100%', marginTop: '0.5rem' }}
|
|
52
|
+
disabled={loading}
|
|
53
|
+
>
|
|
54
|
+
{loading ? 'Signing in...' : 'Sign In'}
|
|
55
|
+
</button>
|
|
56
|
+
</form>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -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.worker) return null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="app-shell">
|
|
22
|
+
<Sidebar />
|
|
23
|
+
<div className="main-area">
|
|
24
|
+
<Header worker={state.worker} onLogout={() => vm.logout()} />
|
|
25
|
+
<div className="page-content">
|
|
26
|
+
<Outlet />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { WorkerState } from '../../types/worker';
|
|
2
|
+
|
|
3
|
+
interface HeaderProps {
|
|
4
|
+
worker: WorkerState;
|
|
5
|
+
onLogout: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Header({ worker, 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
|
+
{worker.name}
|
|
15
|
+
</span>
|
|
16
|
+
<div className="avatar">{worker.avatar}</div>
|
|
17
|
+
<button className="btn btn-secondary btn-sm" onClick={onLogout}>
|
|
18
|
+
Logout
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
</header>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
2
|
+
|
|
3
|
+
const navItems = [
|
|
4
|
+
{ path: '/shift', label: 'My Shift' },
|
|
5
|
+
{ path: '/sites', label: 'Work Sites' },
|
|
6
|
+
{ path: '/messaging', label: 'Messaging' },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export function Sidebar() {
|
|
10
|
+
const { pathname } = useLocation();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<aside className="sidebar">
|
|
14
|
+
<div className="sidebar-logo">WorkerApp</div>
|
|
15
|
+
<nav className="sidebar-nav">
|
|
16
|
+
{navItems.map(item => (
|
|
17
|
+
<Link
|
|
18
|
+
key={item.path}
|
|
19
|
+
to={item.path}
|
|
20
|
+
className={`sidebar-link ${pathname.startsWith(item.path) ? 'active' : ''}`}
|
|
21
|
+
>
|
|
22
|
+
{item.label}
|
|
23
|
+
</Link>
|
|
24
|
+
))}
|
|
25
|
+
</nav>
|
|
26
|
+
</aside>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useModel } from 'mvc-kit/react';
|
|
2
|
+
import { ComposeMessageModel } from '../../models/ComposeMessageModel';
|
|
3
|
+
|
|
4
|
+
interface ComposeBarProps {
|
|
5
|
+
onSend: (text: string) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ComposeBar({ onSend }: ComposeBarProps) {
|
|
9
|
+
const { state, valid, model } = useModel(
|
|
10
|
+
() => new ComposeMessageModel({ text: '' }),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
if (!valid) return;
|
|
16
|
+
onSend(state.text);
|
|
17
|
+
model.setText('');
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<form className="message-compose" onSubmit={handleSubmit}>
|
|
22
|
+
<input
|
|
23
|
+
type="text"
|
|
24
|
+
value={state.text}
|
|
25
|
+
onChange={e => model.setText(e.target.value)}
|
|
26
|
+
placeholder="Type a message..."
|
|
27
|
+
/>
|
|
28
|
+
<button type="submit" className="btn btn-primary" disabled={!valid}>
|
|
29
|
+
Send
|
|
30
|
+
</button>
|
|
31
|
+
</form>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { CardList } from 'mvc-kit/react';
|
|
2
|
+
import type { ConversationDisplay } from '../../viewmodels/ConversationsViewModel';
|
|
3
|
+
|
|
4
|
+
interface ConversationListProps {
|
|
5
|
+
conversations: ConversationDisplay[];
|
|
6
|
+
selectedId: string | null;
|
|
7
|
+
onSelect: (id: string) => void;
|
|
8
|
+
search: string;
|
|
9
|
+
onSearchChange: (value: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ConversationList({
|
|
13
|
+
conversations,
|
|
14
|
+
selectedId,
|
|
15
|
+
onSelect,
|
|
16
|
+
search,
|
|
17
|
+
onSearchChange,
|
|
18
|
+
}: ConversationListProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="conversation-list">
|
|
21
|
+
<div className="conversation-list-header">
|
|
22
|
+
<input
|
|
23
|
+
className="filter-input"
|
|
24
|
+
style={{ width: '100%', minWidth: 0 }}
|
|
25
|
+
type="text"
|
|
26
|
+
value={search}
|
|
27
|
+
onChange={e => onSearchChange(e.target.value)}
|
|
28
|
+
placeholder="Search conversations..."
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="conversation-list-items">
|
|
32
|
+
<CardList
|
|
33
|
+
items={conversations}
|
|
34
|
+
keyOf={c => c.id}
|
|
35
|
+
renderItem={conv => (
|
|
36
|
+
<div
|
|
37
|
+
className={`conversation-item ${conv.id === selectedId ? 'selected' : ''}`}
|
|
38
|
+
onClick={() => onSelect(conv.id)}
|
|
39
|
+
>
|
|
40
|
+
<div className="conversation-item-header">
|
|
41
|
+
<span className="conversation-item-name">{conv.displayName}</span>
|
|
42
|
+
{conv.unreadCount > 0 && (
|
|
43
|
+
<span className="conversation-unread">{conv.unreadCount}</span>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
<div className="conversation-item-preview">{conv.lastMessage}</div>
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
renderEmpty={() => (
|
|
50
|
+
<div className="empty-state" style={{ padding: '1.5rem' }}>
|
|
51
|
+
No conversations found.
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
className="conversation-card-list"
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MessageState } from '../../types/message';
|
|
2
|
+
import type { PendingOperation } from 'mvc-kit';
|
|
3
|
+
|
|
4
|
+
interface MessageBubbleProps {
|
|
5
|
+
message: MessageState;
|
|
6
|
+
isMine: boolean;
|
|
7
|
+
senderName: string;
|
|
8
|
+
pendingStatus?: PendingOperation | null;
|
|
9
|
+
onRetry?: () => void;
|
|
10
|
+
onDismiss?: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function MessageBubble({ message, isMine, senderName, pendingStatus, onRetry, onDismiss }: MessageBubbleProps) {
|
|
14
|
+
const time = new Date(message.sentAt).toLocaleTimeString([], {
|
|
15
|
+
hour: '2-digit',
|
|
16
|
+
minute: '2-digit',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className={`message-bubble ${isMine ? 'mine' : 'theirs'}`}>
|
|
21
|
+
{!isMine && <div className="message-sender">{senderName}</div>}
|
|
22
|
+
<div>{message.text}</div>
|
|
23
|
+
<div className="message-time">
|
|
24
|
+
{time}
|
|
25
|
+
{pendingStatus && (
|
|
26
|
+
<span className="message-status">
|
|
27
|
+
{pendingStatus.status === 'active' && ' · Sending...'}
|
|
28
|
+
{pendingStatus.status === 'retrying' && ` · Retrying (${pendingStatus.attempts}/${pendingStatus.maxRetries})...`}
|
|
29
|
+
{pendingStatus.status === 'failed' && (
|
|
30
|
+
<>
|
|
31
|
+
{' · Failed'}
|
|
32
|
+
{onRetry && (
|
|
33
|
+
<button className="btn-link" onClick={onRetry}>Retry</button>
|
|
34
|
+
)}
|
|
35
|
+
{onDismiss && (
|
|
36
|
+
<button className="btn-link" onClick={onDismiss}>Dismiss</button>
|
|
37
|
+
)}
|
|
38
|
+
</>
|
|
39
|
+
)}
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useLocal, useEvent, InfiniteScroll } from 'mvc-kit/react';
|
|
3
|
+
import { MessageThreadViewModel } from '../../viewmodels/MessageThreadViewModel';
|
|
4
|
+
import { MessageBubble } from './MessageBubble';
|
|
5
|
+
import { ComposeBar } from './ComposeBar';
|
|
6
|
+
import { Spinner } from '../shared/Spinner';
|
|
7
|
+
import { PendingBanner } from '../shared/PendingBanner';
|
|
8
|
+
|
|
9
|
+
interface MessageThreadProps {
|
|
10
|
+
conversationId: string;
|
|
11
|
+
currentWorkerId: string;
|
|
12
|
+
recipientName: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function MessageThread({ conversationId, currentWorkerId, recipientName }: MessageThreadProps) {
|
|
16
|
+
const [, vm] = useLocal(MessageThreadViewModel, {});
|
|
17
|
+
const { loading } = vm.async.loadConversation;
|
|
18
|
+
const loadingMore = vm.async.loadOlderMessages.loading;
|
|
19
|
+
const messagesRef = useRef<HTMLDivElement>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
vm.loadConversation(conversationId);
|
|
23
|
+
}, [conversationId, vm]);
|
|
24
|
+
|
|
25
|
+
useEvent(vm, 'messageSent', () => {
|
|
26
|
+
setTimeout(() => {
|
|
27
|
+
if (messagesRef.current) {
|
|
28
|
+
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
|
|
29
|
+
}
|
|
30
|
+
}, 50);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const messages = vm.sortedMessages;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="message-thread">
|
|
37
|
+
<div className="message-thread-header">
|
|
38
|
+
Messages
|
|
39
|
+
{vm.hasPendingMessages && (
|
|
40
|
+
<span className="badge badge-maintenance" style={{ marginLeft: '0.5rem' }}>
|
|
41
|
+
Sending...
|
|
42
|
+
</span>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<PendingBanner
|
|
47
|
+
pending={vm.pending}
|
|
48
|
+
label="messages"
|
|
49
|
+
renderEntry={entry => (
|
|
50
|
+
<span>To {entry.meta?.recipientName}: "{entry.meta?.preview}" — {entry.error}</span>
|
|
51
|
+
)}
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
<div className="message-thread-messages" ref={messagesRef}>
|
|
55
|
+
{loading && <Spinner />}
|
|
56
|
+
{!loading && (
|
|
57
|
+
<InfiniteScroll
|
|
58
|
+
hasMore={vm.feed.hasMore}
|
|
59
|
+
loading={loadingMore}
|
|
60
|
+
onLoadMore={() => vm.loadOlderMessages()}
|
|
61
|
+
direction="up"
|
|
62
|
+
renderLoading={() => (
|
|
63
|
+
<div className="spinner-center"><div className="spinner" /></div>
|
|
64
|
+
)}
|
|
65
|
+
renderEnd={() => (
|
|
66
|
+
<div className="feed-end">Beginning of conversation</div>
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
{messages.map(msg => {
|
|
70
|
+
const pendingStatus = vm.pending.getStatus(msg.id);
|
|
71
|
+
return (
|
|
72
|
+
<MessageBubble
|
|
73
|
+
key={msg.id}
|
|
74
|
+
message={msg}
|
|
75
|
+
isMine={msg.senderId === currentWorkerId}
|
|
76
|
+
senderName={vm.getSenderName(msg.senderId)}
|
|
77
|
+
pendingStatus={pendingStatus}
|
|
78
|
+
onRetry={pendingStatus?.status === 'failed' ? () => vm.retryMessage(msg.id) : undefined}
|
|
79
|
+
onDismiss={pendingStatus?.status === 'failed' ? () => vm.dismissMessage(msg.id) : undefined}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
})}
|
|
83
|
+
</InfiniteScroll>
|
|
84
|
+
)}
|
|
85
|
+
{!loading && messages.length === 0 && (
|
|
86
|
+
<div className="empty-state">No messages yet.</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<ComposeBar onSend={(text) => vm.sendMessage(conversationId, text, recipientName)} />
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useLocal, useSingleton } from 'mvc-kit/react';
|
|
2
|
+
import { ConversationsViewModel } from '../../viewmodels/ConversationsViewModel';
|
|
3
|
+
import { AuthViewModel } from '../../viewmodels/AuthViewModel';
|
|
4
|
+
import { ConversationList } from './ConversationList';
|
|
5
|
+
import { MessageThread } from './MessageThread';
|
|
6
|
+
import { Spinner } from '../shared/Spinner';
|
|
7
|
+
import { ErrorBanner } from '../shared/ErrorBanner';
|
|
8
|
+
|
|
9
|
+
export function MessagingPage() {
|
|
10
|
+
const [authState] = useSingleton(AuthViewModel);
|
|
11
|
+
const [state, vm] = useLocal(ConversationsViewModel, {
|
|
12
|
+
search: '',
|
|
13
|
+
selectedId: null,
|
|
14
|
+
currentWorkerId: '',
|
|
15
|
+
});
|
|
16
|
+
const { loading, error } = vm.async.load;
|
|
17
|
+
|
|
18
|
+
const currentWorkerId = authState.worker?.id ?? '';
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div>
|
|
22
|
+
<h1 className="page-title">Messaging</h1>
|
|
23
|
+
|
|
24
|
+
{error && <ErrorBanner message={error} />}
|
|
25
|
+
|
|
26
|
+
<div className="messaging-layout">
|
|
27
|
+
{loading ? (
|
|
28
|
+
<Spinner />
|
|
29
|
+
) : (
|
|
30
|
+
<ConversationList
|
|
31
|
+
conversations={vm.filtered}
|
|
32
|
+
selectedId={state.selectedId}
|
|
33
|
+
onSelect={id => vm.selectConversation(id)}
|
|
34
|
+
search={state.search}
|
|
35
|
+
onSearchChange={v => vm.setSearch(v)}
|
|
36
|
+
/>
|
|
37
|
+
)}
|
|
38
|
+
|
|
39
|
+
{state.selectedId ? (
|
|
40
|
+
<MessageThread
|
|
41
|
+
conversationId={state.selectedId}
|
|
42
|
+
currentWorkerId={currentWorkerId}
|
|
43
|
+
recipientName={vm.selected?.displayName ?? ''}
|
|
44
|
+
/>
|
|
45
|
+
) : (
|
|
46
|
+
<div className="message-thread-empty">
|
|
47
|
+
Select a conversation to start messaging
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { Pending, PendingEntry } from 'mvc-kit';
|
|
3
|
+
|
|
4
|
+
interface PendingBannerProps {
|
|
5
|
+
pending: Pending<string, any>;
|
|
6
|
+
label?: string;
|
|
7
|
+
renderEntry?: (entry: PendingEntry<string, any>) => ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function PendingBanner({ pending, label = 'operations', renderEntry }: PendingBannerProps) {
|
|
11
|
+
if (!pending.hasFailed) return null;
|
|
12
|
+
|
|
13
|
+
const failedEntries = pending.entries.filter(e => e.status === 'failed');
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="pending-banner">
|
|
17
|
+
<div className="pending-banner-info">
|
|
18
|
+
<span>{pending.failedCount} failed {label}</span>
|
|
19
|
+
{renderEntry && failedEntries.length > 0 && (
|
|
20
|
+
<ul className="pending-banner-list">
|
|
21
|
+
{failedEntries.map(entry => (
|
|
22
|
+
<li key={entry.id}>{renderEntry(entry)}</li>
|
|
23
|
+
))}
|
|
24
|
+
</ul>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
<div className="pending-banner-actions">
|
|
28
|
+
<button className="btn btn-sm btn-primary" onClick={() => pending.retryAll()}>
|
|
29
|
+
Retry All
|
|
30
|
+
</button>
|
|
31
|
+
<button className="btn btn-sm btn-secondary" onClick={() => pending.dismissAll()}>
|
|
32
|
+
Dismiss All
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -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
|
+
}
|