create-fluxstack 1.20.0 → 1.21.0
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/LLMD/resources/live-components.md +103 -57
- package/LLMD/resources/live-rooms.md +187 -88
- package/README.md +27 -25
- package/app/client/.live-stubs/LiveCounter.js +4 -4
- package/app/client/src/App.tsx +11 -12
- package/app/client/src/components/AppLayout.tsx +290 -252
- package/app/client/src/components/BackButton.tsx +16 -13
- package/app/client/src/components/DemoPage.tsx +135 -22
- package/app/client/src/index.css +21 -11
- package/app/client/src/live/AuthDemo.tsx +270 -333
- package/app/client/src/live/CounterDemo.tsx +151 -206
- package/app/client/src/live/FormDemo.tsx +140 -119
- package/app/client/src/live/PingPongDemo.tsx +180 -202
- package/app/client/src/live/RoomChatDemo.tsx +397 -374
- package/app/client/src/pages/HomePage.tsx +170 -104
- package/app/server/live/LiveCounter.ts +71 -68
- package/app/server/live/LiveSharedCounter.ts +18 -12
- package/app/server/live/auto-generated-components.ts +1 -3
- package/app/server/live/rooms/CounterRoom.ts +15 -10
- package/core/client/index.ts +0 -3
- package/core/client/state/createStore.ts +88 -88
- package/core/client/state/index.ts +5 -5
- package/core/server/live/auto-generated-components.ts +1 -3
- package/core/utils/version.ts +1 -1
- package/package.json +1 -1
- package/tsconfig.json +7 -6
- package/app/client/src/components/LiveUploadWidget.tsx +0 -200
- package/app/client/src/live/UploadDemo.tsx +0 -21
- package/app/server/live/LiveUpload.ts +0 -96
- package/core/client/hooks/useLiveUpload.ts +0 -70
|
@@ -1,206 +1,151 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
<div className="flex
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
{localCounter.$state.count}
|
|
153
|
-
</div>
|
|
154
|
-
</div>
|
|
155
|
-
|
|
156
|
-
<div className="flex gap-4 justify-center">
|
|
157
|
-
<button
|
|
158
|
-
onClick={handleDecrement}
|
|
159
|
-
disabled={localCounter.$loading}
|
|
160
|
-
className="w-14 h-14 flex items-center justify-center text-3xl btn-complement disabled:opacity-50"
|
|
161
|
-
>
|
|
162
|
-
−
|
|
163
|
-
</button>
|
|
164
|
-
|
|
165
|
-
<button
|
|
166
|
-
onClick={handleReset}
|
|
167
|
-
disabled={localCounter.$loading}
|
|
168
|
-
className="px-6 h-14 flex items-center justify-center text-sm btn-theme-outline disabled:opacity-50"
|
|
169
|
-
>
|
|
170
|
-
Reset
|
|
171
|
-
</button>
|
|
172
|
-
|
|
173
|
-
<button
|
|
174
|
-
onClick={handleIncrement}
|
|
175
|
-
disabled={localCounter.$loading}
|
|
176
|
-
className="w-14 h-14 flex items-center justify-center text-3xl btn-accent disabled:opacity-50"
|
|
177
|
-
>
|
|
178
|
-
+
|
|
179
|
-
</button>
|
|
180
|
-
</div>
|
|
181
|
-
|
|
182
|
-
{localCounter.$loading && (
|
|
183
|
-
<div className="flex justify-center mt-4">
|
|
184
|
-
<div className="w-5 h-5 border-2 border-theme border-t-transparent rounded-full animate-spin" />
|
|
185
|
-
</div>
|
|
186
|
-
)}
|
|
187
|
-
</div>
|
|
188
|
-
)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return (
|
|
192
|
-
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 items-stretch justify-center">
|
|
193
|
-
{renderLocalCounter()}
|
|
194
|
-
{renderCounter(
|
|
195
|
-
'Contador Isolado',
|
|
196
|
-
'Cada aba tem seu próprio valor (room único).',
|
|
197
|
-
isolatedCounter
|
|
198
|
-
)}
|
|
199
|
-
{renderCounter(
|
|
200
|
-
'Contador Compartilhado',
|
|
201
|
-
'Abra em várias abas - todos veem o mesmo valor!',
|
|
202
|
-
sharedCounter
|
|
203
|
-
)}
|
|
204
|
-
</div>
|
|
205
|
-
)
|
|
206
|
-
}
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { Live } from '@/core/client'
|
|
3
|
+
import { LiveCounter } from '@server/live/LiveCounter'
|
|
4
|
+
import { LiveLocalCounter } from '@server/live/LiveLocalCounter'
|
|
5
|
+
import { FaMinus, FaPlus, FaRotateRight, FaUsers } from 'react-icons/fa6'
|
|
6
|
+
|
|
7
|
+
type CounterProxy = ReturnType<typeof Live.use>
|
|
8
|
+
|
|
9
|
+
function ConnectionPill({ connected }: { connected: boolean }) {
|
|
10
|
+
return (
|
|
11
|
+
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${
|
|
12
|
+
connected
|
|
13
|
+
? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'
|
|
14
|
+
: 'border-red-400/25 bg-red-400/10 text-red-200'
|
|
15
|
+
}`}>
|
|
16
|
+
<span className={`h-1.5 w-1.5 rounded-full ${connected ? 'bg-emerald-300' : 'bg-red-300'}`} />
|
|
17
|
+
{connected ? 'Connected' : 'Offline'}
|
|
18
|
+
</span>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function CounterCard({
|
|
23
|
+
title,
|
|
24
|
+
description,
|
|
25
|
+
mode,
|
|
26
|
+
counter,
|
|
27
|
+
accent = 'theme',
|
|
28
|
+
}: {
|
|
29
|
+
title: string
|
|
30
|
+
description: string
|
|
31
|
+
mode: string
|
|
32
|
+
counter: CounterProxy
|
|
33
|
+
accent?: 'theme' | 'warm'
|
|
34
|
+
}) {
|
|
35
|
+
const valueClass = accent === 'warm'
|
|
36
|
+
? 'bg-gradient-to-r from-amber-300 via-orange-300 to-rose-300'
|
|
37
|
+
: 'bg-theme-gradient'
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<article className="flex min-h-[430px] w-full flex-col rounded-lg border border-white/10 bg-[#07070b]/85 p-5 shadow-2xl shadow-black/20">
|
|
41
|
+
<div className="flex items-start justify-between gap-4">
|
|
42
|
+
<div>
|
|
43
|
+
<span className="rounded-full border border-white/10 bg-white/[0.03] px-2.5 py-1 text-xs text-gray-400">
|
|
44
|
+
{mode}
|
|
45
|
+
</span>
|
|
46
|
+
<h2 className="mt-4 text-xl font-semibold tracking-tight text-white">{title}</h2>
|
|
47
|
+
<p className="mt-2 text-sm leading-6 text-gray-500">{description}</p>
|
|
48
|
+
</div>
|
|
49
|
+
{'$connected' in counter && <ConnectionPill connected={counter.$connected} />}
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div className="flex flex-1 items-center justify-center py-8">
|
|
53
|
+
<div className={`bg-clip-text text-7xl font-semibold tabular-nums tracking-tight text-transparent sm:text-8xl ${valueClass}`}>
|
|
54
|
+
{counter.$state.count}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{'connectedUsers' in counter.$state && (
|
|
59
|
+
<div className="mb-5 flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.025] px-3 py-2 text-xs text-gray-400">
|
|
60
|
+
<span className="inline-flex items-center gap-2">
|
|
61
|
+
<FaUsers className="text-theme" />
|
|
62
|
+
Users in room
|
|
63
|
+
</span>
|
|
64
|
+
<span className="font-mono text-white">{counter.$state.connectedUsers}</span>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{counter.$state.lastUpdatedBy && (
|
|
69
|
+
<p className="mb-5 truncate text-xs text-gray-500">
|
|
70
|
+
Last update: <span className="text-gray-300">{counter.$state.lastUpdatedBy}</span>
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
<div className="grid grid-cols-[56px_1fr_56px] gap-3">
|
|
75
|
+
<button
|
|
76
|
+
onClick={() => counter.decrement()}
|
|
77
|
+
disabled={counter.$loading}
|
|
78
|
+
className="flex h-12 items-center justify-center rounded-lg border border-white/10 bg-white/[0.03] text-gray-200 transition hover:bg-white/[0.07] disabled:opacity-50"
|
|
79
|
+
aria-label={`Decrease ${title}`}
|
|
80
|
+
>
|
|
81
|
+
<FaMinus />
|
|
82
|
+
</button>
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => counter.reset()}
|
|
85
|
+
disabled={counter.$loading}
|
|
86
|
+
className="inline-flex h-12 items-center justify-center gap-2 rounded-lg border border-theme-active bg-theme-muted px-4 text-sm font-semibold text-theme transition hover:shadow-theme disabled:opacity-50"
|
|
87
|
+
>
|
|
88
|
+
<FaRotateRight className="h-3.5 w-3.5" />
|
|
89
|
+
Reset
|
|
90
|
+
</button>
|
|
91
|
+
<button
|
|
92
|
+
onClick={() => counter.increment()}
|
|
93
|
+
disabled={counter.$loading}
|
|
94
|
+
className="flex h-12 items-center justify-center rounded-lg bg-white text-black transition hover:bg-gray-200 disabled:opacity-50"
|
|
95
|
+
aria-label={`Increase ${title}`}
|
|
96
|
+
>
|
|
97
|
+
<FaPlus />
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</article>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function CounterDemo() {
|
|
105
|
+
const isolatedRoom = useMemo(() => {
|
|
106
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
107
|
+
return `local-${crypto.randomUUID()}`
|
|
108
|
+
}
|
|
109
|
+
return `local-${Math.random().toString(36).slice(2)}`
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
112
|
+
const localCounter = Live.use(LiveLocalCounter, {
|
|
113
|
+
initialState: LiveLocalCounter.defaultState,
|
|
114
|
+
persistState: false,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const isolatedCounter = Live.use(LiveCounter, {
|
|
118
|
+
room: isolatedRoom,
|
|
119
|
+
initialState: LiveCounter.defaultState,
|
|
120
|
+
persistState: false,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const sharedCounter = Live.use(LiveCounter, {
|
|
124
|
+
room: 'global-counter',
|
|
125
|
+
initialState: LiveCounter.defaultState,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="grid w-full gap-4 lg:grid-cols-3">
|
|
130
|
+
<CounterCard
|
|
131
|
+
title="Local state"
|
|
132
|
+
description="A component-local counter for quick state updates without a shared room."
|
|
133
|
+
mode="No room"
|
|
134
|
+
counter={localCounter}
|
|
135
|
+
accent="warm"
|
|
136
|
+
/>
|
|
137
|
+
<CounterCard
|
|
138
|
+
title="Isolated room"
|
|
139
|
+
description="A private room per tab. The server owns the state, but the session is isolated."
|
|
140
|
+
mode="Private room"
|
|
141
|
+
counter={isolatedCounter}
|
|
142
|
+
/>
|
|
143
|
+
<CounterCard
|
|
144
|
+
title="Shared room"
|
|
145
|
+
description="One global room. Open another tab and every client sees the same counter."
|
|
146
|
+
mode="Global room"
|
|
147
|
+
counter={sharedCounter}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -1,119 +1,140 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
</
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
</label>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
{
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
1
|
+
import { Live } from '@/core/client'
|
|
2
|
+
import { LiveForm } from '@server/live/LiveForm'
|
|
3
|
+
import { FaCheck, FaCode, FaEnvelope, FaRegMessage, FaUser } from 'react-icons/fa6'
|
|
4
|
+
|
|
5
|
+
function FieldHint({ children }: { children: string }) {
|
|
6
|
+
return (
|
|
7
|
+
<span className="rounded-full border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-500">
|
|
8
|
+
{children}
|
|
9
|
+
</span>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function FormDemo() {
|
|
14
|
+
const form = Live.use(LiveForm)
|
|
15
|
+
|
|
16
|
+
if (form.submitted) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="w-full max-w-2xl rounded-lg border border-emerald-400/20 bg-emerald-400/10 p-6 text-center shadow-2xl shadow-black/20">
|
|
19
|
+
<div className="mx-auto mb-5 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-400 text-black">
|
|
20
|
+
<FaCheck />
|
|
21
|
+
</div>
|
|
22
|
+
<h2 className="text-2xl font-semibold text-white">Message received</h2>
|
|
23
|
+
<p className="mt-2 text-sm leading-6 text-gray-300">
|
|
24
|
+
Thanks, <span className="text-emerald-200">{form.name || 'there'}</span>. The server state was updated and submitted.
|
|
25
|
+
</p>
|
|
26
|
+
<p className="mt-3 text-xs text-gray-500">
|
|
27
|
+
Submitted at {form.submittedAt ? new Date(form.submittedAt).toLocaleString() : '-'}
|
|
28
|
+
</p>
|
|
29
|
+
<button onClick={() => form.reset()} className="mt-6 h-11 rounded-lg bg-white px-5 text-sm font-semibold text-black transition hover:bg-gray-200">
|
|
30
|
+
Start another form
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="grid w-full max-w-5xl gap-4 lg:grid-cols-[1fr_360px]">
|
|
38
|
+
<section className="rounded-lg border border-white/10 bg-[#07070b]/85 p-5 shadow-2xl shadow-black/20 sm:p-6">
|
|
39
|
+
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
40
|
+
<div>
|
|
41
|
+
<h2 className="text-2xl font-semibold tracking-tight text-white">Contact workflow</h2>
|
|
42
|
+
<p className="mt-2 text-sm leading-6 text-gray-500">
|
|
43
|
+
Fields sync with the server using different strategies so the UI stays responsive.
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
<span className={`inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs ${
|
|
47
|
+
form.$connected
|
|
48
|
+
? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'
|
|
49
|
+
: 'border-red-400/25 bg-red-400/10 text-red-200'
|
|
50
|
+
}`}>
|
|
51
|
+
<span className={`h-1.5 w-1.5 rounded-full ${form.$connected ? 'bg-emerald-300' : 'bg-red-300'}`} />
|
|
52
|
+
{form.$connected ? 'Connected' : 'Offline'}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div className="space-y-4">
|
|
57
|
+
<label className="block">
|
|
58
|
+
<div className="mb-2 flex items-center justify-between gap-3">
|
|
59
|
+
<span className="inline-flex items-center gap-2 text-sm font-medium text-gray-200">
|
|
60
|
+
<FaUser className="text-theme" />
|
|
61
|
+
Name
|
|
62
|
+
</span>
|
|
63
|
+
<FieldHint>sync on blur</FieldHint>
|
|
64
|
+
</div>
|
|
65
|
+
<input
|
|
66
|
+
{...form.$field('name', { syncOn: 'blur' })}
|
|
67
|
+
placeholder="Ada Lovelace"
|
|
68
|
+
className="w-full input-theme"
|
|
69
|
+
/>
|
|
70
|
+
</label>
|
|
71
|
+
|
|
72
|
+
<label className="block">
|
|
73
|
+
<div className="mb-2 flex items-center justify-between gap-3">
|
|
74
|
+
<span className="inline-flex items-center gap-2 text-sm font-medium text-gray-200">
|
|
75
|
+
<FaEnvelope className="text-theme-secondary" />
|
|
76
|
+
Email
|
|
77
|
+
</span>
|
|
78
|
+
<FieldHint>debounce 500ms</FieldHint>
|
|
79
|
+
</div>
|
|
80
|
+
<input
|
|
81
|
+
{...form.$field('email', { syncOn: 'change', debounce: 500 })}
|
|
82
|
+
type="email"
|
|
83
|
+
placeholder="ada@company.dev"
|
|
84
|
+
className="w-full input-theme"
|
|
85
|
+
/>
|
|
86
|
+
</label>
|
|
87
|
+
|
|
88
|
+
<label className="block">
|
|
89
|
+
<div className="mb-2 flex items-center justify-between gap-3">
|
|
90
|
+
<span className="inline-flex items-center gap-2 text-sm font-medium text-gray-200">
|
|
91
|
+
<FaRegMessage className="text-theme" />
|
|
92
|
+
Message
|
|
93
|
+
</span>
|
|
94
|
+
<FieldHint>sync on blur</FieldHint>
|
|
95
|
+
</div>
|
|
96
|
+
<textarea
|
|
97
|
+
{...form.$field('message', { syncOn: 'blur' })}
|
|
98
|
+
rows={5}
|
|
99
|
+
placeholder="Tell us what you want to build..."
|
|
100
|
+
className="w-full resize-none input-theme"
|
|
101
|
+
/>
|
|
102
|
+
</label>
|
|
103
|
+
|
|
104
|
+
<div className="flex flex-col gap-3 pt-2 sm:flex-row">
|
|
105
|
+
<button
|
|
106
|
+
onClick={async () => {
|
|
107
|
+
try {
|
|
108
|
+
await form.$sync()
|
|
109
|
+
await form.submit()
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
alert(err.message || 'Submit failed')
|
|
112
|
+
}
|
|
113
|
+
}}
|
|
114
|
+
disabled={!form.$connected || form.$loading}
|
|
115
|
+
className="h-11 flex-1 rounded-lg bg-white px-5 text-sm font-semibold text-black transition hover:bg-gray-200 disabled:opacity-50"
|
|
116
|
+
>
|
|
117
|
+
{form.$loading ? 'Sending...' : 'Submit'}
|
|
118
|
+
</button>
|
|
119
|
+
<button
|
|
120
|
+
onClick={() => form.reset()}
|
|
121
|
+
className="h-11 rounded-lg border border-white/10 bg-white/[0.03] px-5 text-sm font-semibold text-white transition hover:bg-white/[0.06]"
|
|
122
|
+
>
|
|
123
|
+
Clear
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
|
|
129
|
+
<aside className="rounded-lg border border-white/10 bg-black/30 p-5">
|
|
130
|
+
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
|
|
131
|
+
<FaCode className="text-theme" />
|
|
132
|
+
Server state
|
|
133
|
+
</div>
|
|
134
|
+
<pre className="max-h-[460px] overflow-auto rounded-lg border border-white/10 bg-black/50 p-4 text-xs leading-6 text-emerald-300">
|
|
135
|
+
<code>{JSON.stringify(form.$state, null, 2)}</code>
|
|
136
|
+
</pre>
|
|
137
|
+
</aside>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|