@khester/create-dynamics-app 1.0.4
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/bin/create-dynamics-app.js +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +102 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/copyTemplate.d.ts +2 -0
- package/dist/utils/copyTemplate.d.ts.map +1 -0
- package/dist/utils/copyTemplate.js +28 -0
- package/dist/utils/copyTemplate.js.map +1 -0
- package/dist/utils/initGit.d.ts +2 -0
- package/dist/utils/initGit.d.ts.map +1 -0
- package/dist/utils/initGit.js +122 -0
- package/dist/utils/initGit.js.map +1 -0
- package/dist/utils/installDependencies.d.ts +2 -0
- package/dist/utils/installDependencies.d.ts.map +1 -0
- package/dist/utils/installDependencies.js +40 -0
- package/dist/utils/installDependencies.js.map +1 -0
- package/dist/utils/updatePackageJson.d.ts +2 -0
- package/dist/utils/updatePackageJson.d.ts.map +1 -0
- package/dist/utils/updatePackageJson.js +24 -0
- package/dist/utils/updatePackageJson.js.map +1 -0
- package/package.json +51 -0
- package/templates/dynamics-365-starter/README.md +178 -0
- package/templates/dynamics-365-starter/package.json +44 -0
- package/templates/dynamics-365-starter/public/index.html +18 -0
- package/templates/dynamics-365-starter/src/components/ContactForm.css +48 -0
- package/templates/dynamics-365-starter/src/components/ContactForm.tsx +241 -0
- package/templates/dynamics-365-starter/src/components/ContactManagement.css +86 -0
- package/templates/dynamics-365-starter/src/components/ContactManagement.tsx +267 -0
- package/templates/dynamics-365-starter/src/index.tsx +40 -0
- package/templates/dynamics-365-starter/src/pcf/ContactControlWrapper.tsx +54 -0
- package/templates/dynamics-365-starter/src/providers/DynamicsProvider.tsx +136 -0
- package/templates/dynamics-365-starter/src/styles/index.css +104 -0
- package/templates/dynamics-365-starter/tsconfig.json +26 -0
- package/templates/dynamics-365-starter/webpack.config.js +58 -0
- package/templates/power-pages-starter/.env.example +6 -0
- package/templates/power-pages-starter/README.md +89 -0
- package/templates/power-pages-starter/package.json +42 -0
- package/templates/power-pages-starter/public/index.html +18 -0
- package/templates/power-pages-starter/src/components/ContactForm.css +84 -0
- package/templates/power-pages-starter/src/components/ContactForm.tsx +239 -0
- package/templates/power-pages-starter/src/index.tsx +32 -0
- package/templates/power-pages-starter/src/providers/PowerPagesProvider.tsx +139 -0
- package/templates/power-pages-starter/src/styles/index.css +76 -0
- package/templates/power-pages-starter/tsconfig.json +26 -0
- package/templates/power-pages-starter/webpack.config.js +52 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
TextField,
|
|
5
|
+
Dropdown,
|
|
6
|
+
DatePicker
|
|
7
|
+
} from '@khester1/dynamics-ui-components';
|
|
8
|
+
import { usePowerPagesApi } from '../providers/PowerPagesProvider';
|
|
9
|
+
import './ContactForm.css';
|
|
10
|
+
|
|
11
|
+
interface ContactFormData {
|
|
12
|
+
firstname: string;
|
|
13
|
+
lastname: string;
|
|
14
|
+
emailaddress1: string;
|
|
15
|
+
telephone1: string;
|
|
16
|
+
preferredcontactmethodcode: number;
|
|
17
|
+
birthdate: Date | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const preferredContactOptions = [
|
|
21
|
+
{ key: 'email', text: 'Email' },
|
|
22
|
+
{ key: 'phone', text: 'Phone' },
|
|
23
|
+
{ key: 'mail', text: 'Mail' }
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const ContactForm: React.FC = () => {
|
|
27
|
+
const { createRecord, isAuthenticated } = usePowerPagesApi();
|
|
28
|
+
|
|
29
|
+
const [formData, setFormData] = useState<ContactFormData>({
|
|
30
|
+
firstname: '',
|
|
31
|
+
lastname: '',
|
|
32
|
+
emailaddress1: '',
|
|
33
|
+
telephone1: '',
|
|
34
|
+
preferredcontactmethodcode: 1,
|
|
35
|
+
birthdate: null
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const [errors, setErrors] = useState<Partial<ContactFormData>>({});
|
|
39
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
40
|
+
const [submitError, setSubmitError] = useState<string>('');
|
|
41
|
+
const [submitSuccess, setSubmitSuccess] = useState(false);
|
|
42
|
+
|
|
43
|
+
const validateForm = useCallback((): boolean => {
|
|
44
|
+
const newErrors: Partial<ContactFormData> = {};
|
|
45
|
+
|
|
46
|
+
if (!formData.firstname.trim()) {
|
|
47
|
+
newErrors.firstname = 'First name is required';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!formData.lastname.trim()) {
|
|
51
|
+
newErrors.lastname = 'Last name is required';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!formData.emailaddress1.trim()) {
|
|
55
|
+
newErrors.emailaddress1 = 'Email address is required';
|
|
56
|
+
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.emailaddress1)) {
|
|
57
|
+
newErrors.emailaddress1 = 'Please enter a valid email address';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (formData.telephone1 && !/^\+?[\d\s\-\(\)]+$/.test(formData.telephone1)) {
|
|
61
|
+
newErrors.telephone1 = 'Please enter a valid phone number';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setErrors(newErrors);
|
|
65
|
+
return Object.keys(newErrors).length === 0;
|
|
66
|
+
}, [formData]);
|
|
67
|
+
|
|
68
|
+
const handleInputChange = useCallback((field: keyof ContactFormData, value: any) => {
|
|
69
|
+
setFormData(prev => ({ ...prev, [field]: value }));
|
|
70
|
+
|
|
71
|
+
// Clear error for this field when user starts typing
|
|
72
|
+
if (errors[field]) {
|
|
73
|
+
setErrors(prev => ({ ...prev, [field]: undefined }));
|
|
74
|
+
}
|
|
75
|
+
setSubmitError('');
|
|
76
|
+
setSubmitSuccess(false);
|
|
77
|
+
}, [errors]);
|
|
78
|
+
|
|
79
|
+
const handleSubmit = useCallback(async () => {
|
|
80
|
+
if (!isAuthenticated) {
|
|
81
|
+
setSubmitError('You must be signed in to save contact information');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!validateForm()) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setIsSubmitting(true);
|
|
90
|
+
setSubmitError('');
|
|
91
|
+
setSubmitSuccess(false);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const contactData = {
|
|
95
|
+
firstname: formData.firstname.trim(),
|
|
96
|
+
lastname: formData.lastname.trim(),
|
|
97
|
+
emailaddress1: formData.emailaddress1.trim(),
|
|
98
|
+
telephone1: formData.telephone1.trim() || null,
|
|
99
|
+
preferredcontactmethodcode: formData.preferredcontactmethodcode,
|
|
100
|
+
birthdate: formData.birthdate ? formData.birthdate.toISOString() : null
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
await createRecord('contacts', contactData);
|
|
104
|
+
|
|
105
|
+
setSubmitSuccess(true);
|
|
106
|
+
// Reset form
|
|
107
|
+
setFormData({
|
|
108
|
+
firstname: '',
|
|
109
|
+
lastname: '',
|
|
110
|
+
emailaddress1: '',
|
|
111
|
+
telephone1: '',
|
|
112
|
+
preferredcontactmethodcode: 1,
|
|
113
|
+
birthdate: null
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
setSubmitError(error instanceof Error ? error.message : 'An error occurred while saving');
|
|
117
|
+
} finally {
|
|
118
|
+
setIsSubmitting(false);
|
|
119
|
+
}
|
|
120
|
+
}, [
|
|
121
|
+
formData,
|
|
122
|
+
isAuthenticated,
|
|
123
|
+
validateForm,
|
|
124
|
+
createRecord
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
const handlePreferredContactChange = useCallback((option: any) => {
|
|
128
|
+
const code = option.key === 'email' ? 1 : option.key === 'phone' ? 2 : 3;
|
|
129
|
+
handleInputChange('preferredcontactmethodcode', code);
|
|
130
|
+
}, [handleInputChange]);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="contact-form">
|
|
134
|
+
<div className="contact-form__header">
|
|
135
|
+
<h2 className="contact-form__title">Create New Contact</h2>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="contact-form__content">
|
|
139
|
+
{submitSuccess && (
|
|
140
|
+
<div className="contact-form__success" role="alert">
|
|
141
|
+
Contact created successfully!
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{submitError && (
|
|
146
|
+
<div className="contact-form__error" role="alert">
|
|
147
|
+
{submitError}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
<div className="contact-form__row">
|
|
152
|
+
<div className="contact-form__field">
|
|
153
|
+
<TextField
|
|
154
|
+
label="First Name"
|
|
155
|
+
required
|
|
156
|
+
value={formData.firstname}
|
|
157
|
+
onChange={(_, value) => handleInputChange('firstname', value || '')}
|
|
158
|
+
errorMessage={errors.firstname}
|
|
159
|
+
disabled={isSubmitting}
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
<div className="contact-form__field">
|
|
163
|
+
<TextField
|
|
164
|
+
label="Last Name"
|
|
165
|
+
required
|
|
166
|
+
value={formData.lastname}
|
|
167
|
+
onChange={(_, value) => handleInputChange('lastname', value || '')}
|
|
168
|
+
errorMessage={errors.lastname}
|
|
169
|
+
disabled={isSubmitting}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div className="contact-form__row">
|
|
175
|
+
<div className="contact-form__field">
|
|
176
|
+
<TextField
|
|
177
|
+
label="Email Address"
|
|
178
|
+
type="email"
|
|
179
|
+
required
|
|
180
|
+
value={formData.emailaddress1}
|
|
181
|
+
onChange={(_, value) => handleInputChange('emailaddress1', value || '')}
|
|
182
|
+
errorMessage={errors.emailaddress1}
|
|
183
|
+
disabled={isSubmitting}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="contact-form__field">
|
|
187
|
+
<TextField
|
|
188
|
+
label="Phone Number"
|
|
189
|
+
type="tel"
|
|
190
|
+
value={formData.telephone1}
|
|
191
|
+
onChange={(_, value) => handleInputChange('telephone1', value || '')}
|
|
192
|
+
errorMessage={errors.telephone1}
|
|
193
|
+
disabled={isSubmitting}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div className="contact-form__row">
|
|
199
|
+
<div className="contact-form__field">
|
|
200
|
+
<Dropdown
|
|
201
|
+
label="Preferred Contact Method"
|
|
202
|
+
options={preferredContactOptions}
|
|
203
|
+
selectedKey={
|
|
204
|
+
formData.preferredcontactmethodcode === 1 ? 'email' :
|
|
205
|
+
formData.preferredcontactmethodcode === 2 ? 'phone' : 'mail'
|
|
206
|
+
}
|
|
207
|
+
onChange={handlePreferredContactChange}
|
|
208
|
+
disabled={isSubmitting}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="contact-form__field">
|
|
212
|
+
<DatePicker
|
|
213
|
+
label="Birth Date"
|
|
214
|
+
value={formData.birthdate || undefined}
|
|
215
|
+
onSelectDate={(date) => handleInputChange('birthdate', date)}
|
|
216
|
+
disabled={isSubmitting}
|
|
217
|
+
placeholder="Select date..."
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div className="contact-form__actions">
|
|
224
|
+
<Button
|
|
225
|
+
text="Save Contact"
|
|
226
|
+
variant="primary"
|
|
227
|
+
onClick={handleSubmit}
|
|
228
|
+
disabled={isSubmitting || !isAuthenticated}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{!isAuthenticated && (
|
|
233
|
+
<div className="contact-form__auth-notice">
|
|
234
|
+
Please sign in to save contact information.
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { ContactForm } from './components/ContactForm';
|
|
4
|
+
import { PowerPagesProvider } from './providers/PowerPagesProvider';
|
|
5
|
+
import './styles/index.css';
|
|
6
|
+
|
|
7
|
+
const App: React.FC = () => {
|
|
8
|
+
return (
|
|
9
|
+
<PowerPagesProvider>
|
|
10
|
+
<div className="app">
|
|
11
|
+
<header className="app-header">
|
|
12
|
+
<h1>Power Pages Contact Management</h1>
|
|
13
|
+
<p>Built with Dynamics UI Kit</p>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<main className="app-main">
|
|
17
|
+
<ContactForm />
|
|
18
|
+
</main>
|
|
19
|
+
|
|
20
|
+
<footer className="app-footer">
|
|
21
|
+
<p>© 2024 Your Organization. Powered by Dynamics UI Kit.</p>
|
|
22
|
+
</footer>
|
|
23
|
+
</div>
|
|
24
|
+
</PowerPagesProvider>
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const container = document.getElementById('root');
|
|
29
|
+
if (!container) throw new Error('Failed to find the root element');
|
|
30
|
+
|
|
31
|
+
const root = createRoot(container);
|
|
32
|
+
root.render(<App />);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useEffect } from 'react';
|
|
2
|
+
import { PowerPagesApiService } from '@khester1/dynamics-ui-api-client';
|
|
3
|
+
|
|
4
|
+
interface PowerPagesContextType {
|
|
5
|
+
apiService: PowerPagesApiService | null;
|
|
6
|
+
isAuthenticated: boolean;
|
|
7
|
+
createRecord: (entityName: string, data: any) => Promise<any>;
|
|
8
|
+
retrieveRecord: (entityName: string, id: string, select?: string) => Promise<any>;
|
|
9
|
+
updateRecord: (entityName: string, id: string, data: any) => Promise<any>;
|
|
10
|
+
deleteRecord: (entityName: string, id: string) => Promise<void>;
|
|
11
|
+
retrieveMultiple: (entityName: string, query?: string) => Promise<any>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const PowerPagesContext = createContext<PowerPagesContextType | undefined>(undefined);
|
|
15
|
+
|
|
16
|
+
interface PowerPagesProviderProps {
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const PowerPagesProvider: React.FC<PowerPagesProviderProps> = ({ children }) => {
|
|
21
|
+
const [apiService, setApiService] = useState<PowerPagesApiService | null>(null);
|
|
22
|
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
// Initialize the API service
|
|
26
|
+
const portalUrl = process.env.PORTAL_URL || window.location.origin;
|
|
27
|
+
|
|
28
|
+
// CSRF token function for Power Pages authentication
|
|
29
|
+
const getCsrfToken = async (): Promise<string> => {
|
|
30
|
+
// In a real Power Pages implementation, you would fetch the CSRF token
|
|
31
|
+
// For demo purposes, return empty string
|
|
32
|
+
return '';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const service = new PowerPagesApiService(portalUrl, getCsrfToken);
|
|
36
|
+
|
|
37
|
+
setApiService(service);
|
|
38
|
+
|
|
39
|
+
// Check authentication status
|
|
40
|
+
// In a real implementation, you would check if the user is logged in to the portal
|
|
41
|
+
// For demo purposes, we'll assume they are authenticated
|
|
42
|
+
setIsAuthenticated(true);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const createRecord = async (entityName: string, data: any) => {
|
|
46
|
+
if (!apiService) throw new Error('API service not initialized');
|
|
47
|
+
return await apiService.createRecord(entityName, data);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const retrieveRecord = async (entityName: string, id: string, select?: string) => {
|
|
51
|
+
if (!apiService) throw new Error('API service not initialized');
|
|
52
|
+
// Use retrieveMultipleRecords with FetchXML to get a single record
|
|
53
|
+
const selectAttributes = select ? select.split(',').map(attr => attr.trim()) : ['*'];
|
|
54
|
+
const attributes = selectAttributes.map(attr => attr === '*' ? '' : `<attribute name="${attr}" />`).join('');
|
|
55
|
+
const fetchXml = `
|
|
56
|
+
<fetch top="1">
|
|
57
|
+
<entity name="${entityName}">
|
|
58
|
+
${attributes}
|
|
59
|
+
<filter>
|
|
60
|
+
<condition attribute="${entityName}id" operator="eq" value="${id}" />
|
|
61
|
+
</filter>
|
|
62
|
+
</entity>
|
|
63
|
+
</fetch>
|
|
64
|
+
`;
|
|
65
|
+
const result = await apiService.retrieveMultipleRecords(entityName, fetchXml);
|
|
66
|
+
return result.entities.length > 0 ? result.entities[0] : null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const updateRecord = async (entityName: string, id: string, data: any) => {
|
|
70
|
+
if (!apiService) throw new Error('API service not initialized');
|
|
71
|
+
return await apiService.updateRecord(entityName, id, data);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const deleteRecord = async (entityName: string, id: string) => {
|
|
75
|
+
if (!apiService) throw new Error('API service not initialized');
|
|
76
|
+
return await apiService.deleteRecord(entityName, id);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const retrieveMultiple = async (entityName: string, query?: string) => {
|
|
80
|
+
if (!apiService) throw new Error('API service not initialized');
|
|
81
|
+
// Convert OData-style query to FetchXML
|
|
82
|
+
let fetchXml = `<fetch>`;
|
|
83
|
+
|
|
84
|
+
if (query) {
|
|
85
|
+
// Parse basic OData query parameters
|
|
86
|
+
const selectMatch = query.match(/\$select=([^&]*)/i);
|
|
87
|
+
const orderByMatch = query.match(/\$orderby=([^&]*)/i);
|
|
88
|
+
const topMatch = query.match(/\$top=(\d+)/i);
|
|
89
|
+
|
|
90
|
+
if (topMatch) {
|
|
91
|
+
fetchXml = `<fetch top="${topMatch[1]}">`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fetchXml += `<entity name="${entityName}">`;
|
|
95
|
+
|
|
96
|
+
if (selectMatch) {
|
|
97
|
+
const attributes = selectMatch[1].split(',').map(attr => attr.trim());
|
|
98
|
+
attributes.forEach(attr => {
|
|
99
|
+
fetchXml += `<attribute name="${attr}" />`;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (orderByMatch) {
|
|
104
|
+
const [field, direction] = orderByMatch[1].split(' ');
|
|
105
|
+
fetchXml += `<order attribute="${field.trim()}" descending="${direction?.toLowerCase() === 'desc'}" />`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fetchXml += `</entity></fetch>`;
|
|
109
|
+
} else {
|
|
110
|
+
fetchXml = `<fetch><entity name="${entityName}"></entity></fetch>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return await apiService.retrieveMultipleRecords(entityName, fetchXml);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const value: PowerPagesContextType = {
|
|
117
|
+
apiService,
|
|
118
|
+
isAuthenticated,
|
|
119
|
+
createRecord,
|
|
120
|
+
retrieveRecord,
|
|
121
|
+
updateRecord,
|
|
122
|
+
deleteRecord,
|
|
123
|
+
retrieveMultiple,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<PowerPagesContext.Provider value={value}>
|
|
128
|
+
{children}
|
|
129
|
+
</PowerPagesContext.Provider>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const usePowerPagesApi = (): PowerPagesContextType => {
|
|
134
|
+
const context = useContext(PowerPagesContext);
|
|
135
|
+
if (context === undefined) {
|
|
136
|
+
throw new Error('usePowerPagesApi must be used within a PowerPagesProvider');
|
|
137
|
+
}
|
|
138
|
+
return context;
|
|
139
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* Global Styles */
|
|
2
|
+
* {
|
|
3
|
+
box-sizing: border-box;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
body {
|
|
7
|
+
margin: 0;
|
|
8
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
9
|
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
10
|
+
sans-serif;
|
|
11
|
+
-webkit-font-smoothing: antialiased;
|
|
12
|
+
-moz-osx-font-smoothing: grayscale;
|
|
13
|
+
background-color: #f5f5f5;
|
|
14
|
+
color: #323130;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.app {
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.app-header {
|
|
24
|
+
background: linear-gradient(135deg, #0078d4 0%, #106ebe 100%);
|
|
25
|
+
color: white;
|
|
26
|
+
padding: 24px;
|
|
27
|
+
text-align: center;
|
|
28
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.app-header h1 {
|
|
32
|
+
margin: 0 0 8px 0;
|
|
33
|
+
font-size: 28px;
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.app-header p {
|
|
38
|
+
margin: 0;
|
|
39
|
+
font-size: 16px;
|
|
40
|
+
opacity: 0.9;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.app-main {
|
|
44
|
+
flex: 1;
|
|
45
|
+
padding: 32px 16px;
|
|
46
|
+
max-width: 1200px;
|
|
47
|
+
margin: 0 auto;
|
|
48
|
+
width: 100%;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.app-footer {
|
|
52
|
+
background-color: #f3f2f1;
|
|
53
|
+
border-top: 1px solid #edebe9;
|
|
54
|
+
padding: 16px;
|
|
55
|
+
text-align: center;
|
|
56
|
+
color: #605e5c;
|
|
57
|
+
font-size: 14px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.app-footer p {
|
|
61
|
+
margin: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@media (max-width: 768px) {
|
|
65
|
+
.app-header {
|
|
66
|
+
padding: 16px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.app-header h1 {
|
|
70
|
+
font-size: 24px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.app-main {
|
|
74
|
+
padding: 16px 8px;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es5",
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"es6"
|
|
8
|
+
],
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"allowSyntheticDefaultImports": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"noFallthroughCasesInSwitch": true,
|
|
16
|
+
"module": "esnext",
|
|
17
|
+
"moduleResolution": "node",
|
|
18
|
+
"resolveJsonModule": true,
|
|
19
|
+
"isolatedModules": true,
|
|
20
|
+
"noEmit": true,
|
|
21
|
+
"jsx": "react-jsx"
|
|
22
|
+
},
|
|
23
|
+
"include": [
|
|
24
|
+
"src"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
3
|
+
|
|
4
|
+
module.exports = (env, argv) => {
|
|
5
|
+
const isProduction = argv.mode === 'production';
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
entry: './src/index.tsx',
|
|
9
|
+
output: {
|
|
10
|
+
path: path.resolve(__dirname, 'dist'),
|
|
11
|
+
filename: isProduction ? '[name].[contenthash].js' : '[name].js',
|
|
12
|
+
clean: true,
|
|
13
|
+
publicPath: '/',
|
|
14
|
+
},
|
|
15
|
+
resolve: {
|
|
16
|
+
extensions: ['.tsx', '.ts', '.js', '.jsx'],
|
|
17
|
+
},
|
|
18
|
+
module: {
|
|
19
|
+
rules: [
|
|
20
|
+
{
|
|
21
|
+
test: /\.tsx?$/,
|
|
22
|
+
use: 'ts-loader',
|
|
23
|
+
exclude: /node_modules/,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
test: /\.css$/i,
|
|
27
|
+
use: ['style-loader', 'css-loader'],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
plugins: [
|
|
32
|
+
new HtmlWebpackPlugin({
|
|
33
|
+
template: './public/index.html',
|
|
34
|
+
filename: 'index.html',
|
|
35
|
+
}),
|
|
36
|
+
],
|
|
37
|
+
devServer: {
|
|
38
|
+
static: {
|
|
39
|
+
directory: path.join(__dirname, 'public'),
|
|
40
|
+
},
|
|
41
|
+
port: 3000,
|
|
42
|
+
open: true,
|
|
43
|
+
hot: true,
|
|
44
|
+
historyApiFallback: true,
|
|
45
|
+
},
|
|
46
|
+
optimization: {
|
|
47
|
+
splitChunks: {
|
|
48
|
+
chunks: 'all',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|