@kohryan/moodui 0.0.15 → 0.0.16

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/src/ui/main.tsx CHANGED
@@ -1,24 +1,64 @@
1
1
  import * as React from "react";
2
2
  import { createRoot } from "react-dom/client";
3
- import { MoodUIPromptPlayground, generateReactFromPrompt, MoodUIRuntime } from "../index";
3
+ import { generateReactFromPrompt, MoodUIIcon, MOODUI_ICON_NAMES, MoodUIRuntime } from "../index";
4
+ import type { MoodUISpec } from "../spec";
5
+
6
+ type Provider = "gemini" | "ollama" | "openai-compatible";
7
+ type SaveStatus = "idle" | "saving" | "success" | "error";
8
+
9
+ const promptPresets = [
10
+ {
11
+ title: "SaaS Dashboard",
12
+ description: "Stat cards, chart summary, navigation, CTA",
13
+ prompt:
14
+ "Buat dashboard SaaS modern bergaya clean seperti Untitled UI: header, 3 stat card, section aktivitas terbaru, chart summary, tombol primary, dan gunakan icon chart-bar, bell, dan arrow-right."
15
+ },
16
+ {
17
+ title: "CRM Workspace",
18
+ description: "Sidebar, table preview, status cards",
19
+ prompt:
20
+ "Buat UI CRM workspace modern dengan sidebar, top bar pencarian, card pipeline singkat, daftar customer, dan gunakan icon search, user, mail, plus, dan settings."
21
+ },
22
+ {
23
+ title: "E-commerce Landing",
24
+ description: "Hero, feature grid, trust badges",
25
+ prompt:
26
+ "Buat landing page e-commerce modern dengan hero section, badge promo, 3 fitur utama, CTA beli sekarang, dan gunakan icon sparkles, shield, heart, dan arrow-right."
27
+ },
28
+ {
29
+ title: "Productivity App",
30
+ description: "Tasks, reminders, quick actions",
31
+ prompt:
32
+ "Buat dashboard productivity app dengan welcome card, daftar task hari ini, reminder, action button, dan gunakan icon check, calendar, bell, dan message-circle."
33
+ }
34
+ ] as const;
35
+
36
+ const modernPromptHint =
37
+ "Gunakan layout modern bergaya Flowbite/Untitled UI: card yang rapi, hierarchy kuat, spacing konsisten, warna netral, dan icon reusable seperlunya.";
4
38
 
5
39
  function App() {
6
- const [provider, setProvider] = React.useState<"gemini" | "ollama" | "openai-compatible">("gemini");
40
+ const [provider, setProvider] = React.useState<Provider>("gemini");
7
41
  const [model, setModel] = React.useState("gemini-3-flash-preview");
8
42
  const [apiKey, setApiKey] = React.useState("");
9
43
  const [baseUrl, setBaseUrl] = React.useState("");
10
44
  const [prompt, setPrompt] = React.useState(
11
- "Buat UI mood tracker: judul, input mood, tombol Simpan (actionId save_mood), dan section riwayat."
45
+ "Buat dashboard mood tracker modern: top summary, form input mood, riwayat mood, CTA utama, dan gunakan icon heart, calendar, dan chart-bar."
12
46
  );
13
47
  const [componentName, setComponentName] = React.useState("MoodScreen");
14
48
  const [outputPath, setOutputPath] = React.useState("src/generated/MoodScreen.tsx");
15
-
16
49
  const [loading, setLoading] = React.useState(false);
17
50
  const [error, setError] = React.useState<string | null>(null);
18
- const [spec, setSpec] = React.useState<any>(null);
51
+ const [spec, setSpec] = React.useState<MoodUISpec | null>(null);
19
52
  const [code, setCode] = React.useState("");
20
53
  const [copied, setCopied] = React.useState(false);
21
- const [saveStatus, setSaveStatus] = React.useState<"idle" | "saving" | "success" | "error">("idle");
54
+ const [saveStatus, setSaveStatus] = React.useState<SaveStatus>("idle");
55
+ const suggestedPathRef = React.useRef(outputPath);
56
+
57
+ React.useEffect(() => {
58
+ const nextSuggestedPath = `src/generated/${componentName || "MoodScreen"}.tsx`;
59
+ setOutputPath((current) => (current === suggestedPathRef.current ? nextSuggestedPath : current));
60
+ suggestedPathRef.current = nextSuggestedPath;
61
+ }, [componentName]);
22
62
 
23
63
  const onGenerate = React.useCallback(async () => {
24
64
  setLoading(true);
@@ -26,32 +66,49 @@ function App() {
26
66
  setCopied(false);
27
67
  setSaveStatus("idle");
28
68
  try {
29
- const result = await generateReactFromPrompt({
30
- provider,
31
- model,
32
- apiKey: apiKey || undefined,
33
- baseUrl: baseUrl || undefined,
34
- prompt,
35
- componentName
36
- });
69
+ const result =
70
+ provider === "ollama"
71
+ ? await generateReactFromPrompt({
72
+ provider,
73
+ model,
74
+ baseUrl: baseUrl || undefined,
75
+ prompt,
76
+ componentName
77
+ })
78
+ : provider === "openai-compatible"
79
+ ? await generateReactFromPrompt({
80
+ provider,
81
+ model,
82
+ apiKey,
83
+ baseUrl,
84
+ prompt,
85
+ componentName
86
+ })
87
+ : await generateReactFromPrompt({
88
+ provider,
89
+ model,
90
+ apiKey,
91
+ baseUrl: baseUrl || undefined,
92
+ prompt,
93
+ componentName
94
+ });
37
95
 
38
96
  setSpec(result.spec);
39
97
  setCode(result.code);
40
- setOutputPath(`src/generated/${componentName}.tsx`);
41
98
  } catch (e) {
42
99
  const msg = e instanceof Error ? e.message : String(e);
43
100
  setError(msg);
44
101
  } finally {
45
102
  setLoading(false);
46
103
  }
47
- }, [apiKey, baseUrl, model, prompt, componentName, provider]);
104
+ }, [apiKey, baseUrl, componentName, model, prompt, provider]);
48
105
 
49
106
  const onCopyCode = React.useCallback(async () => {
50
107
  if (!code) return;
51
108
  try {
52
109
  await navigator.clipboard.writeText(code);
53
110
  setCopied(true);
54
- setTimeout(() => setCopied(false), 2000);
111
+ window.setTimeout(() => setCopied(false), 1800);
55
112
  } catch (e) {
56
113
  console.error("Failed to copy:", e);
57
114
  }
@@ -61,249 +118,420 @@ function App() {
61
118
  if (!code) return;
62
119
  const blob = new Blob([code], { type: "text/plain" });
63
120
  const url = URL.createObjectURL(blob);
64
- const a = document.createElement("a");
65
- a.href = url;
66
- a.download = `${componentName}.tsx`;
67
- document.body.appendChild(a);
68
- a.click();
69
- document.body.removeChild(a);
121
+ const anchor = document.createElement("a");
122
+ anchor.href = url;
123
+ anchor.download = `${componentName || "MoodScreen"}.tsx`;
124
+ document.body.appendChild(anchor);
125
+ anchor.click();
126
+ document.body.removeChild(anchor);
70
127
  URL.revokeObjectURL(url);
71
128
  }, [code, componentName]);
72
129
 
73
130
  const onSaveToProject = React.useCallback(async () => {
74
131
  if (!code) return;
75
132
  setSaveStatus("saving");
133
+ setError(null);
76
134
  try {
77
135
  const response = await fetch("/api/save-file", {
78
136
  method: "POST",
79
137
  headers: { "Content-Type": "application/json" },
80
138
  body: JSON.stringify({ path: outputPath, code })
81
139
  });
82
-
83
- const result = await response.json();
84
- if (result.success) {
85
- setSaveStatus("success");
86
- setTimeout(() => setSaveStatus("idle"), 3000);
87
- } else {
88
- throw new Error(result.error);
89
- }
140
+ const result = (await response.json()) as { success: boolean; error?: string };
141
+ if (!result.success) throw new Error(result.error ?? "Gagal menyimpan file");
142
+ setSaveStatus("success");
143
+ window.setTimeout(() => setSaveStatus("idle"), 2200);
90
144
  } catch (e) {
91
145
  const msg = e instanceof Error ? e.message : String(e);
92
- console.error("Failed to save to project:", e);
146
+ setError(msg);
93
147
  setSaveStatus("error");
94
- setTimeout(() => setSaveStatus("idle"), 3000);
148
+ window.setTimeout(() => setSaveStatus("idle"), 2600);
95
149
  }
96
150
  }, [code, outputPath]);
97
151
 
152
+ const applyPreset = React.useCallback((value: string) => {
153
+ setPrompt(value);
154
+ }, []);
155
+
156
+ const appendModernHint = React.useCallback(() => {
157
+ setPrompt((current) => `${current.trim()}\n\n${modernPromptHint}`);
158
+ }, []);
159
+
160
+ const appendIconHint = React.useCallback(() => {
161
+ setPrompt(
162
+ (current) =>
163
+ `${current.trim()}\n\nGunakan icon reusable dari MoodUI seperti search, chart-bar, bell, user, calendar, home, plus, shield, sparkles, dan arrow-right jika relevan.`
164
+ );
165
+ }, []);
166
+
167
+ const codeLineCount = React.useMemo(() => (code ? code.split("\n").length : 0), [code]);
168
+ const saveButtonLabel =
169
+ saveStatus === "saving"
170
+ ? "Menyimpan..."
171
+ : saveStatus === "success"
172
+ ? "Tersimpan"
173
+ : saveStatus === "error"
174
+ ? "Gagal"
175
+ : "Save to Project";
176
+
98
177
  return (
99
- <div style={{ minHeight: "100vh", padding: 24, background: "#0b1220" }}>
100
- <div style={{ maxWidth: 1100, margin: "0 auto", background: "#fff", padding: 16, borderRadius: 16 }}>
101
- <div style={{ marginBottom: 16, paddingBottom: 16, borderBottom: "1px solid #e5e7eb" }}>
102
- <h2 style={{ margin: "0 0 12px 0", fontSize: 20, fontWeight: 700 }}>MoodUI - AI UI Generator</h2>
103
- <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
104
- <div>
105
- <label style={{ display: "block", marginBottom: 4, fontSize: 12, fontWeight: 600 }}>Component Name</label>
106
- <input
107
- value={componentName}
108
- onChange={(e) => setComponentName(e.target.value)}
109
- style={{ width: "100%", padding: "10px 12px", borderRadius: 10, border: "1px solid #d1d5db" }}
110
- />
178
+ <div
179
+ style={{
180
+ minHeight: "100vh",
181
+ background:
182
+ "radial-gradient(circle at top left, rgba(124,58,237,0.22), transparent 28%), radial-gradient(circle at top right, rgba(14,165,233,0.18), transparent 32%), linear-gradient(180deg, #0f172a 0%, #111827 100%)",
183
+ color: "#0f172a",
184
+ padding: 28
185
+ }}
186
+ >
187
+ <div style={{ maxWidth: 1440, margin: "0 auto", display: "flex", flexDirection: "column", gap: 20 }}>
188
+ <section
189
+ style={{
190
+ borderRadius: 28,
191
+ padding: 28,
192
+ background: "rgba(255,255,255,0.94)",
193
+ border: "1px solid rgba(255,255,255,0.65)",
194
+ boxShadow: "0 24px 80px rgba(15,23,42,0.26)",
195
+ backdropFilter: "blur(14px)"
196
+ }}
197
+ >
198
+ <div
199
+ style={{
200
+ display: "grid",
201
+ gridTemplateColumns: "minmax(0, 1.5fr) minmax(320px, 0.9fr)",
202
+ gap: 20,
203
+ alignItems: "stretch"
204
+ }}
205
+ >
206
+ <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
207
+ <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
208
+ <Badge icon="sparkles" text="MoodUI Browser CLI" tone="purple" />
209
+ <Badge icon="check" text="Generate to Repo" tone="green" />
210
+ <Badge icon="shield" text="Reusable Icon Registry" tone="blue" />
211
+ </div>
212
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
213
+ <h1 style={{ margin: 0, fontSize: 34, lineHeight: 1.05, fontWeight: 800 }}>
214
+ Generate UI modern, simpan langsung ke repo, dan pakai icon reusable bawaan MoodUI.
215
+ </h1>
216
+ <p style={{ margin: 0, color: "#475467", fontSize: 15, lineHeight: 1.7, maxWidth: 760 }}>
217
+ Tampilan ini saya rapikan ke arah product tool yang lebih dekat ke rasa Flowbite atau Untitled UI:
218
+ panel jelas, quick templates, output path eksplisit, preview langsung, dan daftar icon yang bisa
219
+ diulang pemakaiannya di banyak screen.
220
+ </p>
221
+ </div>
222
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12 }}>
223
+ <StatCard icon="plus" label="Quick Templates" value={String(promptPresets.length)} />
224
+ <StatCard icon="sparkles" label="Reusable Icons" value={String(MOODUI_ICON_NAMES.length)} />
225
+ <StatCard icon="check" label="Generated Lines" value={String(codeLineCount)} />
226
+ </div>
111
227
  </div>
112
- <div>
113
- <label style={{ display: "block", marginBottom: 4, fontSize: 12, fontWeight: 600 }}>Output Path</label>
114
- <input
115
- value={outputPath}
116
- onChange={(e) => setOutputPath(e.target.value)}
117
- style={{ width: "100%", padding: "10px 12px", borderRadius: 10, border: "1px solid #d1d5db" }}
118
- />
228
+
229
+ <div
230
+ style={{
231
+ borderRadius: 24,
232
+ padding: 18,
233
+ background: "linear-gradient(180deg, #111827 0%, #0f172a 100%)",
234
+ color: "#ffffff",
235
+ display: "flex",
236
+ flexDirection: "column",
237
+ gap: 14,
238
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)"
239
+ }}
240
+ >
241
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
242
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
243
+ <div
244
+ style={{
245
+ width: 40,
246
+ height: 40,
247
+ borderRadius: 12,
248
+ display: "grid",
249
+ placeItems: "center",
250
+ background: "linear-gradient(135deg, #7c3aed 0%, #2563eb 100%)"
251
+ }}
252
+ >
253
+ <MoodUIIcon name="sparkles" color="#ffffff" size={20} />
254
+ </div>
255
+ <div>
256
+ <div style={{ fontSize: 14, fontWeight: 700 }}>Recommended Workflow</div>
257
+ <div style={{ fontSize: 12, color: "#cbd5e1" }}>Generate, review, lalu save ke repo webapp</div>
258
+ </div>
259
+ </div>
260
+ </div>
261
+ <ChecklistItem icon="search" text="Tulis prompt UI yang spesifik dan modern" />
262
+ <ChecklistItem icon="chart-bar" text="Gunakan preset kalau butuh struktur awal cepat" />
263
+ <ChecklistItem icon="calendar" text="Sesuaikan component name dan output path" />
264
+ <ChecklistItem icon="check" text="Preview hasil sebelum simpan ke project" />
119
265
  </div>
120
266
  </div>
121
- </div>
122
-
123
- <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
124
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
125
- <div style={{ fontWeight: 700, fontSize: 16 }}>MoodUI Prompt</div>
126
-
127
- <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
128
- <select
129
- value={provider}
130
- onChange={(e) => setProvider(e.target.value as any)}
267
+ </section>
268
+
269
+ <div
270
+ style={{
271
+ display: "grid",
272
+ gridTemplateColumns: "minmax(380px, 0.95fr) minmax(0, 1.45fr)",
273
+ gap: 20,
274
+ alignItems: "start"
275
+ }}
276
+ >
277
+ <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
278
+ <Panel title="Generate UI" icon="sparkles" subtitle="Form utama untuk menghasilkan komponen React">
279
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
280
+ <Field label="Component Name">
281
+ <input
282
+ value={componentName}
283
+ onChange={(e) => setComponentName(e.target.value)}
284
+ placeholder="SalesDashboard"
285
+ style={inputStyle}
286
+ />
287
+ </Field>
288
+ <Field label="Output Path">
289
+ <input
290
+ value={outputPath}
291
+ onChange={(e) => setOutputPath(e.target.value)}
292
+ placeholder="src/generated/SalesDashboard.tsx"
293
+ style={inputStyle}
294
+ />
295
+ </Field>
296
+ </div>
297
+
298
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
299
+ <Field label="Provider">
300
+ <select value={provider} onChange={(e) => setProvider(e.target.value as Provider)} style={inputStyle}>
301
+ <option value="gemini">gemini</option>
302
+ <option value="ollama">ollama</option>
303
+ <option value="openai-compatible">openai-compatible</option>
304
+ </select>
305
+ </Field>
306
+ <Field label="Model">
307
+ <input
308
+ value={model}
309
+ onChange={(e) => setModel(e.target.value)}
310
+ placeholder="gemini-3-flash-preview"
311
+ style={inputStyle}
312
+ />
313
+ </Field>
314
+ </div>
315
+
316
+ {provider !== "ollama" ? (
317
+ <Field label="API Key">
318
+ <input
319
+ value={apiKey}
320
+ onChange={(e) => setApiKey(e.target.value)}
321
+ placeholder="Masukkan API key"
322
+ type="password"
323
+ style={inputStyle}
324
+ />
325
+ </Field>
326
+ ) : null}
327
+
328
+ <Field label="Base URL">
329
+ <input
330
+ value={baseUrl}
331
+ onChange={(e) => setBaseUrl(e.target.value)}
332
+ placeholder={provider === "ollama" ? "http://localhost:11434" : "Optional"}
333
+ style={inputStyle}
334
+ />
335
+ </Field>
336
+
337
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
338
+ <div style={{ fontSize: 13, fontWeight: 700, color: "#101828" }}>Prompt UI</div>
339
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
340
+ <MiniButton icon="sparkles" label="Modernize" onClick={appendModernHint} />
341
+ <MiniButton icon="shield" label="Add Icons" onClick={appendIconHint} />
342
+ </div>
343
+ </div>
344
+
345
+ <textarea
346
+ value={prompt}
347
+ onChange={(e) => setPrompt(e.target.value)}
348
+ rows={11}
131
349
  style={{
132
- padding: "10px 12px",
133
- borderRadius: 10,
134
- border: "1px solid #d1d5db",
135
- background: "#ffffff",
136
- width: 200
350
+ ...inputStyle,
351
+ minHeight: 240,
352
+ resize: "vertical",
353
+ lineHeight: 1.65,
354
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
137
355
  }}
138
- >
139
- <option value="gemini">gemini</option>
140
- <option value="ollama">ollama</option>
141
- <option value="openai-compatible">openai-compatible</option>
142
- </select>
143
- <input
144
- value={model}
145
- onChange={(e) => setModel(e.target.value)}
146
- placeholder="model"
147
- style={{ padding: "10px 12px", borderRadius: 10, border: "1px solid #d1d5db", flex: "1 1 220px" }}
148
356
  />
149
- </div>
150
-
151
- {provider !== "ollama" ? (
152
- <input
153
- value={apiKey}
154
- onChange={(e) => setApiKey(e.target.value)}
155
- placeholder="API key"
156
- type="password"
157
- style={{ padding: "10px 12px", borderRadius: 10, border: "1px solid #d1d5db" }}
158
- />
159
- ) : null}
160
-
161
- <input
162
- value={baseUrl}
163
- onChange={(e) => setBaseUrl(e.target.value)}
164
- placeholder={provider === "ollama" ? "baseUrl (optional) e.g. http://localhost:11434" : "baseUrl (optional)"}
165
- style={{ padding: "10px 12px", borderRadius: 10, border: "1px solid #d1d5db" }}
166
- />
167
-
168
- <textarea
169
- value={prompt}
170
- onChange={(e) => setPrompt(e.target.value)}
171
- rows={10}
172
- style={{
173
- padding: 12,
174
- borderRadius: 12,
175
- border: "1px solid #d1d5db",
176
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
177
- fontSize: 12
178
- }}
179
- />
180
357
 
181
- <button
182
- type="button"
183
- onClick={onGenerate}
184
- disabled={loading}
185
- style={{
186
- padding: "10px 12px",
187
- borderRadius: 10,
188
- border: "1px solid #111827",
189
- background: "#111827",
190
- color: "#ffffff",
191
- cursor: loading ? "not-allowed" : "pointer"
192
- }}
193
- >
194
- {loading ? "Generating..." : "Generate"}
195
- </button>
358
+ <button type="button" onClick={onGenerate} disabled={loading} style={primaryButtonStyle(loading)}>
359
+ <MoodUIIcon name={loading ? "settings" : "sparkles"} color="#ffffff" size={16} />
360
+ {loading ? "Generating..." : "Generate Component"}
361
+ </button>
196
362
 
197
- {error ? (
198
- <pre
363
+ <div
199
364
  style={{
200
- margin: 0,
201
- padding: 12,
202
- borderRadius: 12,
203
- border: "1px solid #7f1d1d",
204
- background: "#fef2f2",
205
- color: "#7f1d1d",
206
- whiteSpace: "pre-wrap"
365
+ display: "grid",
366
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
367
+ gap: 10
207
368
  }}
208
369
  >
209
- {error}
210
- </pre>
211
- ) : null}
212
- </div>
213
-
214
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
215
- <div style={{ fontWeight: 700, fontSize: 16 }}>Preview</div>
216
- <div style={{ minHeight: 240, padding: 16, borderRadius: 16, background: "#ffffff", border: "1px solid #e5e7eb" }}>
217
- {spec ? (
218
- <MoodUIRuntime spec={spec} />
219
- ) : (
220
- <div style={{ color: "#6b7280" }}>Belum ada hasil</div>
221
- )}
222
- </div>
370
+ {promptPresets.map((preset) => (
371
+ <button
372
+ key={preset.title}
373
+ type="button"
374
+ onClick={() => applyPreset(preset.prompt)}
375
+ style={presetCardStyle}
376
+ >
377
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
378
+ <div
379
+ style={{
380
+ width: 36,
381
+ height: 36,
382
+ borderRadius: 12,
383
+ display: "grid",
384
+ placeItems: "center",
385
+ background: "#eef2ff",
386
+ color: "#4338ca"
387
+ }}
388
+ >
389
+ <MoodUIIcon name="sparkles" size={18} />
390
+ </div>
391
+ <div style={{ textAlign: "left" }}>
392
+ <div style={{ fontWeight: 700, fontSize: 13 }}>{preset.title}</div>
393
+ <div style={{ fontSize: 12, color: "#667085" }}>{preset.description}</div>
394
+ </div>
395
+ </div>
396
+ </button>
397
+ ))}
398
+ </div>
223
399
 
224
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
225
- <div style={{ fontWeight: 700, fontSize: 16 }}>React Code</div>
226
- <div style={{ display: "flex", gap: 8 }}>
227
- <button
228
- type="button"
229
- onClick={onCopyCode}
230
- disabled={!code}
231
- style={{
232
- padding: "6px 12px",
233
- borderRadius: 8,
234
- border: copied ? "1px solid #16a34a" : "1px solid #6b7280",
235
- background: copied ? "#dcfce7" : "#ffffff",
236
- color: copied ? "#16a34a" : "#374151",
237
- cursor: !code ? "not-allowed" : "pointer",
238
- fontSize: 12
239
- }}
240
- >
241
- {copied ? "Copied!" : "Copy"}
242
- </button>
243
- <button
244
- type="button"
245
- onClick={onSaveToProject}
246
- disabled={!code || saveStatus === "saving"}
400
+ {error ? (
401
+ <div
247
402
  style={{
248
- padding: "6px 12px",
249
- borderRadius: 8,
250
- border: saveStatus === "success"
251
- ? "1px solid #16a34a"
252
- : saveStatus === "error"
253
- ? "1px solid #7f1d1d"
254
- : "1px solid #059669",
255
- background: saveStatus === "success"
256
- ? "#dcfce7"
257
- : saveStatus === "error"
258
- ? "#fef2f2"
259
- : "#059669",
260
- color: saveStatus === "success"
261
- ? "#16a34a"
262
- : saveStatus === "error"
263
- ? "#7f1d1d"
264
- : "#ffffff",
265
- cursor: !code || saveStatus === "saving" ? "not-allowed" : "pointer",
266
- fontSize: 12
403
+ borderRadius: 16,
404
+ padding: 14,
405
+ border: "1px solid #fda29b",
406
+ background: "#fef3f2",
407
+ color: "#b42318",
408
+ fontSize: 13,
409
+ lineHeight: 1.6,
410
+ whiteSpace: "pre-wrap"
267
411
  }}
268
412
  >
269
- {saveStatus === "saving"
270
- ? "Saving..."
271
- : saveStatus === "success"
272
- ? "Saved!"
273
- : saveStatus === "error"
274
- ? "Error!"
275
- : "Save to Project"}
276
- </button>
277
- <button
278
- type="button"
279
- onClick={onDownloadCode}
280
- disabled={!code}
281
- style={{
282
- padding: "6px 12px",
283
- borderRadius: 8,
284
- border: "1px solid #111827",
285
- background: "#111827",
286
- color: "#ffffff",
287
- cursor: !code ? "not-allowed" : "pointer",
288
- fontSize: 12
289
- }}
290
- >
291
- Download
292
- </button>
413
+ {error}
414
+ </div>
415
+ ) : null}
416
+ </Panel>
417
+
418
+ <Panel title="Icon Library" icon="shield" subtitle="Referensi icon reusable yang dikenali MoodUI">
419
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
420
+ {MOODUI_ICON_NAMES.map((iconName) => (
421
+ <button
422
+ key={iconName}
423
+ type="button"
424
+ onClick={() => setPrompt((current) => `${current.trim()}\n- gunakan icon ${iconName}`)}
425
+ style={iconChipStyle}
426
+ >
427
+ <MoodUIIcon name={iconName} size={16} color="#344054" />
428
+ <span>{iconName}</span>
429
+ </button>
430
+ ))}
293
431
  </div>
294
- </div>
295
- <textarea
296
- value={code}
297
- readOnly
298
- rows={12}
299
- style={{
300
- padding: 12,
301
- borderRadius: 12,
302
- border: "1px solid #d1d5db",
303
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
304
- fontSize: 12
305
- }}
306
- />
432
+ <div
433
+ style={{
434
+ borderRadius: 16,
435
+ padding: 14,
436
+ background: "#f8fafc",
437
+ border: "1px solid #e2e8f0",
438
+ fontSize: 13,
439
+ lineHeight: 1.7,
440
+ color: "#475467"
441
+ }}
442
+ >
443
+ Tips: cukup minta model memakai icon berdasarkan nama, misalnya{" "}
444
+ <code style={inlineCodeStyle}>search</code>, <code style={inlineCodeStyle}>calendar</code>,{" "}
445
+ <code style={inlineCodeStyle}>chart-bar</code>, atau <code style={inlineCodeStyle}>arrow-right</code>.
446
+ </div>
447
+ </Panel>
448
+ </div>
449
+
450
+ <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
451
+ <Panel title="Live Preview" icon="home" subtitle="Hasil render MoodUIRuntime dari spec terbaru">
452
+ <div
453
+ style={{
454
+ minHeight: 460,
455
+ borderRadius: 24,
456
+ padding: 18,
457
+ background: "linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)",
458
+ border: "1px solid #dbe4ff",
459
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.7)"
460
+ }}
461
+ >
462
+ {spec ? (
463
+ <div
464
+ style={{
465
+ minHeight: 420,
466
+ borderRadius: 20,
467
+ padding: 18,
468
+ background: "#ffffff",
469
+ boxShadow: "0 18px 48px rgba(15,23,42,0.08)",
470
+ overflow: "auto"
471
+ }}
472
+ >
473
+ <MoodUIRuntime spec={spec} />
474
+ </div>
475
+ ) : (
476
+ <EmptyState
477
+ icon="sparkles"
478
+ title="Belum ada preview"
479
+ description="Generate komponen dulu. Preview dan code akan muncul di sini."
480
+ />
481
+ )}
482
+ </div>
483
+ </Panel>
484
+
485
+ <Panel title="Generated Code" icon="arrow-right" subtitle="Copy, download, atau simpan langsung ke repo">
486
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
487
+ <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
488
+ <InfoPill icon="check" label={`${codeLineCount} lines`} />
489
+ <InfoPill icon="home" label={outputPath} />
490
+ </div>
491
+ <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
492
+ <button type="button" onClick={onCopyCode} disabled={!code} style={secondaryActionStyle(!code)}>
493
+ <MoodUIIcon name="plus" size={15} />
494
+ {copied ? "Copied" : "Copy"}
495
+ </button>
496
+ <button type="button" onClick={onDownloadCode} disabled={!code} style={secondaryActionStyle(!code)}>
497
+ <MoodUIIcon name="arrow-right" size={15} />
498
+ Download
499
+ </button>
500
+ <button
501
+ type="button"
502
+ onClick={onSaveToProject}
503
+ disabled={!code || saveStatus === "saving"}
504
+ style={saveActionStyle(saveStatus, !code)}
505
+ >
506
+ <MoodUIIcon
507
+ name={saveStatus === "success" ? "check" : saveStatus === "error" ? "shield" : "home"}
508
+ size={15}
509
+ color={saveStatus === "error" ? "#b42318" : undefined}
510
+ />
511
+ {saveButtonLabel}
512
+ </button>
513
+ </div>
514
+ </div>
515
+
516
+ <textarea
517
+ value={code}
518
+ readOnly
519
+ rows={18}
520
+ style={{
521
+ ...inputStyle,
522
+ minHeight: 360,
523
+ background: "#0f172a",
524
+ color: "#e2e8f0",
525
+ border: "1px solid #1e293b",
526
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
527
+ lineHeight: 1.55
528
+ }}
529
+ />
530
+ <div style={{ fontSize: 12, color: "#667085" }}>
531
+ Jalankan <code style={inlineCodeStyle}>npx moodui ui</code> dari root repo webapp agar tombol{" "}
532
+ <code style={inlineCodeStyle}>Save to Project</code> menulis file ke repo tersebut.
533
+ </div>
534
+ </Panel>
307
535
  </div>
308
536
  </div>
309
537
  </div>
@@ -311,4 +539,297 @@ function App() {
311
539
  );
312
540
  }
313
541
 
542
+ function Panel(props: { title: string; subtitle: string; icon: React.ComponentProps<typeof MoodUIIcon>["name"]; children: React.ReactNode }) {
543
+ return (
544
+ <section
545
+ style={{
546
+ borderRadius: 24,
547
+ padding: 20,
548
+ background: "rgba(255,255,255,0.96)",
549
+ border: "1px solid rgba(255,255,255,0.72)",
550
+ boxShadow: "0 16px 48px rgba(15,23,42,0.18)"
551
+ }}
552
+ >
553
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, marginBottom: 16 }}>
554
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
555
+ <div
556
+ style={{
557
+ width: 40,
558
+ height: 40,
559
+ borderRadius: 14,
560
+ display: "grid",
561
+ placeItems: "center",
562
+ background: "#eef2ff",
563
+ color: "#4338ca"
564
+ }}
565
+ >
566
+ <MoodUIIcon name={props.icon} size={18} />
567
+ </div>
568
+ <div>
569
+ <div style={{ fontSize: 16, fontWeight: 800, color: "#101828" }}>{props.title}</div>
570
+ <div style={{ fontSize: 13, color: "#667085" }}>{props.subtitle}</div>
571
+ </div>
572
+ </div>
573
+ </div>
574
+ <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>{props.children}</div>
575
+ </section>
576
+ );
577
+ }
578
+
579
+ function Field(props: { label: string; children: React.ReactNode }) {
580
+ return (
581
+ <label style={{ display: "flex", flexDirection: "column", gap: 7 }}>
582
+ <span style={{ fontSize: 12, fontWeight: 700, color: "#344054", letterSpacing: 0.2 }}>{props.label}</span>
583
+ {props.children}
584
+ </label>
585
+ );
586
+ }
587
+
588
+ function Badge(props: { icon: React.ComponentProps<typeof MoodUIIcon>["name"]; text: string; tone: "purple" | "green" | "blue" }) {
589
+ const tones = {
590
+ purple: { background: "#f5f3ff", color: "#6d28d9", border: "#ddd6fe" },
591
+ green: { background: "#ecfdf3", color: "#027a48", border: "#abefc6" },
592
+ blue: { background: "#eff8ff", color: "#175cd3", border: "#b2ddff" }
593
+ } as const;
594
+ const tone = tones[props.tone];
595
+
596
+ return (
597
+ <div
598
+ style={{
599
+ display: "inline-flex",
600
+ alignItems: "center",
601
+ gap: 8,
602
+ padding: "8px 12px",
603
+ borderRadius: 999,
604
+ background: tone.background,
605
+ border: `1px solid ${tone.border}`,
606
+ color: tone.color,
607
+ fontSize: 12,
608
+ fontWeight: 700
609
+ }}
610
+ >
611
+ <MoodUIIcon name={props.icon} size={14} />
612
+ {props.text}
613
+ </div>
614
+ );
615
+ }
616
+
617
+ function StatCard(props: { icon: React.ComponentProps<typeof MoodUIIcon>["name"]; label: string; value: string }) {
618
+ return (
619
+ <div
620
+ style={{
621
+ borderRadius: 18,
622
+ padding: 16,
623
+ background: "#f8fafc",
624
+ border: "1px solid #e2e8f0",
625
+ display: "flex",
626
+ flexDirection: "column",
627
+ gap: 10
628
+ }}
629
+ >
630
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
631
+ <MoodUIIcon name={props.icon} size={16} color="#475467" />
632
+ <span style={{ fontSize: 12, color: "#667085", fontWeight: 700 }}>{props.label}</span>
633
+ </div>
634
+ <div style={{ fontSize: 26, lineHeight: 1, fontWeight: 800, color: "#101828" }}>{props.value}</div>
635
+ </div>
636
+ );
637
+ }
638
+
639
+ function ChecklistItem(props: { icon: React.ComponentProps<typeof MoodUIIcon>["name"]; text: string }) {
640
+ return (
641
+ <div
642
+ style={{
643
+ display: "flex",
644
+ alignItems: "center",
645
+ gap: 10,
646
+ borderRadius: 14,
647
+ padding: "10px 12px",
648
+ background: "rgba(255,255,255,0.05)",
649
+ border: "1px solid rgba(255,255,255,0.08)"
650
+ }}
651
+ >
652
+ <MoodUIIcon name={props.icon} size={15} color="#c4b5fd" />
653
+ <span style={{ fontSize: 13, color: "#e2e8f0" }}>{props.text}</span>
654
+ </div>
655
+ );
656
+ }
657
+
658
+ function MiniButton(props: { icon: React.ComponentProps<typeof MoodUIIcon>["name"]; label: string; onClick: () => void }) {
659
+ return (
660
+ <button type="button" onClick={props.onClick} style={miniButtonStyle}>
661
+ <MoodUIIcon name={props.icon} size={14} />
662
+ {props.label}
663
+ </button>
664
+ );
665
+ }
666
+
667
+ function InfoPill(props: { icon: React.ComponentProps<typeof MoodUIIcon>["name"]; label: string }) {
668
+ return (
669
+ <div
670
+ style={{
671
+ display: "inline-flex",
672
+ alignItems: "center",
673
+ gap: 8,
674
+ padding: "8px 12px",
675
+ borderRadius: 999,
676
+ background: "#f8fafc",
677
+ border: "1px solid #e4e7ec",
678
+ fontSize: 12,
679
+ color: "#475467",
680
+ maxWidth: 320
681
+ }}
682
+ >
683
+ <MoodUIIcon name={props.icon} size={14} />
684
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{props.label}</span>
685
+ </div>
686
+ );
687
+ }
688
+
689
+ function EmptyState(props: { icon: React.ComponentProps<typeof MoodUIIcon>["name"]; title: string; description: string }) {
690
+ return (
691
+ <div
692
+ style={{
693
+ minHeight: 380,
694
+ display: "grid",
695
+ placeItems: "center",
696
+ textAlign: "center",
697
+ color: "#667085",
698
+ padding: 24
699
+ }}
700
+ >
701
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 14, maxWidth: 280 }}>
702
+ <div
703
+ style={{
704
+ width: 56,
705
+ height: 56,
706
+ borderRadius: 20,
707
+ display: "grid",
708
+ placeItems: "center",
709
+ background: "#eef2ff",
710
+ color: "#4338ca"
711
+ }}
712
+ >
713
+ <MoodUIIcon name={props.icon} size={24} />
714
+ </div>
715
+ <div style={{ fontSize: 16, fontWeight: 800, color: "#101828" }}>{props.title}</div>
716
+ <div style={{ fontSize: 13, lineHeight: 1.7 }}>{props.description}</div>
717
+ </div>
718
+ </div>
719
+ );
720
+ }
721
+
722
+ const inputStyle: React.CSSProperties = {
723
+ width: "100%",
724
+ padding: "12px 14px",
725
+ borderRadius: 14,
726
+ border: "1px solid #d0d5dd",
727
+ background: "#ffffff",
728
+ color: "#101828",
729
+ fontSize: 14,
730
+ outline: "none",
731
+ boxSizing: "border-box"
732
+ };
733
+
734
+ const miniButtonStyle: React.CSSProperties = {
735
+ display: "inline-flex",
736
+ alignItems: "center",
737
+ gap: 8,
738
+ padding: "8px 12px",
739
+ borderRadius: 999,
740
+ border: "1px solid #d0d5dd",
741
+ background: "#ffffff",
742
+ color: "#344054",
743
+ fontSize: 12,
744
+ fontWeight: 700,
745
+ cursor: "pointer"
746
+ };
747
+
748
+ const presetCardStyle: React.CSSProperties = {
749
+ padding: 14,
750
+ borderRadius: 18,
751
+ border: "1px solid #e4e7ec",
752
+ background: "#ffffff",
753
+ cursor: "pointer"
754
+ };
755
+
756
+ const iconChipStyle: React.CSSProperties = {
757
+ display: "inline-flex",
758
+ alignItems: "center",
759
+ gap: 8,
760
+ padding: "10px 12px",
761
+ borderRadius: 999,
762
+ border: "1px solid #e4e7ec",
763
+ background: "#ffffff",
764
+ color: "#344054",
765
+ fontSize: 12,
766
+ fontWeight: 700,
767
+ cursor: "pointer"
768
+ };
769
+
770
+ const inlineCodeStyle: React.CSSProperties = {
771
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
772
+ background: "#f2f4f7",
773
+ padding: "2px 6px",
774
+ borderRadius: 6
775
+ };
776
+
777
+ function primaryButtonStyle(disabled: boolean): React.CSSProperties {
778
+ return {
779
+ display: "inline-flex",
780
+ alignItems: "center",
781
+ justifyContent: "center",
782
+ gap: 10,
783
+ width: "100%",
784
+ padding: "14px 18px",
785
+ borderRadius: 16,
786
+ border: "1px solid #111827",
787
+ background: disabled ? "#475467" : "linear-gradient(135deg, #111827 0%, #1d4ed8 100%)",
788
+ color: "#ffffff",
789
+ fontSize: 14,
790
+ fontWeight: 800,
791
+ cursor: disabled ? "not-allowed" : "pointer",
792
+ boxShadow: disabled ? "none" : "0 14px 32px rgba(29,78,216,0.22)"
793
+ };
794
+ }
795
+
796
+ function secondaryActionStyle(disabled: boolean): React.CSSProperties {
797
+ return {
798
+ display: "inline-flex",
799
+ alignItems: "center",
800
+ gap: 8,
801
+ padding: "10px 14px",
802
+ borderRadius: 12,
803
+ border: "1px solid #d0d5dd",
804
+ background: disabled ? "#f2f4f7" : "#ffffff",
805
+ color: disabled ? "#98a2b3" : "#344054",
806
+ cursor: disabled ? "not-allowed" : "pointer",
807
+ fontSize: 13,
808
+ fontWeight: 700
809
+ };
810
+ }
811
+
812
+ function saveActionStyle(status: SaveStatus, disabled: boolean): React.CSSProperties {
813
+ const tone =
814
+ status === "success"
815
+ ? { background: "#ecfdf3", border: "#abefc6", color: "#027a48" }
816
+ : status === "error"
817
+ ? { background: "#fef3f2", border: "#fecdca", color: "#b42318" }
818
+ : { background: "#111827", border: "#111827", color: "#ffffff" };
819
+
820
+ return {
821
+ display: "inline-flex",
822
+ alignItems: "center",
823
+ gap: 8,
824
+ padding: "10px 14px",
825
+ borderRadius: 12,
826
+ border: `1px solid ${disabled && status === "idle" ? "#d0d5dd" : tone.border}`,
827
+ background: disabled && status === "idle" ? "#f2f4f7" : tone.background,
828
+ color: disabled && status === "idle" ? "#98a2b3" : tone.color,
829
+ cursor: disabled ? "not-allowed" : "pointer",
830
+ fontSize: 13,
831
+ fontWeight: 700
832
+ };
833
+ }
834
+
314
835
  createRoot(document.getElementById("root")!).render(<App />);