@idealyst/mcp-server 1.2.18 → 1.2.19
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/dist/data/recipes.d.ts +36 -0
- package/dist/data/recipes.d.ts.map +1 -0
- package/dist/data/recipes.js +2201 -0
- package/dist/data/recipes.js.map +1 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,2201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idealyst Recipes - Common UI Patterns
|
|
3
|
+
* Ready-to-use code examples for building apps with Idealyst
|
|
4
|
+
*/
|
|
5
|
+
export const recipes = {
|
|
6
|
+
"login-form": {
|
|
7
|
+
name: "Login Form",
|
|
8
|
+
description: "A complete login form with email/password validation and error handling",
|
|
9
|
+
category: "auth",
|
|
10
|
+
difficulty: "beginner",
|
|
11
|
+
packages: ["@idealyst/components", "@idealyst/theme"],
|
|
12
|
+
code: `import React, { useState } from 'react';
|
|
13
|
+
import { Button, Input, Card, Text, View } from '@idealyst/components';
|
|
14
|
+
|
|
15
|
+
interface LoginFormProps {
|
|
16
|
+
onSubmit: (email: string, password: string) => Promise<void>;
|
|
17
|
+
onForgotPassword?: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function LoginForm({ onSubmit, onForgotPassword }: LoginFormProps) {
|
|
21
|
+
const [email, setEmail] = useState('');
|
|
22
|
+
const [password, setPassword] = useState('');
|
|
23
|
+
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
|
|
24
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
25
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
const validate = () => {
|
|
28
|
+
const newErrors: typeof errors = {};
|
|
29
|
+
|
|
30
|
+
if (!email) {
|
|
31
|
+
newErrors.email = 'Email is required';
|
|
32
|
+
} else if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {
|
|
33
|
+
newErrors.email = 'Please enter a valid email';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!password) {
|
|
37
|
+
newErrors.password = 'Password is required';
|
|
38
|
+
} else if (password.length < 8) {
|
|
39
|
+
newErrors.password = 'Password must be at least 8 characters';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setErrors(newErrors);
|
|
43
|
+
return Object.keys(newErrors).length === 0;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleSubmit = async () => {
|
|
47
|
+
setSubmitError(null);
|
|
48
|
+
|
|
49
|
+
if (!validate()) return;
|
|
50
|
+
|
|
51
|
+
setIsLoading(true);
|
|
52
|
+
try {
|
|
53
|
+
await onSubmit(email, password);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
setSubmitError(error instanceof Error ? error.message : 'Login failed');
|
|
56
|
+
} finally {
|
|
57
|
+
setIsLoading(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Card padding="lg">
|
|
63
|
+
<Text variant="headline" style={{ marginBottom: 24 }}>
|
|
64
|
+
Sign In
|
|
65
|
+
</Text>
|
|
66
|
+
|
|
67
|
+
{submitError && (
|
|
68
|
+
<View style={{ marginBottom: 16 }}>
|
|
69
|
+
<Text intent="danger">{submitError}</Text>
|
|
70
|
+
</View>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
<View style={{ gap: 16 }}>
|
|
74
|
+
<Input
|
|
75
|
+
label="Email"
|
|
76
|
+
placeholder="you@example.com"
|
|
77
|
+
value={email}
|
|
78
|
+
onChangeText={setEmail}
|
|
79
|
+
keyboardType="email-address"
|
|
80
|
+
autoCapitalize="none"
|
|
81
|
+
autoComplete="email"
|
|
82
|
+
error={errors.email}
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
<Input
|
|
86
|
+
label="Password"
|
|
87
|
+
placeholder="Enter your password"
|
|
88
|
+
value={password}
|
|
89
|
+
onChangeText={setPassword}
|
|
90
|
+
secureTextEntry
|
|
91
|
+
autoComplete="current-password"
|
|
92
|
+
error={errors.password}
|
|
93
|
+
/>
|
|
94
|
+
|
|
95
|
+
<Button
|
|
96
|
+
onPress={handleSubmit}
|
|
97
|
+
loading={isLoading}
|
|
98
|
+
disabled={isLoading}
|
|
99
|
+
>
|
|
100
|
+
Sign In
|
|
101
|
+
</Button>
|
|
102
|
+
|
|
103
|
+
{onForgotPassword && (
|
|
104
|
+
<Button type="text" onPress={onForgotPassword}>
|
|
105
|
+
Forgot Password?
|
|
106
|
+
</Button>
|
|
107
|
+
)}
|
|
108
|
+
</View>
|
|
109
|
+
</Card>
|
|
110
|
+
);
|
|
111
|
+
}`,
|
|
112
|
+
explanation: `This login form demonstrates:
|
|
113
|
+
- Controlled inputs with useState
|
|
114
|
+
- Client-side validation with error messages
|
|
115
|
+
- Loading state during submission
|
|
116
|
+
- Error handling for failed login attempts
|
|
117
|
+
- Proper keyboard types and autocomplete hints for better UX`,
|
|
118
|
+
tips: [
|
|
119
|
+
"Add onBlur validation for immediate feedback",
|
|
120
|
+
"Consider using react-hook-form for complex forms",
|
|
121
|
+
"Store tokens securely using @idealyst/storage after successful login",
|
|
122
|
+
],
|
|
123
|
+
relatedRecipes: ["signup-form", "forgot-password", "protected-route"],
|
|
124
|
+
},
|
|
125
|
+
"signup-form": {
|
|
126
|
+
name: "Signup Form",
|
|
127
|
+
description: "User registration form with password confirmation and terms acceptance",
|
|
128
|
+
category: "auth",
|
|
129
|
+
difficulty: "beginner",
|
|
130
|
+
packages: ["@idealyst/components", "@idealyst/theme"],
|
|
131
|
+
code: `import React, { useState } from 'react';
|
|
132
|
+
import { Button, Input, Card, Text, View, Checkbox, Link } from '@idealyst/components';
|
|
133
|
+
|
|
134
|
+
interface SignupFormProps {
|
|
135
|
+
onSubmit: (data: { name: string; email: string; password: string }) => Promise<void>;
|
|
136
|
+
onTermsPress?: () => void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function SignupForm({ onSubmit, onTermsPress }: SignupFormProps) {
|
|
140
|
+
const [name, setName] = useState('');
|
|
141
|
+
const [email, setEmail] = useState('');
|
|
142
|
+
const [password, setPassword] = useState('');
|
|
143
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
144
|
+
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
|
145
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
146
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
147
|
+
|
|
148
|
+
const validate = () => {
|
|
149
|
+
const newErrors: Record<string, string> = {};
|
|
150
|
+
|
|
151
|
+
if (!name.trim()) {
|
|
152
|
+
newErrors.name = 'Name is required';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!email) {
|
|
156
|
+
newErrors.email = 'Email is required';
|
|
157
|
+
} else if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {
|
|
158
|
+
newErrors.email = 'Please enter a valid email';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!password) {
|
|
162
|
+
newErrors.password = 'Password is required';
|
|
163
|
+
} else if (password.length < 8) {
|
|
164
|
+
newErrors.password = 'Password must be at least 8 characters';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (password !== confirmPassword) {
|
|
168
|
+
newErrors.confirmPassword = 'Passwords do not match';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!acceptedTerms) {
|
|
172
|
+
newErrors.terms = 'You must accept the terms and conditions';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setErrors(newErrors);
|
|
176
|
+
return Object.keys(newErrors).length === 0;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const handleSubmit = async () => {
|
|
180
|
+
if (!validate()) return;
|
|
181
|
+
|
|
182
|
+
setIsLoading(true);
|
|
183
|
+
try {
|
|
184
|
+
await onSubmit({ name, email, password });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
setErrors({ submit: error instanceof Error ? error.message : 'Signup failed' });
|
|
187
|
+
} finally {
|
|
188
|
+
setIsLoading(false);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<Card padding="lg">
|
|
194
|
+
<Text variant="headline" style={{ marginBottom: 24 }}>
|
|
195
|
+
Create Account
|
|
196
|
+
</Text>
|
|
197
|
+
|
|
198
|
+
{errors.submit && (
|
|
199
|
+
<View style={{ marginBottom: 16 }}>
|
|
200
|
+
<Text intent="danger">{errors.submit}</Text>
|
|
201
|
+
</View>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
<View style={{ gap: 16 }}>
|
|
205
|
+
<Input
|
|
206
|
+
label="Full Name"
|
|
207
|
+
placeholder="John Doe"
|
|
208
|
+
value={name}
|
|
209
|
+
onChangeText={setName}
|
|
210
|
+
autoComplete="name"
|
|
211
|
+
error={errors.name}
|
|
212
|
+
/>
|
|
213
|
+
|
|
214
|
+
<Input
|
|
215
|
+
label="Email"
|
|
216
|
+
placeholder="you@example.com"
|
|
217
|
+
value={email}
|
|
218
|
+
onChangeText={setEmail}
|
|
219
|
+
keyboardType="email-address"
|
|
220
|
+
autoCapitalize="none"
|
|
221
|
+
autoComplete="email"
|
|
222
|
+
error={errors.email}
|
|
223
|
+
/>
|
|
224
|
+
|
|
225
|
+
<Input
|
|
226
|
+
label="Password"
|
|
227
|
+
placeholder="At least 8 characters"
|
|
228
|
+
value={password}
|
|
229
|
+
onChangeText={setPassword}
|
|
230
|
+
secureTextEntry
|
|
231
|
+
autoComplete="new-password"
|
|
232
|
+
error={errors.password}
|
|
233
|
+
/>
|
|
234
|
+
|
|
235
|
+
<Input
|
|
236
|
+
label="Confirm Password"
|
|
237
|
+
placeholder="Confirm your password"
|
|
238
|
+
value={confirmPassword}
|
|
239
|
+
onChangeText={setConfirmPassword}
|
|
240
|
+
secureTextEntry
|
|
241
|
+
autoComplete="new-password"
|
|
242
|
+
error={errors.confirmPassword}
|
|
243
|
+
/>
|
|
244
|
+
|
|
245
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
246
|
+
<Checkbox
|
|
247
|
+
checked={acceptedTerms}
|
|
248
|
+
onCheckedChange={setAcceptedTerms}
|
|
249
|
+
/>
|
|
250
|
+
<Text>
|
|
251
|
+
I agree to the{' '}
|
|
252
|
+
<Link onPress={onTermsPress}>Terms and Conditions</Link>
|
|
253
|
+
</Text>
|
|
254
|
+
</View>
|
|
255
|
+
{errors.terms && <Text intent="danger" size="sm">{errors.terms}</Text>}
|
|
256
|
+
|
|
257
|
+
<Button
|
|
258
|
+
onPress={handleSubmit}
|
|
259
|
+
loading={isLoading}
|
|
260
|
+
disabled={isLoading}
|
|
261
|
+
>
|
|
262
|
+
Create Account
|
|
263
|
+
</Button>
|
|
264
|
+
</View>
|
|
265
|
+
</Card>
|
|
266
|
+
);
|
|
267
|
+
}`,
|
|
268
|
+
explanation: `This signup form includes:
|
|
269
|
+
- Multiple field validation including password matching
|
|
270
|
+
- Terms and conditions checkbox with validation
|
|
271
|
+
- Proper autocomplete hints for password managers
|
|
272
|
+
- Loading and error states`,
|
|
273
|
+
tips: [
|
|
274
|
+
"Add password strength indicator for better UX",
|
|
275
|
+
"Consider email verification flow after signup",
|
|
276
|
+
"Use secure password hashing on the backend",
|
|
277
|
+
],
|
|
278
|
+
relatedRecipes: ["login-form", "email-verification"],
|
|
279
|
+
},
|
|
280
|
+
"settings-screen": {
|
|
281
|
+
name: "Settings Screen",
|
|
282
|
+
description: "App settings screen with toggles, selections, and grouped options",
|
|
283
|
+
category: "settings",
|
|
284
|
+
difficulty: "beginner",
|
|
285
|
+
packages: ["@idealyst/components", "@idealyst/theme", "@idealyst/storage"],
|
|
286
|
+
code: `import React, { useState, useEffect } from 'react';
|
|
287
|
+
import { ScrollView } from 'react-native';
|
|
288
|
+
import {
|
|
289
|
+
View, Text, Switch, Select, Card, Divider, Icon
|
|
290
|
+
} from '@idealyst/components';
|
|
291
|
+
import { storage } from '@idealyst/storage';
|
|
292
|
+
|
|
293
|
+
interface Settings {
|
|
294
|
+
notifications: boolean;
|
|
295
|
+
emailUpdates: boolean;
|
|
296
|
+
darkMode: boolean;
|
|
297
|
+
language: string;
|
|
298
|
+
fontSize: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const defaultSettings: Settings = {
|
|
302
|
+
notifications: true,
|
|
303
|
+
emailUpdates: false,
|
|
304
|
+
darkMode: false,
|
|
305
|
+
language: 'en',
|
|
306
|
+
fontSize: 'medium',
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
export function SettingsScreen() {
|
|
310
|
+
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
|
311
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
312
|
+
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
loadSettings();
|
|
315
|
+
}, []);
|
|
316
|
+
|
|
317
|
+
const loadSettings = async () => {
|
|
318
|
+
try {
|
|
319
|
+
const saved = await storage.get<Settings>('user-settings');
|
|
320
|
+
if (saved) {
|
|
321
|
+
setSettings(saved);
|
|
322
|
+
}
|
|
323
|
+
} finally {
|
|
324
|
+
setIsLoading(false);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const updateSetting = async <K extends keyof Settings>(
|
|
329
|
+
key: K,
|
|
330
|
+
value: Settings[K]
|
|
331
|
+
) => {
|
|
332
|
+
const newSettings = { ...settings, [key]: value };
|
|
333
|
+
setSettings(newSettings);
|
|
334
|
+
await storage.set('user-settings', newSettings);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (isLoading) {
|
|
338
|
+
return <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
339
|
+
<Text>Loading...</Text>
|
|
340
|
+
</View>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<ScrollView style={{ flex: 1 }}>
|
|
345
|
+
<View style={{ padding: 16, gap: 16 }}>
|
|
346
|
+
{/* Notifications Section */}
|
|
347
|
+
<Card>
|
|
348
|
+
<Text variant="title" style={{ marginBottom: 16 }}>
|
|
349
|
+
Notifications
|
|
350
|
+
</Text>
|
|
351
|
+
|
|
352
|
+
<SettingRow
|
|
353
|
+
icon="bell"
|
|
354
|
+
label="Push Notifications"
|
|
355
|
+
description="Receive push notifications"
|
|
356
|
+
>
|
|
357
|
+
<Switch
|
|
358
|
+
checked={settings.notifications}
|
|
359
|
+
onCheckedChange={(v) => updateSetting('notifications', v)}
|
|
360
|
+
/>
|
|
361
|
+
</SettingRow>
|
|
362
|
+
|
|
363
|
+
<Divider />
|
|
364
|
+
|
|
365
|
+
<SettingRow
|
|
366
|
+
icon="email"
|
|
367
|
+
label="Email Updates"
|
|
368
|
+
description="Receive weekly email updates"
|
|
369
|
+
>
|
|
370
|
+
<Switch
|
|
371
|
+
checked={settings.emailUpdates}
|
|
372
|
+
onCheckedChange={(v) => updateSetting('emailUpdates', v)}
|
|
373
|
+
/>
|
|
374
|
+
</SettingRow>
|
|
375
|
+
</Card>
|
|
376
|
+
|
|
377
|
+
{/* Appearance Section */}
|
|
378
|
+
<Card>
|
|
379
|
+
<Text variant="title" style={{ marginBottom: 16 }}>
|
|
380
|
+
Appearance
|
|
381
|
+
</Text>
|
|
382
|
+
|
|
383
|
+
<SettingRow
|
|
384
|
+
icon="theme-light-dark"
|
|
385
|
+
label="Dark Mode"
|
|
386
|
+
description="Use dark theme"
|
|
387
|
+
>
|
|
388
|
+
<Switch
|
|
389
|
+
checked={settings.darkMode}
|
|
390
|
+
onCheckedChange={(v) => updateSetting('darkMode', v)}
|
|
391
|
+
/>
|
|
392
|
+
</SettingRow>
|
|
393
|
+
|
|
394
|
+
<Divider />
|
|
395
|
+
|
|
396
|
+
<SettingRow
|
|
397
|
+
icon="format-size"
|
|
398
|
+
label="Font Size"
|
|
399
|
+
>
|
|
400
|
+
<Select
|
|
401
|
+
value={settings.fontSize}
|
|
402
|
+
onValueChange={(v) => updateSetting('fontSize', v)}
|
|
403
|
+
options={[
|
|
404
|
+
{ label: 'Small', value: 'small' },
|
|
405
|
+
{ label: 'Medium', value: 'medium' },
|
|
406
|
+
{ label: 'Large', value: 'large' },
|
|
407
|
+
]}
|
|
408
|
+
style={{ width: 120 }}
|
|
409
|
+
/>
|
|
410
|
+
</SettingRow>
|
|
411
|
+
</Card>
|
|
412
|
+
|
|
413
|
+
{/* Language Section */}
|
|
414
|
+
<Card>
|
|
415
|
+
<Text variant="title" style={{ marginBottom: 16 }}>
|
|
416
|
+
Language & Region
|
|
417
|
+
</Text>
|
|
418
|
+
|
|
419
|
+
<SettingRow
|
|
420
|
+
icon="translate"
|
|
421
|
+
label="Language"
|
|
422
|
+
>
|
|
423
|
+
<Select
|
|
424
|
+
value={settings.language}
|
|
425
|
+
onValueChange={(v) => updateSetting('language', v)}
|
|
426
|
+
options={[
|
|
427
|
+
{ label: 'English', value: 'en' },
|
|
428
|
+
{ label: 'Spanish', value: 'es' },
|
|
429
|
+
{ label: 'French', value: 'fr' },
|
|
430
|
+
{ label: 'German', value: 'de' },
|
|
431
|
+
]}
|
|
432
|
+
style={{ width: 140 }}
|
|
433
|
+
/>
|
|
434
|
+
</SettingRow>
|
|
435
|
+
</Card>
|
|
436
|
+
</View>
|
|
437
|
+
</ScrollView>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Helper component for consistent setting rows
|
|
442
|
+
function SettingRow({
|
|
443
|
+
icon,
|
|
444
|
+
label,
|
|
445
|
+
description,
|
|
446
|
+
children
|
|
447
|
+
}: {
|
|
448
|
+
icon: string;
|
|
449
|
+
label: string;
|
|
450
|
+
description?: string;
|
|
451
|
+
children: React.ReactNode;
|
|
452
|
+
}) {
|
|
453
|
+
return (
|
|
454
|
+
<View style={{
|
|
455
|
+
flexDirection: 'row',
|
|
456
|
+
alignItems: 'center',
|
|
457
|
+
justifyContent: 'space-between',
|
|
458
|
+
paddingVertical: 12,
|
|
459
|
+
}}>
|
|
460
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, flex: 1 }}>
|
|
461
|
+
<Icon name={icon} size={24} />
|
|
462
|
+
<View style={{ flex: 1 }}>
|
|
463
|
+
<Text>{label}</Text>
|
|
464
|
+
{description && (
|
|
465
|
+
<Text size="sm" style={{ opacity: 0.7 }}>{description}</Text>
|
|
466
|
+
)}
|
|
467
|
+
</View>
|
|
468
|
+
</View>
|
|
469
|
+
{children}
|
|
470
|
+
</View>
|
|
471
|
+
);
|
|
472
|
+
}`,
|
|
473
|
+
explanation: `This settings screen demonstrates:
|
|
474
|
+
- Loading and persisting settings with @idealyst/storage
|
|
475
|
+
- Grouped settings sections with Cards
|
|
476
|
+
- Switch toggles for boolean options
|
|
477
|
+
- Select dropdowns for choices
|
|
478
|
+
- Reusable SettingRow component for consistent layout`,
|
|
479
|
+
tips: [
|
|
480
|
+
"Consider debouncing saves for rapid toggles",
|
|
481
|
+
"Add a 'Reset to Defaults' option",
|
|
482
|
+
"Sync settings with backend for cross-device consistency",
|
|
483
|
+
],
|
|
484
|
+
relatedRecipes: ["theme-switcher", "profile-screen"],
|
|
485
|
+
},
|
|
486
|
+
"theme-switcher": {
|
|
487
|
+
name: "Theme Switcher",
|
|
488
|
+
description: "Toggle between light and dark mode with persistence",
|
|
489
|
+
category: "settings",
|
|
490
|
+
difficulty: "beginner",
|
|
491
|
+
packages: ["@idealyst/components", "@idealyst/theme", "@idealyst/storage"],
|
|
492
|
+
code: `import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
493
|
+
import { UnistylesRuntime } from 'react-native-unistyles';
|
|
494
|
+
import { storage } from '@idealyst/storage';
|
|
495
|
+
import { Switch, View, Text, Icon } from '@idealyst/components';
|
|
496
|
+
|
|
497
|
+
type ThemeMode = 'light' | 'dark' | 'system';
|
|
498
|
+
|
|
499
|
+
interface ThemeContextType {
|
|
500
|
+
mode: ThemeMode;
|
|
501
|
+
setMode: (mode: ThemeMode) => void;
|
|
502
|
+
isDark: boolean;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const ThemeContext = createContext<ThemeContextType | null>(null);
|
|
506
|
+
|
|
507
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
508
|
+
const [mode, setModeState] = useState<ThemeMode>('system');
|
|
509
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
510
|
+
|
|
511
|
+
useEffect(() => {
|
|
512
|
+
loadTheme();
|
|
513
|
+
}, []);
|
|
514
|
+
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
if (!isLoaded) return;
|
|
517
|
+
|
|
518
|
+
// Apply theme based on mode
|
|
519
|
+
if (mode === 'system') {
|
|
520
|
+
UnistylesRuntime.setAdaptiveThemes(true);
|
|
521
|
+
} else {
|
|
522
|
+
UnistylesRuntime.setAdaptiveThemes(false);
|
|
523
|
+
UnistylesRuntime.setTheme(mode);
|
|
524
|
+
}
|
|
525
|
+
}, [mode, isLoaded]);
|
|
526
|
+
|
|
527
|
+
const loadTheme = async () => {
|
|
528
|
+
const saved = await storage.get<ThemeMode>('theme-mode');
|
|
529
|
+
if (saved) {
|
|
530
|
+
setModeState(saved);
|
|
531
|
+
}
|
|
532
|
+
setIsLoaded(true);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const setMode = async (newMode: ThemeMode) => {
|
|
536
|
+
setModeState(newMode);
|
|
537
|
+
await storage.set('theme-mode', newMode);
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const isDark = mode === 'dark' ||
|
|
541
|
+
(mode === 'system' && UnistylesRuntime.colorScheme === 'dark');
|
|
542
|
+
|
|
543
|
+
if (!isLoaded) return null;
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<ThemeContext.Provider value={{ mode, setMode, isDark }}>
|
|
547
|
+
{children}
|
|
548
|
+
</ThemeContext.Provider>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function useTheme() {
|
|
553
|
+
const context = useContext(ThemeContext);
|
|
554
|
+
if (!context) {
|
|
555
|
+
throw new Error('useTheme must be used within ThemeProvider');
|
|
556
|
+
}
|
|
557
|
+
return context;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Simple toggle component
|
|
561
|
+
export function ThemeToggle() {
|
|
562
|
+
const { isDark, setMode } = useTheme();
|
|
563
|
+
|
|
564
|
+
return (
|
|
565
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
|
566
|
+
<Icon name={isDark ? 'weather-night' : 'weather-sunny'} size={24} />
|
|
567
|
+
<Text>Dark Mode</Text>
|
|
568
|
+
<Switch
|
|
569
|
+
checked={isDark}
|
|
570
|
+
onCheckedChange={(checked) => setMode(checked ? 'dark' : 'light')}
|
|
571
|
+
/>
|
|
572
|
+
</View>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Full selector with system option
|
|
577
|
+
export function ThemeSelector() {
|
|
578
|
+
const { mode, setMode } = useTheme();
|
|
579
|
+
|
|
580
|
+
return (
|
|
581
|
+
<View style={{ gap: 8 }}>
|
|
582
|
+
<ThemeOption
|
|
583
|
+
label="Light"
|
|
584
|
+
icon="weather-sunny"
|
|
585
|
+
selected={mode === 'light'}
|
|
586
|
+
onPress={() => setMode('light')}
|
|
587
|
+
/>
|
|
588
|
+
<ThemeOption
|
|
589
|
+
label="Dark"
|
|
590
|
+
icon="weather-night"
|
|
591
|
+
selected={mode === 'dark'}
|
|
592
|
+
onPress={() => setMode('dark')}
|
|
593
|
+
/>
|
|
594
|
+
<ThemeOption
|
|
595
|
+
label="System"
|
|
596
|
+
icon="cellphone"
|
|
597
|
+
selected={mode === 'system'}
|
|
598
|
+
onPress={() => setMode('system')}
|
|
599
|
+
/>
|
|
600
|
+
</View>
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function ThemeOption({
|
|
605
|
+
label,
|
|
606
|
+
icon,
|
|
607
|
+
selected,
|
|
608
|
+
onPress
|
|
609
|
+
}: {
|
|
610
|
+
label: string;
|
|
611
|
+
icon: string;
|
|
612
|
+
selected: boolean;
|
|
613
|
+
onPress: () => void;
|
|
614
|
+
}) {
|
|
615
|
+
return (
|
|
616
|
+
<Pressable onPress={onPress}>
|
|
617
|
+
<View style={{
|
|
618
|
+
flexDirection: 'row',
|
|
619
|
+
alignItems: 'center',
|
|
620
|
+
gap: 12,
|
|
621
|
+
padding: 12,
|
|
622
|
+
borderRadius: 8,
|
|
623
|
+
backgroundColor: selected ? 'rgba(0,0,0,0.1)' : 'transparent',
|
|
624
|
+
}}>
|
|
625
|
+
<Icon name={icon} size={20} />
|
|
626
|
+
<Text>{label}</Text>
|
|
627
|
+
{selected && <Icon name="check" size={20} intent="success" />}
|
|
628
|
+
</View>
|
|
629
|
+
</Pressable>
|
|
630
|
+
);
|
|
631
|
+
}`,
|
|
632
|
+
explanation: `This theme switcher provides:
|
|
633
|
+
- ThemeProvider context for app-wide theme state
|
|
634
|
+
- Persistence with @idealyst/storage
|
|
635
|
+
- Support for light, dark, and system-follow modes
|
|
636
|
+
- Integration with Unistyles runtime
|
|
637
|
+
- Both simple toggle and full selector UI components`,
|
|
638
|
+
tips: [
|
|
639
|
+
"Wrap your app root with ThemeProvider",
|
|
640
|
+
"The system option follows device settings automatically",
|
|
641
|
+
"Theme changes are instant with no reload required",
|
|
642
|
+
],
|
|
643
|
+
relatedRecipes: ["settings-screen"],
|
|
644
|
+
},
|
|
645
|
+
"tab-navigation": {
|
|
646
|
+
name: "Tab Navigation",
|
|
647
|
+
description: "Bottom tab navigation with icons and badges",
|
|
648
|
+
category: "navigation",
|
|
649
|
+
difficulty: "beginner",
|
|
650
|
+
packages: ["@idealyst/components", "@idealyst/navigation"],
|
|
651
|
+
code: `import React from 'react';
|
|
652
|
+
import { Router, TabBar } from '@idealyst/navigation';
|
|
653
|
+
import { Icon, Badge, View } from '@idealyst/components';
|
|
654
|
+
|
|
655
|
+
// Define your screens
|
|
656
|
+
function HomeScreen() {
|
|
657
|
+
return <View><Text>Home</Text></View>;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function SearchScreen() {
|
|
661
|
+
return <View><Text>Search</Text></View>;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function NotificationsScreen() {
|
|
665
|
+
return <View><Text>Notifications</Text></View>;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function ProfileScreen() {
|
|
669
|
+
return <View><Text>Profile</Text></View>;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Route configuration
|
|
673
|
+
const routes = {
|
|
674
|
+
home: {
|
|
675
|
+
path: '/',
|
|
676
|
+
screen: HomeScreen,
|
|
677
|
+
options: {
|
|
678
|
+
title: 'Home',
|
|
679
|
+
tabBarIcon: ({ focused }: { focused: boolean }) => (
|
|
680
|
+
<Icon name={focused ? 'home' : 'home-outline'} size={24} />
|
|
681
|
+
),
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
search: {
|
|
685
|
+
path: '/search',
|
|
686
|
+
screen: SearchScreen,
|
|
687
|
+
options: {
|
|
688
|
+
title: 'Search',
|
|
689
|
+
tabBarIcon: ({ focused }: { focused: boolean }) => (
|
|
690
|
+
<Icon name={focused ? 'magnify' : 'magnify'} size={24} />
|
|
691
|
+
),
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
notifications: {
|
|
695
|
+
path: '/notifications',
|
|
696
|
+
screen: NotificationsScreen,
|
|
697
|
+
options: {
|
|
698
|
+
title: 'Notifications',
|
|
699
|
+
tabBarIcon: ({ focused }: { focused: boolean }) => (
|
|
700
|
+
<View>
|
|
701
|
+
<Icon name={focused ? 'bell' : 'bell-outline'} size={24} />
|
|
702
|
+
{/* Show badge when there are unread notifications */}
|
|
703
|
+
<Badge
|
|
704
|
+
count={3}
|
|
705
|
+
style={{ position: 'absolute', top: -4, right: -8 }}
|
|
706
|
+
/>
|
|
707
|
+
</View>
|
|
708
|
+
),
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
profile: {
|
|
712
|
+
path: '/profile',
|
|
713
|
+
screen: ProfileScreen,
|
|
714
|
+
options: {
|
|
715
|
+
title: 'Profile',
|
|
716
|
+
tabBarIcon: ({ focused }: { focused: boolean }) => (
|
|
717
|
+
<Icon name={focused ? 'account' : 'account-outline'} size={24} />
|
|
718
|
+
),
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
export function App() {
|
|
724
|
+
return (
|
|
725
|
+
<Router
|
|
726
|
+
routes={routes}
|
|
727
|
+
navigator="tabs"
|
|
728
|
+
tabBarPosition="bottom"
|
|
729
|
+
/>
|
|
730
|
+
);
|
|
731
|
+
}`,
|
|
732
|
+
explanation: `This tab navigation setup includes:
|
|
733
|
+
- Four tabs with icons that change when focused
|
|
734
|
+
- Badge on notifications tab for unread count
|
|
735
|
+
- Type-safe route configuration
|
|
736
|
+
- Works on both web and native`,
|
|
737
|
+
tips: [
|
|
738
|
+
"Use outline/filled icon variants to indicate focus state",
|
|
739
|
+
"Keep tab count to 3-5 for best usability",
|
|
740
|
+
"Consider hiding tabs on certain screens (like detail views)",
|
|
741
|
+
],
|
|
742
|
+
relatedRecipes: ["drawer-navigation", "stack-navigation", "protected-route"],
|
|
743
|
+
},
|
|
744
|
+
"drawer-navigation": {
|
|
745
|
+
name: "Drawer Navigation",
|
|
746
|
+
description: "Side drawer menu with navigation items and user profile",
|
|
747
|
+
category: "navigation",
|
|
748
|
+
difficulty: "intermediate",
|
|
749
|
+
packages: ["@idealyst/components", "@idealyst/navigation"],
|
|
750
|
+
code: `import React from 'react';
|
|
751
|
+
import { Router, useNavigator } from '@idealyst/navigation';
|
|
752
|
+
import { View, Text, Icon, Avatar, Pressable, Divider } from '@idealyst/components';
|
|
753
|
+
|
|
754
|
+
// Custom drawer content
|
|
755
|
+
function DrawerContent() {
|
|
756
|
+
const { navigate, currentRoute } = useNavigator();
|
|
757
|
+
|
|
758
|
+
const menuItems = [
|
|
759
|
+
{ route: 'home', icon: 'home', label: 'Home' },
|
|
760
|
+
{ route: 'dashboard', icon: 'view-dashboard', label: 'Dashboard' },
|
|
761
|
+
{ route: 'messages', icon: 'message', label: 'Messages' },
|
|
762
|
+
{ route: 'settings', icon: 'cog', label: 'Settings' },
|
|
763
|
+
];
|
|
764
|
+
|
|
765
|
+
return (
|
|
766
|
+
<View style={{ flex: 1, padding: 16 }}>
|
|
767
|
+
{/* User Profile Header */}
|
|
768
|
+
<View style={{ alignItems: 'center', paddingVertical: 24 }}>
|
|
769
|
+
<Avatar
|
|
770
|
+
source={{ uri: 'https://example.com/avatar.jpg' }}
|
|
771
|
+
size="lg"
|
|
772
|
+
/>
|
|
773
|
+
<Text variant="title" style={{ marginTop: 12 }}>John Doe</Text>
|
|
774
|
+
<Text size="sm" style={{ opacity: 0.7 }}>john@example.com</Text>
|
|
775
|
+
</View>
|
|
776
|
+
|
|
777
|
+
<Divider style={{ marginVertical: 16 }} />
|
|
778
|
+
|
|
779
|
+
{/* Menu Items */}
|
|
780
|
+
<View style={{ gap: 4 }}>
|
|
781
|
+
{menuItems.map((item) => (
|
|
782
|
+
<DrawerItem
|
|
783
|
+
key={item.route}
|
|
784
|
+
icon={item.icon}
|
|
785
|
+
label={item.label}
|
|
786
|
+
active={currentRoute === item.route}
|
|
787
|
+
onPress={() => navigate(item.route)}
|
|
788
|
+
/>
|
|
789
|
+
))}
|
|
790
|
+
</View>
|
|
791
|
+
|
|
792
|
+
{/* Footer */}
|
|
793
|
+
<View style={{ marginTop: 'auto' }}>
|
|
794
|
+
<Divider style={{ marginVertical: 16 }} />
|
|
795
|
+
<DrawerItem
|
|
796
|
+
icon="logout"
|
|
797
|
+
label="Sign Out"
|
|
798
|
+
onPress={() => {
|
|
799
|
+
// Handle logout
|
|
800
|
+
}}
|
|
801
|
+
/>
|
|
802
|
+
</View>
|
|
803
|
+
</View>
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function DrawerItem({
|
|
808
|
+
icon,
|
|
809
|
+
label,
|
|
810
|
+
active,
|
|
811
|
+
onPress
|
|
812
|
+
}: {
|
|
813
|
+
icon: string;
|
|
814
|
+
label: string;
|
|
815
|
+
active?: boolean;
|
|
816
|
+
onPress: () => void;
|
|
817
|
+
}) {
|
|
818
|
+
return (
|
|
819
|
+
<Pressable onPress={onPress}>
|
|
820
|
+
<View style={{
|
|
821
|
+
flexDirection: 'row',
|
|
822
|
+
alignItems: 'center',
|
|
823
|
+
gap: 16,
|
|
824
|
+
padding: 12,
|
|
825
|
+
borderRadius: 8,
|
|
826
|
+
backgroundColor: active ? 'rgba(0,0,0,0.1)' : 'transparent',
|
|
827
|
+
}}>
|
|
828
|
+
<Icon name={icon} size={24} intent={active ? 'primary' : undefined} />
|
|
829
|
+
<Text intent={active ? 'primary' : undefined}>{label}</Text>
|
|
830
|
+
</View>
|
|
831
|
+
</Pressable>
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Route configuration
|
|
836
|
+
const routes = {
|
|
837
|
+
home: { path: '/', screen: HomeScreen },
|
|
838
|
+
dashboard: { path: '/dashboard', screen: DashboardScreen },
|
|
839
|
+
messages: { path: '/messages', screen: MessagesScreen },
|
|
840
|
+
settings: { path: '/settings', screen: SettingsScreen },
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
export function App() {
|
|
844
|
+
return (
|
|
845
|
+
<Router
|
|
846
|
+
routes={routes}
|
|
847
|
+
navigator="drawer"
|
|
848
|
+
drawerContent={DrawerContent}
|
|
849
|
+
/>
|
|
850
|
+
);
|
|
851
|
+
}`,
|
|
852
|
+
explanation: `This drawer navigation includes:
|
|
853
|
+
- Custom drawer content with user profile
|
|
854
|
+
- Active state highlighting for current route
|
|
855
|
+
- Grouped menu items with icons
|
|
856
|
+
- Sign out button at the bottom
|
|
857
|
+
- Works on both web (sidebar) and native (slide-out drawer)`,
|
|
858
|
+
tips: [
|
|
859
|
+
"Add a hamburger menu button to open drawer on native",
|
|
860
|
+
"Consider using drawer on tablet/desktop, tabs on mobile",
|
|
861
|
+
"Add gesture support for swipe-to-open on native",
|
|
862
|
+
],
|
|
863
|
+
relatedRecipes: ["tab-navigation", "stack-navigation"],
|
|
864
|
+
},
|
|
865
|
+
"protected-route": {
|
|
866
|
+
name: "Protected Routes",
|
|
867
|
+
description: "Redirect unauthenticated users to login with auth state management",
|
|
868
|
+
category: "auth",
|
|
869
|
+
difficulty: "intermediate",
|
|
870
|
+
packages: ["@idealyst/navigation", "@idealyst/storage", "@idealyst/components"],
|
|
871
|
+
code: `import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
872
|
+
import { Router, useNavigator } from '@idealyst/navigation';
|
|
873
|
+
import { storage } from '@idealyst/storage';
|
|
874
|
+
import { View, Text, ActivityIndicator } from '@idealyst/components';
|
|
875
|
+
|
|
876
|
+
// Auth Context
|
|
877
|
+
interface User {
|
|
878
|
+
id: string;
|
|
879
|
+
email: string;
|
|
880
|
+
name: string;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
interface AuthContextType {
|
|
884
|
+
user: User | null;
|
|
885
|
+
isLoading: boolean;
|
|
886
|
+
login: (email: string, password: string) => Promise<void>;
|
|
887
|
+
logout: () => Promise<void>;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const AuthContext = createContext<AuthContextType | null>(null);
|
|
891
|
+
|
|
892
|
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
893
|
+
const [user, setUser] = useState<User | null>(null);
|
|
894
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
895
|
+
|
|
896
|
+
useEffect(() => {
|
|
897
|
+
checkAuth();
|
|
898
|
+
}, []);
|
|
899
|
+
|
|
900
|
+
const checkAuth = async () => {
|
|
901
|
+
try {
|
|
902
|
+
const token = await storage.get<string>('auth-token');
|
|
903
|
+
if (token) {
|
|
904
|
+
// Validate token and get user data
|
|
905
|
+
const userData = await fetchUser(token);
|
|
906
|
+
setUser(userData);
|
|
907
|
+
}
|
|
908
|
+
} catch (error) {
|
|
909
|
+
// Token invalid or expired
|
|
910
|
+
await storage.remove('auth-token');
|
|
911
|
+
} finally {
|
|
912
|
+
setIsLoading(false);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const login = async (email: string, password: string) => {
|
|
917
|
+
const { token, user } = await apiLogin(email, password);
|
|
918
|
+
await storage.set('auth-token', token);
|
|
919
|
+
setUser(user);
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
const logout = async () => {
|
|
923
|
+
await storage.remove('auth-token');
|
|
924
|
+
setUser(null);
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
return (
|
|
928
|
+
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
|
|
929
|
+
{children}
|
|
930
|
+
</AuthContext.Provider>
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export function useAuth() {
|
|
935
|
+
const context = useContext(AuthContext);
|
|
936
|
+
if (!context) {
|
|
937
|
+
throw new Error('useAuth must be used within AuthProvider');
|
|
938
|
+
}
|
|
939
|
+
return context;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Protected Route Wrapper
|
|
943
|
+
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
944
|
+
const { user, isLoading } = useAuth();
|
|
945
|
+
const { navigate } = useNavigator();
|
|
946
|
+
|
|
947
|
+
useEffect(() => {
|
|
948
|
+
if (!isLoading && !user) {
|
|
949
|
+
navigate('login');
|
|
950
|
+
}
|
|
951
|
+
}, [user, isLoading]);
|
|
952
|
+
|
|
953
|
+
if (isLoading) {
|
|
954
|
+
return (
|
|
955
|
+
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
956
|
+
<ActivityIndicator size="lg" />
|
|
957
|
+
</View>
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (!user) {
|
|
962
|
+
return null; // Will redirect
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return <>{children}</>;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Route configuration
|
|
969
|
+
const routes = {
|
|
970
|
+
login: {
|
|
971
|
+
path: '/login',
|
|
972
|
+
screen: LoginScreen,
|
|
973
|
+
options: { public: true },
|
|
974
|
+
},
|
|
975
|
+
signup: {
|
|
976
|
+
path: '/signup',
|
|
977
|
+
screen: SignupScreen,
|
|
978
|
+
options: { public: true },
|
|
979
|
+
},
|
|
980
|
+
home: {
|
|
981
|
+
path: '/',
|
|
982
|
+
screen: () => (
|
|
983
|
+
<ProtectedRoute>
|
|
984
|
+
<HomeScreen />
|
|
985
|
+
</ProtectedRoute>
|
|
986
|
+
),
|
|
987
|
+
},
|
|
988
|
+
profile: {
|
|
989
|
+
path: '/profile',
|
|
990
|
+
screen: () => (
|
|
991
|
+
<ProtectedRoute>
|
|
992
|
+
<ProfileScreen />
|
|
993
|
+
</ProtectedRoute>
|
|
994
|
+
),
|
|
995
|
+
},
|
|
996
|
+
settings: {
|
|
997
|
+
path: '/settings',
|
|
998
|
+
screen: () => (
|
|
999
|
+
<ProtectedRoute>
|
|
1000
|
+
<SettingsScreen />
|
|
1001
|
+
</ProtectedRoute>
|
|
1002
|
+
),
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
export function App() {
|
|
1007
|
+
return (
|
|
1008
|
+
<AuthProvider>
|
|
1009
|
+
<Router routes={routes} />
|
|
1010
|
+
</AuthProvider>
|
|
1011
|
+
);
|
|
1012
|
+
}`,
|
|
1013
|
+
explanation: `This protected routes setup includes:
|
|
1014
|
+
- AuthProvider context for app-wide auth state
|
|
1015
|
+
- Token persistence with @idealyst/storage
|
|
1016
|
+
- Loading state while checking authentication
|
|
1017
|
+
- Automatic redirect to login for unauthenticated users
|
|
1018
|
+
- ProtectedRoute wrapper component for easy use`,
|
|
1019
|
+
tips: [
|
|
1020
|
+
"Add token refresh logic for long-lived sessions",
|
|
1021
|
+
"Consider deep link handling for login redirects",
|
|
1022
|
+
"Use @idealyst/oauth-client for OAuth flows",
|
|
1023
|
+
],
|
|
1024
|
+
relatedRecipes: ["login-form", "oauth-flow"],
|
|
1025
|
+
},
|
|
1026
|
+
"data-list": {
|
|
1027
|
+
name: "Data List with Pull-to-Refresh",
|
|
1028
|
+
description: "Scrollable list with pull-to-refresh, loading states, and empty state",
|
|
1029
|
+
category: "data",
|
|
1030
|
+
difficulty: "intermediate",
|
|
1031
|
+
packages: ["@idealyst/components"],
|
|
1032
|
+
code: `import React, { useState, useEffect, useCallback } from 'react';
|
|
1033
|
+
import { FlatList, RefreshControl } from 'react-native';
|
|
1034
|
+
import { View, Text, Card, ActivityIndicator, Button, Icon } from '@idealyst/components';
|
|
1035
|
+
|
|
1036
|
+
interface Item {
|
|
1037
|
+
id: string;
|
|
1038
|
+
title: string;
|
|
1039
|
+
description: string;
|
|
1040
|
+
createdAt: string;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
interface DataListProps {
|
|
1044
|
+
fetchItems: () => Promise<Item[]>;
|
|
1045
|
+
onItemPress?: (item: Item) => void;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export function DataList({ fetchItems, onItemPress }: DataListProps) {
|
|
1049
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
1050
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
1051
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
1052
|
+
const [error, setError] = useState<string | null>(null);
|
|
1053
|
+
|
|
1054
|
+
const loadData = useCallback(async (showLoader = true) => {
|
|
1055
|
+
if (showLoader) setIsLoading(true);
|
|
1056
|
+
setError(null);
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
const data = await fetchItems();
|
|
1060
|
+
setItems(data);
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
setError(err instanceof Error ? err.message : 'Failed to load data');
|
|
1063
|
+
} finally {
|
|
1064
|
+
setIsLoading(false);
|
|
1065
|
+
setIsRefreshing(false);
|
|
1066
|
+
}
|
|
1067
|
+
}, [fetchItems]);
|
|
1068
|
+
|
|
1069
|
+
useEffect(() => {
|
|
1070
|
+
loadData();
|
|
1071
|
+
}, [loadData]);
|
|
1072
|
+
|
|
1073
|
+
const handleRefresh = () => {
|
|
1074
|
+
setIsRefreshing(true);
|
|
1075
|
+
loadData(false);
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
// Loading state
|
|
1079
|
+
if (isLoading) {
|
|
1080
|
+
return (
|
|
1081
|
+
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
1082
|
+
<ActivityIndicator size="lg" />
|
|
1083
|
+
<Text style={{ marginTop: 16 }}>Loading...</Text>
|
|
1084
|
+
</View>
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Error state
|
|
1089
|
+
if (error) {
|
|
1090
|
+
return (
|
|
1091
|
+
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }}>
|
|
1092
|
+
<Icon name="alert-circle" size={48} intent="danger" />
|
|
1093
|
+
<Text variant="title" style={{ marginTop: 16 }}>Something went wrong</Text>
|
|
1094
|
+
<Text style={{ textAlign: 'center', marginTop: 8, opacity: 0.7 }}>
|
|
1095
|
+
{error}
|
|
1096
|
+
</Text>
|
|
1097
|
+
<Button onPress={() => loadData()} style={{ marginTop: 24 }}>
|
|
1098
|
+
Try Again
|
|
1099
|
+
</Button>
|
|
1100
|
+
</View>
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Empty state
|
|
1105
|
+
if (items.length === 0) {
|
|
1106
|
+
return (
|
|
1107
|
+
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }}>
|
|
1108
|
+
<Icon name="inbox" size={48} style={{ opacity: 0.5 }} />
|
|
1109
|
+
<Text variant="title" style={{ marginTop: 16 }}>No items yet</Text>
|
|
1110
|
+
<Text style={{ textAlign: 'center', marginTop: 8, opacity: 0.7 }}>
|
|
1111
|
+
Pull down to refresh or check back later
|
|
1112
|
+
</Text>
|
|
1113
|
+
</View>
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return (
|
|
1118
|
+
<FlatList
|
|
1119
|
+
data={items}
|
|
1120
|
+
keyExtractor={(item) => item.id}
|
|
1121
|
+
contentContainerStyle={{ padding: 16, gap: 12 }}
|
|
1122
|
+
refreshControl={
|
|
1123
|
+
<RefreshControl
|
|
1124
|
+
refreshing={isRefreshing}
|
|
1125
|
+
onRefresh={handleRefresh}
|
|
1126
|
+
/>
|
|
1127
|
+
}
|
|
1128
|
+
renderItem={({ item }) => (
|
|
1129
|
+
<Card
|
|
1130
|
+
onPress={() => onItemPress?.(item)}
|
|
1131
|
+
style={{ padding: 16 }}
|
|
1132
|
+
>
|
|
1133
|
+
<Text variant="title">{item.title}</Text>
|
|
1134
|
+
<Text style={{ marginTop: 4, opacity: 0.7 }}>
|
|
1135
|
+
{item.description}
|
|
1136
|
+
</Text>
|
|
1137
|
+
<Text size="sm" style={{ marginTop: 8, opacity: 0.5 }}>
|
|
1138
|
+
{new Date(item.createdAt).toLocaleDateString()}
|
|
1139
|
+
</Text>
|
|
1140
|
+
</Card>
|
|
1141
|
+
)}
|
|
1142
|
+
/>
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Usage example
|
|
1147
|
+
function MyScreen() {
|
|
1148
|
+
const fetchItems = async () => {
|
|
1149
|
+
const response = await fetch('/api/items');
|
|
1150
|
+
return response.json();
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
return (
|
|
1154
|
+
<DataList
|
|
1155
|
+
fetchItems={fetchItems}
|
|
1156
|
+
onItemPress={(item) => console.log('Selected:', item)}
|
|
1157
|
+
/>
|
|
1158
|
+
);
|
|
1159
|
+
}`,
|
|
1160
|
+
explanation: `This data list component handles:
|
|
1161
|
+
- Initial loading state with spinner
|
|
1162
|
+
- Pull-to-refresh functionality
|
|
1163
|
+
- Error state with retry button
|
|
1164
|
+
- Empty state with helpful message
|
|
1165
|
+
- Efficient FlatList rendering for large lists`,
|
|
1166
|
+
tips: [
|
|
1167
|
+
"Add pagination with onEndReached for large datasets",
|
|
1168
|
+
"Use skeleton loading for smoother perceived performance",
|
|
1169
|
+
"Consider optimistic updates for better UX",
|
|
1170
|
+
],
|
|
1171
|
+
relatedRecipes: ["search-filter", "infinite-scroll"],
|
|
1172
|
+
},
|
|
1173
|
+
"search-filter": {
|
|
1174
|
+
name: "Search with Filters",
|
|
1175
|
+
description: "Search input with filter chips and debounced search",
|
|
1176
|
+
category: "data",
|
|
1177
|
+
difficulty: "intermediate",
|
|
1178
|
+
packages: ["@idealyst/components"],
|
|
1179
|
+
code: `import React, { useState, useEffect, useMemo } from 'react';
|
|
1180
|
+
import { ScrollView } from 'react-native';
|
|
1181
|
+
import { View, Input, Chip, Text, Icon } from '@idealyst/components';
|
|
1182
|
+
|
|
1183
|
+
interface SearchFilterProps<T> {
|
|
1184
|
+
data: T[];
|
|
1185
|
+
searchKeys: (keyof T)[];
|
|
1186
|
+
filterOptions: { key: string; label: string; values: string[] }[];
|
|
1187
|
+
renderItem: (item: T) => React.ReactNode;
|
|
1188
|
+
placeholder?: string;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Debounce hook
|
|
1192
|
+
function useDebounce<T>(value: T, delay: number): T {
|
|
1193
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
1194
|
+
|
|
1195
|
+
useEffect(() => {
|
|
1196
|
+
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
|
1197
|
+
return () => clearTimeout(timer);
|
|
1198
|
+
}, [value, delay]);
|
|
1199
|
+
|
|
1200
|
+
return debouncedValue;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export function SearchFilter<T extends Record<string, any>>({
|
|
1204
|
+
data,
|
|
1205
|
+
searchKeys,
|
|
1206
|
+
filterOptions,
|
|
1207
|
+
renderItem,
|
|
1208
|
+
placeholder = 'Search...',
|
|
1209
|
+
}: SearchFilterProps<T>) {
|
|
1210
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
1211
|
+
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
|
|
1212
|
+
|
|
1213
|
+
const debouncedQuery = useDebounce(searchQuery, 300);
|
|
1214
|
+
|
|
1215
|
+
const toggleFilter = (key: string, value: string) => {
|
|
1216
|
+
setActiveFilters((prev) => {
|
|
1217
|
+
const current = prev[key] || [];
|
|
1218
|
+
const updated = current.includes(value)
|
|
1219
|
+
? current.filter((v) => v !== value)
|
|
1220
|
+
: [...current, value];
|
|
1221
|
+
|
|
1222
|
+
return { ...prev, [key]: updated };
|
|
1223
|
+
});
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
const clearFilters = () => {
|
|
1227
|
+
setActiveFilters({});
|
|
1228
|
+
setSearchQuery('');
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
const filteredData = useMemo(() => {
|
|
1232
|
+
let result = data;
|
|
1233
|
+
|
|
1234
|
+
// Apply search
|
|
1235
|
+
if (debouncedQuery) {
|
|
1236
|
+
const query = debouncedQuery.toLowerCase();
|
|
1237
|
+
result = result.filter((item) =>
|
|
1238
|
+
searchKeys.some((key) =>
|
|
1239
|
+
String(item[key]).toLowerCase().includes(query)
|
|
1240
|
+
)
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Apply filters
|
|
1245
|
+
for (const [key, values] of Object.entries(activeFilters)) {
|
|
1246
|
+
if (values.length > 0) {
|
|
1247
|
+
result = result.filter((item) => values.includes(String(item[key])));
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
return result;
|
|
1252
|
+
}, [data, debouncedQuery, activeFilters, searchKeys]);
|
|
1253
|
+
|
|
1254
|
+
const hasActiveFilters =
|
|
1255
|
+
searchQuery || Object.values(activeFilters).some((v) => v.length > 0);
|
|
1256
|
+
|
|
1257
|
+
return (
|
|
1258
|
+
<View style={{ flex: 1 }}>
|
|
1259
|
+
{/* Search Input */}
|
|
1260
|
+
<View style={{ padding: 16 }}>
|
|
1261
|
+
<Input
|
|
1262
|
+
placeholder={placeholder}
|
|
1263
|
+
value={searchQuery}
|
|
1264
|
+
onChangeText={setSearchQuery}
|
|
1265
|
+
leftIcon="magnify"
|
|
1266
|
+
rightIcon={searchQuery ? 'close' : undefined}
|
|
1267
|
+
onRightIconPress={() => setSearchQuery('')}
|
|
1268
|
+
/>
|
|
1269
|
+
</View>
|
|
1270
|
+
|
|
1271
|
+
{/* Filter Chips */}
|
|
1272
|
+
{filterOptions.map((filter) => (
|
|
1273
|
+
<View key={filter.key} style={{ paddingHorizontal: 16, marginBottom: 12 }}>
|
|
1274
|
+
<Text size="sm" style={{ marginBottom: 8, opacity: 0.7 }}>
|
|
1275
|
+
{filter.label}
|
|
1276
|
+
</Text>
|
|
1277
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
1278
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
1279
|
+
{filter.values.map((value) => (
|
|
1280
|
+
<Chip
|
|
1281
|
+
key={value}
|
|
1282
|
+
selected={(activeFilters[filter.key] || []).includes(value)}
|
|
1283
|
+
onPress={() => toggleFilter(filter.key, value)}
|
|
1284
|
+
>
|
|
1285
|
+
{value}
|
|
1286
|
+
</Chip>
|
|
1287
|
+
))}
|
|
1288
|
+
</View>
|
|
1289
|
+
</ScrollView>
|
|
1290
|
+
</View>
|
|
1291
|
+
))}
|
|
1292
|
+
|
|
1293
|
+
{/* Results Header */}
|
|
1294
|
+
<View style={{
|
|
1295
|
+
flexDirection: 'row',
|
|
1296
|
+
justifyContent: 'space-between',
|
|
1297
|
+
alignItems: 'center',
|
|
1298
|
+
paddingHorizontal: 16,
|
|
1299
|
+
paddingVertical: 8,
|
|
1300
|
+
}}>
|
|
1301
|
+
<Text size="sm" style={{ opacity: 0.7 }}>
|
|
1302
|
+
{filteredData.length} result{filteredData.length !== 1 ? 's' : ''}
|
|
1303
|
+
</Text>
|
|
1304
|
+
{hasActiveFilters && (
|
|
1305
|
+
<Chip onPress={clearFilters} size="sm">
|
|
1306
|
+
<Icon name="close" size={14} /> Clear all
|
|
1307
|
+
</Chip>
|
|
1308
|
+
)}
|
|
1309
|
+
</View>
|
|
1310
|
+
|
|
1311
|
+
{/* Results */}
|
|
1312
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 12 }}>
|
|
1313
|
+
{filteredData.length === 0 ? (
|
|
1314
|
+
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
|
|
1315
|
+
<Icon name="magnify-close" size={48} style={{ opacity: 0.5 }} />
|
|
1316
|
+
<Text style={{ marginTop: 16 }}>No results found</Text>
|
|
1317
|
+
</View>
|
|
1318
|
+
) : (
|
|
1319
|
+
filteredData.map((item, index) => (
|
|
1320
|
+
<View key={index}>{renderItem(item)}</View>
|
|
1321
|
+
))
|
|
1322
|
+
)}
|
|
1323
|
+
</ScrollView>
|
|
1324
|
+
</View>
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Usage example
|
|
1329
|
+
const products = [
|
|
1330
|
+
{ id: '1', name: 'iPhone', category: 'Electronics', price: 999 },
|
|
1331
|
+
{ id: '2', name: 'MacBook', category: 'Electronics', price: 1999 },
|
|
1332
|
+
{ id: '3', name: 'Desk Chair', category: 'Furniture', price: 299 },
|
|
1333
|
+
];
|
|
1334
|
+
|
|
1335
|
+
function ProductSearch() {
|
|
1336
|
+
return (
|
|
1337
|
+
<SearchFilter
|
|
1338
|
+
data={products}
|
|
1339
|
+
searchKeys={['name']}
|
|
1340
|
+
filterOptions={[
|
|
1341
|
+
{ key: 'category', label: 'Category', values: ['Electronics', 'Furniture'] },
|
|
1342
|
+
]}
|
|
1343
|
+
renderItem={(product) => (
|
|
1344
|
+
<Card>
|
|
1345
|
+
<Text>{product.name}</Text>
|
|
1346
|
+
<Text>\${product.price}</Text>
|
|
1347
|
+
</Card>
|
|
1348
|
+
)}
|
|
1349
|
+
/>
|
|
1350
|
+
);
|
|
1351
|
+
}`,
|
|
1352
|
+
explanation: `This search and filter component provides:
|
|
1353
|
+
- Debounced search input (300ms delay)
|
|
1354
|
+
- Multiple filter categories with chips
|
|
1355
|
+
- Combined search + filter logic
|
|
1356
|
+
- Clear all filters button
|
|
1357
|
+
- Result count display
|
|
1358
|
+
- Empty state handling`,
|
|
1359
|
+
tips: [
|
|
1360
|
+
"Add URL query params sync for shareable filtered views",
|
|
1361
|
+
"Consider server-side filtering for large datasets",
|
|
1362
|
+
"Add sort options alongside filters",
|
|
1363
|
+
],
|
|
1364
|
+
relatedRecipes: ["data-list", "infinite-scroll"],
|
|
1365
|
+
},
|
|
1366
|
+
"modal-confirmation": {
|
|
1367
|
+
name: "Confirmation Dialog",
|
|
1368
|
+
description: "Reusable confirmation modal for destructive actions",
|
|
1369
|
+
category: "layout",
|
|
1370
|
+
difficulty: "beginner",
|
|
1371
|
+
packages: ["@idealyst/components"],
|
|
1372
|
+
code: `import React, { createContext, useContext, useState, useCallback } from 'react';
|
|
1373
|
+
import { Dialog, Button, Text, View, Icon } from '@idealyst/components';
|
|
1374
|
+
|
|
1375
|
+
interface ConfirmOptions {
|
|
1376
|
+
title: string;
|
|
1377
|
+
message: string;
|
|
1378
|
+
confirmLabel?: string;
|
|
1379
|
+
cancelLabel?: string;
|
|
1380
|
+
intent?: 'danger' | 'warning' | 'primary';
|
|
1381
|
+
icon?: string;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
interface ConfirmContextType {
|
|
1385
|
+
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const ConfirmContext = createContext<ConfirmContextType | null>(null);
|
|
1389
|
+
|
|
1390
|
+
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
|
1391
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
1392
|
+
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
|
1393
|
+
const [resolveRef, setResolveRef] = useState<((value: boolean) => void) | null>(null);
|
|
1394
|
+
|
|
1395
|
+
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
|
|
1396
|
+
return new Promise((resolve) => {
|
|
1397
|
+
setOptions(opts);
|
|
1398
|
+
setResolveRef(() => resolve);
|
|
1399
|
+
setIsOpen(true);
|
|
1400
|
+
});
|
|
1401
|
+
}, []);
|
|
1402
|
+
|
|
1403
|
+
const handleClose = (confirmed: boolean) => {
|
|
1404
|
+
setIsOpen(false);
|
|
1405
|
+
resolveRef?.(confirmed);
|
|
1406
|
+
// Clean up after animation
|
|
1407
|
+
setTimeout(() => {
|
|
1408
|
+
setOptions(null);
|
|
1409
|
+
setResolveRef(null);
|
|
1410
|
+
}, 300);
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
return (
|
|
1414
|
+
<ConfirmContext.Provider value={{ confirm }}>
|
|
1415
|
+
{children}
|
|
1416
|
+
|
|
1417
|
+
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose(false)}>
|
|
1418
|
+
{options && (
|
|
1419
|
+
<View style={{ padding: 24, alignItems: 'center' }}>
|
|
1420
|
+
{options.icon && (
|
|
1421
|
+
<Icon
|
|
1422
|
+
name={options.icon}
|
|
1423
|
+
size={48}
|
|
1424
|
+
intent={options.intent || 'danger'}
|
|
1425
|
+
style={{ marginBottom: 16 }}
|
|
1426
|
+
/>
|
|
1427
|
+
)}
|
|
1428
|
+
|
|
1429
|
+
<Text variant="headline" style={{ textAlign: 'center' }}>
|
|
1430
|
+
{options.title}
|
|
1431
|
+
</Text>
|
|
1432
|
+
|
|
1433
|
+
<Text style={{ textAlign: 'center', marginTop: 8, opacity: 0.7 }}>
|
|
1434
|
+
{options.message}
|
|
1435
|
+
</Text>
|
|
1436
|
+
|
|
1437
|
+
<View style={{
|
|
1438
|
+
flexDirection: 'row',
|
|
1439
|
+
gap: 12,
|
|
1440
|
+
marginTop: 24,
|
|
1441
|
+
width: '100%',
|
|
1442
|
+
}}>
|
|
1443
|
+
<Button
|
|
1444
|
+
type="outlined"
|
|
1445
|
+
onPress={() => handleClose(false)}
|
|
1446
|
+
style={{ flex: 1 }}
|
|
1447
|
+
>
|
|
1448
|
+
{options.cancelLabel || 'Cancel'}
|
|
1449
|
+
</Button>
|
|
1450
|
+
|
|
1451
|
+
<Button
|
|
1452
|
+
intent={options.intent || 'danger'}
|
|
1453
|
+
onPress={() => handleClose(true)}
|
|
1454
|
+
style={{ flex: 1 }}
|
|
1455
|
+
>
|
|
1456
|
+
{options.confirmLabel || 'Confirm'}
|
|
1457
|
+
</Button>
|
|
1458
|
+
</View>
|
|
1459
|
+
</View>
|
|
1460
|
+
)}
|
|
1461
|
+
</Dialog>
|
|
1462
|
+
</ConfirmContext.Provider>
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
export function useConfirm() {
|
|
1467
|
+
const context = useContext(ConfirmContext);
|
|
1468
|
+
if (!context) {
|
|
1469
|
+
throw new Error('useConfirm must be used within ConfirmProvider');
|
|
1470
|
+
}
|
|
1471
|
+
return context.confirm;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Usage example
|
|
1475
|
+
function DeleteButton({ onDelete }: { onDelete: () => void }) {
|
|
1476
|
+
const confirm = useConfirm();
|
|
1477
|
+
|
|
1478
|
+
const handleDelete = async () => {
|
|
1479
|
+
const confirmed = await confirm({
|
|
1480
|
+
title: 'Delete Item?',
|
|
1481
|
+
message: 'This action cannot be undone. Are you sure you want to delete this item?',
|
|
1482
|
+
confirmLabel: 'Delete',
|
|
1483
|
+
cancelLabel: 'Keep',
|
|
1484
|
+
intent: 'danger',
|
|
1485
|
+
icon: 'delete',
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
if (confirmed) {
|
|
1489
|
+
onDelete();
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
return (
|
|
1494
|
+
<Button intent="danger" type="outlined" onPress={handleDelete}>
|
|
1495
|
+
Delete
|
|
1496
|
+
</Button>
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Wrap your app
|
|
1501
|
+
function App() {
|
|
1502
|
+
return (
|
|
1503
|
+
<ConfirmProvider>
|
|
1504
|
+
<MyApp />
|
|
1505
|
+
</ConfirmProvider>
|
|
1506
|
+
);
|
|
1507
|
+
}`,
|
|
1508
|
+
explanation: `This confirmation dialog system provides:
|
|
1509
|
+
- Async/await API for easy use: \`if (await confirm({...})) { ... }\`
|
|
1510
|
+
- Customizable title, message, buttons, and icon
|
|
1511
|
+
- Intent-based styling (danger, warning, primary)
|
|
1512
|
+
- Promise-based resolution
|
|
1513
|
+
- Clean context-based architecture`,
|
|
1514
|
+
tips: [
|
|
1515
|
+
"Use danger intent for destructive actions",
|
|
1516
|
+
"Keep messages concise and actionable",
|
|
1517
|
+
"Consider adding a 'Don't ask again' checkbox for repeated actions",
|
|
1518
|
+
],
|
|
1519
|
+
relatedRecipes: ["toast-notifications"],
|
|
1520
|
+
},
|
|
1521
|
+
"toast-notifications": {
|
|
1522
|
+
name: "Toast Notifications",
|
|
1523
|
+
description: "Temporary notification messages that auto-dismiss",
|
|
1524
|
+
category: "layout",
|
|
1525
|
+
difficulty: "intermediate",
|
|
1526
|
+
packages: ["@idealyst/components"],
|
|
1527
|
+
code: `import React, { createContext, useContext, useState, useCallback } from 'react';
|
|
1528
|
+
import { Animated, Pressable } from 'react-native';
|
|
1529
|
+
import { View, Text, Icon } from '@idealyst/components';
|
|
1530
|
+
|
|
1531
|
+
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
|
1532
|
+
|
|
1533
|
+
interface Toast {
|
|
1534
|
+
id: string;
|
|
1535
|
+
type: ToastType;
|
|
1536
|
+
message: string;
|
|
1537
|
+
duration?: number;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
interface ToastContextType {
|
|
1541
|
+
showToast: (type: ToastType, message: string, duration?: number) => void;
|
|
1542
|
+
success: (message: string) => void;
|
|
1543
|
+
error: (message: string) => void;
|
|
1544
|
+
warning: (message: string) => void;
|
|
1545
|
+
info: (message: string) => void;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const ToastContext = createContext<ToastContextType | null>(null);
|
|
1549
|
+
|
|
1550
|
+
const toastConfig: Record<ToastType, { icon: string; intent: string }> = {
|
|
1551
|
+
success: { icon: 'check-circle', intent: 'success' },
|
|
1552
|
+
error: { icon: 'alert-circle', intent: 'danger' },
|
|
1553
|
+
warning: { icon: 'alert', intent: 'warning' },
|
|
1554
|
+
info: { icon: 'information', intent: 'primary' },
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
1558
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
1559
|
+
|
|
1560
|
+
const removeToast = useCallback((id: string) => {
|
|
1561
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
1562
|
+
}, []);
|
|
1563
|
+
|
|
1564
|
+
const showToast = useCallback((type: ToastType, message: string, duration = 3000) => {
|
|
1565
|
+
const id = Date.now().toString();
|
|
1566
|
+
const toast: Toast = { id, type, message, duration };
|
|
1567
|
+
|
|
1568
|
+
setToasts((prev) => [...prev, toast]);
|
|
1569
|
+
|
|
1570
|
+
if (duration > 0) {
|
|
1571
|
+
setTimeout(() => removeToast(id), duration);
|
|
1572
|
+
}
|
|
1573
|
+
}, [removeToast]);
|
|
1574
|
+
|
|
1575
|
+
const contextValue: ToastContextType = {
|
|
1576
|
+
showToast,
|
|
1577
|
+
success: (msg) => showToast('success', msg),
|
|
1578
|
+
error: (msg) => showToast('error', msg),
|
|
1579
|
+
warning: (msg) => showToast('warning', msg),
|
|
1580
|
+
info: (msg) => showToast('info', msg),
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1583
|
+
return (
|
|
1584
|
+
<ToastContext.Provider value={contextValue}>
|
|
1585
|
+
{children}
|
|
1586
|
+
|
|
1587
|
+
{/* Toast Container */}
|
|
1588
|
+
<View
|
|
1589
|
+
style={{
|
|
1590
|
+
position: 'absolute',
|
|
1591
|
+
top: 60,
|
|
1592
|
+
left: 16,
|
|
1593
|
+
right: 16,
|
|
1594
|
+
zIndex: 9999,
|
|
1595
|
+
gap: 8,
|
|
1596
|
+
}}
|
|
1597
|
+
pointerEvents="box-none"
|
|
1598
|
+
>
|
|
1599
|
+
{toasts.map((toast) => (
|
|
1600
|
+
<ToastItem
|
|
1601
|
+
key={toast.id}
|
|
1602
|
+
toast={toast}
|
|
1603
|
+
onDismiss={() => removeToast(toast.id)}
|
|
1604
|
+
/>
|
|
1605
|
+
))}
|
|
1606
|
+
</View>
|
|
1607
|
+
</ToastContext.Provider>
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
|
|
1612
|
+
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
|
1613
|
+
const config = toastConfig[toast.type];
|
|
1614
|
+
|
|
1615
|
+
React.useEffect(() => {
|
|
1616
|
+
Animated.timing(fadeAnim, {
|
|
1617
|
+
toValue: 1,
|
|
1618
|
+
duration: 200,
|
|
1619
|
+
useNativeDriver: true,
|
|
1620
|
+
}).start();
|
|
1621
|
+
}, []);
|
|
1622
|
+
|
|
1623
|
+
return (
|
|
1624
|
+
<Animated.View style={{ opacity: fadeAnim, transform: [{ translateY: fadeAnim.interpolate({
|
|
1625
|
+
inputRange: [0, 1],
|
|
1626
|
+
outputRange: [-20, 0],
|
|
1627
|
+
}) }] }}>
|
|
1628
|
+
<Pressable onPress={onDismiss}>
|
|
1629
|
+
<View
|
|
1630
|
+
style={{
|
|
1631
|
+
flexDirection: 'row',
|
|
1632
|
+
alignItems: 'center',
|
|
1633
|
+
gap: 12,
|
|
1634
|
+
padding: 16,
|
|
1635
|
+
borderRadius: 8,
|
|
1636
|
+
backgroundColor: '#1a1a1a',
|
|
1637
|
+
shadowColor: '#000',
|
|
1638
|
+
shadowOffset: { width: 0, height: 2 },
|
|
1639
|
+
shadowOpacity: 0.25,
|
|
1640
|
+
shadowRadius: 4,
|
|
1641
|
+
elevation: 5,
|
|
1642
|
+
}}
|
|
1643
|
+
>
|
|
1644
|
+
<Icon name={config.icon} size={20} intent={config.intent as any} />
|
|
1645
|
+
<Text style={{ flex: 1, color: '#fff' }}>{toast.message}</Text>
|
|
1646
|
+
<Icon name="close" size={16} style={{ opacity: 0.5 }} />
|
|
1647
|
+
</View>
|
|
1648
|
+
</Pressable>
|
|
1649
|
+
</Animated.View>
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
export function useToast() {
|
|
1654
|
+
const context = useContext(ToastContext);
|
|
1655
|
+
if (!context) {
|
|
1656
|
+
throw new Error('useToast must be used within ToastProvider');
|
|
1657
|
+
}
|
|
1658
|
+
return context;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Usage example
|
|
1662
|
+
function SaveButton() {
|
|
1663
|
+
const toast = useToast();
|
|
1664
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
1665
|
+
|
|
1666
|
+
const handleSave = async () => {
|
|
1667
|
+
setIsSaving(true);
|
|
1668
|
+
try {
|
|
1669
|
+
await saveData();
|
|
1670
|
+
toast.success('Changes saved successfully!');
|
|
1671
|
+
} catch (error) {
|
|
1672
|
+
toast.error('Failed to save changes. Please try again.');
|
|
1673
|
+
} finally {
|
|
1674
|
+
setIsSaving(false);
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1677
|
+
|
|
1678
|
+
return (
|
|
1679
|
+
<Button onPress={handleSave} loading={isSaving}>
|
|
1680
|
+
Save
|
|
1681
|
+
</Button>
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// Wrap your app
|
|
1686
|
+
function App() {
|
|
1687
|
+
return (
|
|
1688
|
+
<ToastProvider>
|
|
1689
|
+
<MyApp />
|
|
1690
|
+
</ToastProvider>
|
|
1691
|
+
);
|
|
1692
|
+
}`,
|
|
1693
|
+
explanation: `This toast notification system provides:
|
|
1694
|
+
- Simple API: \`toast.success('Message')\`
|
|
1695
|
+
- Four types: success, error, warning, info
|
|
1696
|
+
- Auto-dismiss with configurable duration
|
|
1697
|
+
- Tap to dismiss
|
|
1698
|
+
- Animated entrance
|
|
1699
|
+
- Stacking multiple toasts`,
|
|
1700
|
+
tips: [
|
|
1701
|
+
"Use success for completed actions, error for failures",
|
|
1702
|
+
"Keep messages under 50 characters for readability",
|
|
1703
|
+
"Don't show toasts for every action - use sparingly",
|
|
1704
|
+
],
|
|
1705
|
+
relatedRecipes: ["modal-confirmation"],
|
|
1706
|
+
},
|
|
1707
|
+
"form-with-validation": {
|
|
1708
|
+
name: "Form with Validation",
|
|
1709
|
+
description: "Multi-field form with real-time validation and error handling",
|
|
1710
|
+
category: "forms",
|
|
1711
|
+
difficulty: "intermediate",
|
|
1712
|
+
packages: ["@idealyst/components"],
|
|
1713
|
+
code: `import React, { useState } from 'react';
|
|
1714
|
+
import { ScrollView } from 'react-native';
|
|
1715
|
+
import {
|
|
1716
|
+
View, Text, Input, Select, Checkbox, Button, Card
|
|
1717
|
+
} from '@idealyst/components';
|
|
1718
|
+
|
|
1719
|
+
// Validation rules
|
|
1720
|
+
type ValidationRule<T> = {
|
|
1721
|
+
validate: (value: T, formData: FormData) => boolean;
|
|
1722
|
+
message: string;
|
|
1723
|
+
};
|
|
1724
|
+
|
|
1725
|
+
interface FormData {
|
|
1726
|
+
name: string;
|
|
1727
|
+
email: string;
|
|
1728
|
+
phone: string;
|
|
1729
|
+
country: string;
|
|
1730
|
+
message: string;
|
|
1731
|
+
subscribe: boolean;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
const validationRules: Partial<Record<keyof FormData, ValidationRule<any>[]>> = {
|
|
1735
|
+
name: [
|
|
1736
|
+
{ validate: (v) => v.trim().length > 0, message: 'Name is required' },
|
|
1737
|
+
{ validate: (v) => v.trim().length >= 2, message: 'Name must be at least 2 characters' },
|
|
1738
|
+
],
|
|
1739
|
+
email: [
|
|
1740
|
+
{ validate: (v) => v.length > 0, message: 'Email is required' },
|
|
1741
|
+
{ validate: (v) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(v), message: 'Invalid email format' },
|
|
1742
|
+
],
|
|
1743
|
+
phone: [
|
|
1744
|
+
{ validate: (v) => !v || /^[+]?[0-9\\s-]{10,}$/.test(v), message: 'Invalid phone number' },
|
|
1745
|
+
],
|
|
1746
|
+
country: [
|
|
1747
|
+
{ validate: (v) => v.length > 0, message: 'Please select a country' },
|
|
1748
|
+
],
|
|
1749
|
+
message: [
|
|
1750
|
+
{ validate: (v) => v.length > 0, message: 'Message is required' },
|
|
1751
|
+
{ validate: (v) => v.length >= 10, message: 'Message must be at least 10 characters' },
|
|
1752
|
+
],
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
export function ContactForm({ onSubmit }: { onSubmit: (data: FormData) => Promise<void> }) {
|
|
1756
|
+
const [formData, setFormData] = useState<FormData>({
|
|
1757
|
+
name: '',
|
|
1758
|
+
email: '',
|
|
1759
|
+
phone: '',
|
|
1760
|
+
country: '',
|
|
1761
|
+
message: '',
|
|
1762
|
+
subscribe: false,
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
|
1766
|
+
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
|
|
1767
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
1768
|
+
|
|
1769
|
+
const validateField = (field: keyof FormData, value: any): string | undefined => {
|
|
1770
|
+
const rules = validationRules[field];
|
|
1771
|
+
if (!rules) return undefined;
|
|
1772
|
+
|
|
1773
|
+
for (const rule of rules) {
|
|
1774
|
+
if (!rule.validate(value, formData)) {
|
|
1775
|
+
return rule.message;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
return undefined;
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
const validateAll = (): boolean => {
|
|
1782
|
+
const newErrors: typeof errors = {};
|
|
1783
|
+
let isValid = true;
|
|
1784
|
+
|
|
1785
|
+
for (const field of Object.keys(validationRules) as (keyof FormData)[]) {
|
|
1786
|
+
const error = validateField(field, formData[field]);
|
|
1787
|
+
if (error) {
|
|
1788
|
+
newErrors[field] = error;
|
|
1789
|
+
isValid = false;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
setErrors(newErrors);
|
|
1794
|
+
return isValid;
|
|
1795
|
+
};
|
|
1796
|
+
|
|
1797
|
+
const handleChange = (field: keyof FormData, value: any) => {
|
|
1798
|
+
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
1799
|
+
|
|
1800
|
+
// Validate on change if field was touched
|
|
1801
|
+
if (touched[field]) {
|
|
1802
|
+
const error = validateField(field, value);
|
|
1803
|
+
setErrors((prev) => ({ ...prev, [field]: error }));
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
|
|
1807
|
+
const handleBlur = (field: keyof FormData) => {
|
|
1808
|
+
setTouched((prev) => ({ ...prev, [field]: true }));
|
|
1809
|
+
const error = validateField(field, formData[field]);
|
|
1810
|
+
setErrors((prev) => ({ ...prev, [field]: error }));
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
const handleSubmit = async () => {
|
|
1814
|
+
// Mark all fields as touched
|
|
1815
|
+
const allTouched = Object.keys(formData).reduce(
|
|
1816
|
+
(acc, key) => ({ ...acc, [key]: true }),
|
|
1817
|
+
{}
|
|
1818
|
+
);
|
|
1819
|
+
setTouched(allTouched);
|
|
1820
|
+
|
|
1821
|
+
if (!validateAll()) return;
|
|
1822
|
+
|
|
1823
|
+
setIsSubmitting(true);
|
|
1824
|
+
try {
|
|
1825
|
+
await onSubmit(formData);
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
setErrors({
|
|
1828
|
+
submit: error instanceof Error ? error.message : 'Submission failed'
|
|
1829
|
+
} as any);
|
|
1830
|
+
} finally {
|
|
1831
|
+
setIsSubmitting(false);
|
|
1832
|
+
}
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
return (
|
|
1836
|
+
<ScrollView>
|
|
1837
|
+
<Card padding="lg">
|
|
1838
|
+
<Text variant="headline" style={{ marginBottom: 24 }}>
|
|
1839
|
+
Contact Us
|
|
1840
|
+
</Text>
|
|
1841
|
+
|
|
1842
|
+
<View style={{ gap: 16 }}>
|
|
1843
|
+
<Input
|
|
1844
|
+
label="Name *"
|
|
1845
|
+
placeholder="Your full name"
|
|
1846
|
+
value={formData.name}
|
|
1847
|
+
onChangeText={(v) => handleChange('name', v)}
|
|
1848
|
+
onBlur={() => handleBlur('name')}
|
|
1849
|
+
error={touched.name ? errors.name : undefined}
|
|
1850
|
+
/>
|
|
1851
|
+
|
|
1852
|
+
<Input
|
|
1853
|
+
label="Email *"
|
|
1854
|
+
placeholder="you@example.com"
|
|
1855
|
+
value={formData.email}
|
|
1856
|
+
onChangeText={(v) => handleChange('email', v)}
|
|
1857
|
+
onBlur={() => handleBlur('email')}
|
|
1858
|
+
keyboardType="email-address"
|
|
1859
|
+
autoCapitalize="none"
|
|
1860
|
+
error={touched.email ? errors.email : undefined}
|
|
1861
|
+
/>
|
|
1862
|
+
|
|
1863
|
+
<Input
|
|
1864
|
+
label="Phone"
|
|
1865
|
+
placeholder="+1 234 567 8900"
|
|
1866
|
+
value={formData.phone}
|
|
1867
|
+
onChangeText={(v) => handleChange('phone', v)}
|
|
1868
|
+
onBlur={() => handleBlur('phone')}
|
|
1869
|
+
keyboardType="phone-pad"
|
|
1870
|
+
error={touched.phone ? errors.phone : undefined}
|
|
1871
|
+
/>
|
|
1872
|
+
|
|
1873
|
+
<Select
|
|
1874
|
+
label="Country *"
|
|
1875
|
+
placeholder="Select your country"
|
|
1876
|
+
value={formData.country}
|
|
1877
|
+
onValueChange={(v) => handleChange('country', v)}
|
|
1878
|
+
options={[
|
|
1879
|
+
{ label: 'United States', value: 'us' },
|
|
1880
|
+
{ label: 'United Kingdom', value: 'uk' },
|
|
1881
|
+
{ label: 'Canada', value: 'ca' },
|
|
1882
|
+
{ label: 'Australia', value: 'au' },
|
|
1883
|
+
{ label: 'Other', value: 'other' },
|
|
1884
|
+
]}
|
|
1885
|
+
error={touched.country ? errors.country : undefined}
|
|
1886
|
+
/>
|
|
1887
|
+
|
|
1888
|
+
<Input
|
|
1889
|
+
label="Message *"
|
|
1890
|
+
placeholder="How can we help you?"
|
|
1891
|
+
value={formData.message}
|
|
1892
|
+
onChangeText={(v) => handleChange('message', v)}
|
|
1893
|
+
onBlur={() => handleBlur('message')}
|
|
1894
|
+
multiline
|
|
1895
|
+
numberOfLines={4}
|
|
1896
|
+
error={touched.message ? errors.message : undefined}
|
|
1897
|
+
/>
|
|
1898
|
+
|
|
1899
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
1900
|
+
<Checkbox
|
|
1901
|
+
checked={formData.subscribe}
|
|
1902
|
+
onCheckedChange={(v) => handleChange('subscribe', v)}
|
|
1903
|
+
/>
|
|
1904
|
+
<Text>Subscribe to our newsletter</Text>
|
|
1905
|
+
</View>
|
|
1906
|
+
|
|
1907
|
+
<Button
|
|
1908
|
+
onPress={handleSubmit}
|
|
1909
|
+
loading={isSubmitting}
|
|
1910
|
+
disabled={isSubmitting}
|
|
1911
|
+
style={{ marginTop: 8 }}
|
|
1912
|
+
>
|
|
1913
|
+
Send Message
|
|
1914
|
+
</Button>
|
|
1915
|
+
</View>
|
|
1916
|
+
</Card>
|
|
1917
|
+
</ScrollView>
|
|
1918
|
+
);
|
|
1919
|
+
}`,
|
|
1920
|
+
explanation: `This form demonstrates:
|
|
1921
|
+
- Field-level validation with custom rules
|
|
1922
|
+
- Validation on blur (after first touch)
|
|
1923
|
+
- Real-time validation after field is touched
|
|
1924
|
+
- Full form validation on submit
|
|
1925
|
+
- Error display with touched state tracking
|
|
1926
|
+
- Loading state during submission`,
|
|
1927
|
+
tips: [
|
|
1928
|
+
"Consider using a form library like react-hook-form for complex forms",
|
|
1929
|
+
"Add success state/message after submission",
|
|
1930
|
+
"Implement autosave for long forms",
|
|
1931
|
+
],
|
|
1932
|
+
relatedRecipes: ["login-form", "signup-form"],
|
|
1933
|
+
},
|
|
1934
|
+
"image-upload": {
|
|
1935
|
+
name: "Image Upload",
|
|
1936
|
+
description: "Image picker with preview, crop option, and upload progress",
|
|
1937
|
+
category: "media",
|
|
1938
|
+
difficulty: "intermediate",
|
|
1939
|
+
packages: ["@idealyst/components", "@idealyst/camera"],
|
|
1940
|
+
code: `import React, { useState } from 'react';
|
|
1941
|
+
import { Image } from 'react-native';
|
|
1942
|
+
import { View, Text, Button, Card, Icon, Progress } from '@idealyst/components';
|
|
1943
|
+
// Note: You'll need expo-image-picker or react-native-image-picker
|
|
1944
|
+
|
|
1945
|
+
interface ImageUploadProps {
|
|
1946
|
+
onUpload: (uri: string) => Promise<string>; // Returns uploaded URL
|
|
1947
|
+
currentImage?: string;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
export function ImageUpload({ onUpload, currentImage }: ImageUploadProps) {
|
|
1951
|
+
const [imageUri, setImageUri] = useState<string | null>(currentImage || null);
|
|
1952
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
1953
|
+
const [uploadProgress, setUploadProgress] = useState(0);
|
|
1954
|
+
const [error, setError] = useState<string | null>(null);
|
|
1955
|
+
|
|
1956
|
+
const pickImage = async () => {
|
|
1957
|
+
try {
|
|
1958
|
+
// Using expo-image-picker as example
|
|
1959
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
1960
|
+
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
1961
|
+
allowsEditing: true,
|
|
1962
|
+
aspect: [1, 1],
|
|
1963
|
+
quality: 0.8,
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
if (!result.canceled && result.assets[0]) {
|
|
1967
|
+
setImageUri(result.assets[0].uri);
|
|
1968
|
+
setError(null);
|
|
1969
|
+
}
|
|
1970
|
+
} catch (err) {
|
|
1971
|
+
setError('Failed to pick image');
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
|
|
1975
|
+
const takePhoto = async () => {
|
|
1976
|
+
try {
|
|
1977
|
+
const result = await ImagePicker.launchCameraAsync({
|
|
1978
|
+
allowsEditing: true,
|
|
1979
|
+
aspect: [1, 1],
|
|
1980
|
+
quality: 0.8,
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
if (!result.canceled && result.assets[0]) {
|
|
1984
|
+
setImageUri(result.assets[0].uri);
|
|
1985
|
+
setError(null);
|
|
1986
|
+
}
|
|
1987
|
+
} catch (err) {
|
|
1988
|
+
setError('Failed to take photo');
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
const handleUpload = async () => {
|
|
1993
|
+
if (!imageUri) return;
|
|
1994
|
+
|
|
1995
|
+
setIsUploading(true);
|
|
1996
|
+
setUploadProgress(0);
|
|
1997
|
+
setError(null);
|
|
1998
|
+
|
|
1999
|
+
try {
|
|
2000
|
+
// Simulate upload progress
|
|
2001
|
+
const progressInterval = setInterval(() => {
|
|
2002
|
+
setUploadProgress((prev) => Math.min(prev + 10, 90));
|
|
2003
|
+
}, 200);
|
|
2004
|
+
|
|
2005
|
+
const uploadedUrl = await onUpload(imageUri);
|
|
2006
|
+
|
|
2007
|
+
clearInterval(progressInterval);
|
|
2008
|
+
setUploadProgress(100);
|
|
2009
|
+
setImageUri(uploadedUrl);
|
|
2010
|
+
} catch (err) {
|
|
2011
|
+
setError(err instanceof Error ? err.message : 'Upload failed');
|
|
2012
|
+
} finally {
|
|
2013
|
+
setIsUploading(false);
|
|
2014
|
+
}
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
const removeImage = () => {
|
|
2018
|
+
setImageUri(null);
|
|
2019
|
+
setUploadProgress(0);
|
|
2020
|
+
setError(null);
|
|
2021
|
+
};
|
|
2022
|
+
|
|
2023
|
+
return (
|
|
2024
|
+
<Card padding="lg">
|
|
2025
|
+
<Text variant="title" style={{ marginBottom: 16 }}>
|
|
2026
|
+
Profile Photo
|
|
2027
|
+
</Text>
|
|
2028
|
+
|
|
2029
|
+
{/* Image Preview */}
|
|
2030
|
+
<View style={{ alignItems: 'center', marginBottom: 16 }}>
|
|
2031
|
+
{imageUri ? (
|
|
2032
|
+
<View style={{ position: 'relative' }}>
|
|
2033
|
+
<Image
|
|
2034
|
+
source={{ uri: imageUri }}
|
|
2035
|
+
style={{
|
|
2036
|
+
width: 150,
|
|
2037
|
+
height: 150,
|
|
2038
|
+
borderRadius: 75,
|
|
2039
|
+
}}
|
|
2040
|
+
/>
|
|
2041
|
+
<Pressable
|
|
2042
|
+
onPress={removeImage}
|
|
2043
|
+
style={{
|
|
2044
|
+
position: 'absolute',
|
|
2045
|
+
top: 0,
|
|
2046
|
+
right: 0,
|
|
2047
|
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
2048
|
+
borderRadius: 12,
|
|
2049
|
+
padding: 4,
|
|
2050
|
+
}}
|
|
2051
|
+
>
|
|
2052
|
+
<Icon name="close" size={16} color="#fff" />
|
|
2053
|
+
</Pressable>
|
|
2054
|
+
</View>
|
|
2055
|
+
) : (
|
|
2056
|
+
<View
|
|
2057
|
+
style={{
|
|
2058
|
+
width: 150,
|
|
2059
|
+
height: 150,
|
|
2060
|
+
borderRadius: 75,
|
|
2061
|
+
backgroundColor: 'rgba(0,0,0,0.1)',
|
|
2062
|
+
justifyContent: 'center',
|
|
2063
|
+
alignItems: 'center',
|
|
2064
|
+
}}
|
|
2065
|
+
>
|
|
2066
|
+
<Icon name="account" size={64} style={{ opacity: 0.3 }} />
|
|
2067
|
+
</View>
|
|
2068
|
+
)}
|
|
2069
|
+
</View>
|
|
2070
|
+
|
|
2071
|
+
{/* Upload Progress */}
|
|
2072
|
+
{isUploading && (
|
|
2073
|
+
<View style={{ marginBottom: 16 }}>
|
|
2074
|
+
<Progress value={uploadProgress} />
|
|
2075
|
+
<Text size="sm" style={{ textAlign: 'center', marginTop: 4 }}>
|
|
2076
|
+
Uploading... {uploadProgress}%
|
|
2077
|
+
</Text>
|
|
2078
|
+
</View>
|
|
2079
|
+
)}
|
|
2080
|
+
|
|
2081
|
+
{/* Error Message */}
|
|
2082
|
+
{error && (
|
|
2083
|
+
<Text intent="danger" style={{ textAlign: 'center', marginBottom: 16 }}>
|
|
2084
|
+
{error}
|
|
2085
|
+
</Text>
|
|
2086
|
+
)}
|
|
2087
|
+
|
|
2088
|
+
{/* Action Buttons */}
|
|
2089
|
+
<View style={{ gap: 12 }}>
|
|
2090
|
+
<View style={{ flexDirection: 'row', gap: 12 }}>
|
|
2091
|
+
<Button
|
|
2092
|
+
type="outlined"
|
|
2093
|
+
onPress={pickImage}
|
|
2094
|
+
disabled={isUploading}
|
|
2095
|
+
style={{ flex: 1 }}
|
|
2096
|
+
>
|
|
2097
|
+
<Icon name="image" size={18} /> Gallery
|
|
2098
|
+
</Button>
|
|
2099
|
+
<Button
|
|
2100
|
+
type="outlined"
|
|
2101
|
+
onPress={takePhoto}
|
|
2102
|
+
disabled={isUploading}
|
|
2103
|
+
style={{ flex: 1 }}
|
|
2104
|
+
>
|
|
2105
|
+
<Icon name="camera" size={18} /> Camera
|
|
2106
|
+
</Button>
|
|
2107
|
+
</View>
|
|
2108
|
+
|
|
2109
|
+
{imageUri && !imageUri.startsWith('http') && (
|
|
2110
|
+
<Button
|
|
2111
|
+
onPress={handleUpload}
|
|
2112
|
+
loading={isUploading}
|
|
2113
|
+
disabled={isUploading}
|
|
2114
|
+
>
|
|
2115
|
+
Upload Photo
|
|
2116
|
+
</Button>
|
|
2117
|
+
)}
|
|
2118
|
+
</View>
|
|
2119
|
+
</Card>
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Usage
|
|
2124
|
+
function ProfileScreen() {
|
|
2125
|
+
const uploadImage = async (uri: string): Promise<string> => {
|
|
2126
|
+
// Upload to your server/cloud storage
|
|
2127
|
+
const formData = new FormData();
|
|
2128
|
+
formData.append('image', {
|
|
2129
|
+
uri,
|
|
2130
|
+
type: 'image/jpeg',
|
|
2131
|
+
name: 'photo.jpg',
|
|
2132
|
+
} as any);
|
|
2133
|
+
|
|
2134
|
+
const response = await fetch('/api/upload', {
|
|
2135
|
+
method: 'POST',
|
|
2136
|
+
body: formData,
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
const { url } = await response.json();
|
|
2140
|
+
return url;
|
|
2141
|
+
};
|
|
2142
|
+
|
|
2143
|
+
return (
|
|
2144
|
+
<ImageUpload
|
|
2145
|
+
currentImage="https://example.com/current-avatar.jpg"
|
|
2146
|
+
onUpload={uploadImage}
|
|
2147
|
+
/>
|
|
2148
|
+
);
|
|
2149
|
+
}`,
|
|
2150
|
+
explanation: `This image upload component provides:
|
|
2151
|
+
- Pick from gallery or take photo
|
|
2152
|
+
- Image preview with circular crop
|
|
2153
|
+
- Upload progress indicator
|
|
2154
|
+
- Error handling
|
|
2155
|
+
- Remove/replace image option
|
|
2156
|
+
- Works with any backend upload API`,
|
|
2157
|
+
tips: [
|
|
2158
|
+
"Add image compression before upload to reduce size",
|
|
2159
|
+
"Consider using a CDN for image hosting",
|
|
2160
|
+
"Implement retry logic for failed uploads",
|
|
2161
|
+
],
|
|
2162
|
+
relatedRecipes: ["form-with-validation"],
|
|
2163
|
+
},
|
|
2164
|
+
};
|
|
2165
|
+
/**
|
|
2166
|
+
* Get all recipes grouped by category
|
|
2167
|
+
*/
|
|
2168
|
+
export function getRecipesByCategory() {
|
|
2169
|
+
const grouped = {};
|
|
2170
|
+
for (const recipe of Object.values(recipes)) {
|
|
2171
|
+
if (!grouped[recipe.category]) {
|
|
2172
|
+
grouped[recipe.category] = [];
|
|
2173
|
+
}
|
|
2174
|
+
grouped[recipe.category].push(recipe);
|
|
2175
|
+
}
|
|
2176
|
+
return grouped;
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Get a summary list of all recipes
|
|
2180
|
+
*/
|
|
2181
|
+
export function getRecipeSummary() {
|
|
2182
|
+
return Object.entries(recipes).map(([id, recipe]) => ({
|
|
2183
|
+
id,
|
|
2184
|
+
name: recipe.name,
|
|
2185
|
+
description: recipe.description,
|
|
2186
|
+
category: recipe.category,
|
|
2187
|
+
difficulty: recipe.difficulty,
|
|
2188
|
+
packages: recipe.packages,
|
|
2189
|
+
}));
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Search recipes by query
|
|
2193
|
+
*/
|
|
2194
|
+
export function searchRecipes(query) {
|
|
2195
|
+
const lowerQuery = query.toLowerCase();
|
|
2196
|
+
return Object.values(recipes).filter((recipe) => recipe.name.toLowerCase().includes(lowerQuery) ||
|
|
2197
|
+
recipe.description.toLowerCase().includes(lowerQuery) ||
|
|
2198
|
+
recipe.category.toLowerCase().includes(lowerQuery) ||
|
|
2199
|
+
recipe.packages.some((p) => p.toLowerCase().includes(lowerQuery)));
|
|
2200
|
+
}
|
|
2201
|
+
//# sourceMappingURL=recipes.js.map
|