Skip to content

Commit aa597fb

Browse files
authored
Merge pull request #69 from tokenhost/issue-62/pr-07-native-image-ui
[Issue 62 7/8] Native generated-UI image field UX
2 parents c8392da + 74046d0 commit aa597fb

8 files changed

Lines changed: 734 additions & 80 deletions

File tree

packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { fetchManifest, getPrimaryDeployment } from '../../../src/lib/manifest';
1212
import { getCollection, mutableFields, type ThsCollection, type ThsField } from '../../../src/lib/ths';
1313
import { submitWriteTx } from '../../../src/lib/tx';
1414
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
15+
import ImageFieldInput from '../../../src/components/ImageFieldInput';
1516

1617
function inputType(field: ThsField): 'text' | 'number' {
1718
if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal' || field.type === 'reference') return 'number';
@@ -42,7 +43,9 @@ export default function EditRecordPage(props: { params: { collection: string } }
4243
const idParam = search.get('id');
4344
const rpcOverride = search.get('rpc') ?? undefined;
4445

45-
const [loading, setLoading] = useState(true);
46+
const [bootstrapping, setBootstrapping] = useState(true);
47+
const [recordLoading, setRecordLoading] = useState(false);
48+
const [initialRecordResolved, setInitialRecordResolved] = useState(false);
4649
const [error, setError] = useState<string | null>(null);
4750
const [status, setStatus] = useState<string | null>(null);
4851
const [txPhase, setTxPhase] = useState<TxPhase>('idle');
@@ -67,7 +70,8 @@ export default function EditRecordPage(props: { params: { collection: string } }
6770

6871
useEffect(() => {
6972
async function boot() {
70-
setLoading(true);
73+
setBootstrapping(true);
74+
setInitialRecordResolved(false);
7175
setError(null);
7276
try {
7377
const manifest = await fetchManifest();
@@ -89,7 +93,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
8993
} catch (e: any) {
9094
setError(String(e?.message ?? e));
9195
} finally {
92-
setLoading(false);
96+
setBootstrapping(false);
9397
}
9498
}
9599
void boot();
@@ -100,8 +104,11 @@ export default function EditRecordPage(props: { params: { collection: string } }
100104
const fields = collection ? mutableFields(collection) : [];
101105
const optimistic = Boolean((collection as any)?.updateRules?.optimisticConcurrency);
102106

103-
async function fetchRecord() {
107+
async function fetchRecord(options?: { initial?: boolean }) {
104108
if (!publicClient || !abi || !appAddress || id === null) return;
109+
const isInitial = options?.initial === true;
110+
if (isInitial) setInitialRecordResolved(false);
111+
setRecordLoading(true);
105112
setError(null);
106113
try {
107114
assertAbiFunction(abi, fnGet(collectionName), collectionName);
@@ -114,12 +121,15 @@ export default function EditRecordPage(props: { params: { collection: string } }
114121
setRecord(r);
115122
} catch (e: any) {
116123
setError(String(e?.message ?? e));
124+
} finally {
125+
setRecordLoading(false);
126+
if (isInitial) setInitialRecordResolved(true);
117127
}
118128
}
119129

120130
// Load record once ABI + id are ready.
121131
useEffect(() => {
122-
void fetchRecord();
132+
void fetchRecord({ initial: true });
123133
// eslint-disable-next-line react-hooks/exhaustive-deps
124134
}, [publicClient, abi, appAddress, idParam]);
125135

@@ -211,7 +221,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
211221
);
212222
}
213223

214-
if (loading && !record) {
224+
if ((bootstrapping || (recordLoading && !initialRecordResolved)) && !record) {
215225
return (
216226
<div className="card">
217227
<h2>Loading…</h2>
@@ -264,7 +274,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
264274
);
265275
}
266276

267-
if (!record) {
277+
if (initialRecordResolved && !record) {
268278
return (
269279
<div className="card">
270280
<h2>Not found</h2>
@@ -282,31 +292,40 @@ export default function EditRecordPage(props: { params: { collection: string } }
282292
<button className="btn" onClick={() => router.push(`/${collectionName}/?mode=view&id=${String(id)}`)}>Back</button>
283293
</div>
284294

285-
{fields.map((f) => (
286-
<div key={f.name}>
287-
<label className="label">{f.name}</label>
288-
{f.type === 'bool' ? (
289-
<select
290-
className="select"
291-
value={form[f.name] ?? 'false'}
292-
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
293-
>
294-
<option value="false">false</option>
295-
<option value="true">true</option>
296-
</select>
297-
) : (
298-
<input
299-
className="input"
300-
type={inputType(f)}
301-
value={form[f.name] ?? ''}
302-
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
303-
placeholder={f.type === 'reference' ? 'record id (uint256)' : f.type}
304-
/>
305-
)}
306-
</div>
307-
))}
295+
<div className="formGrid">
296+
{fields.map((f) => (
297+
<div key={f.name} className="fieldGroup">
298+
<label className="label">{f.name}</label>
299+
{f.type === 'bool' ? (
300+
<select
301+
className="select"
302+
value={form[f.name] ?? 'false'}
303+
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
304+
>
305+
<option value="false">false</option>
306+
<option value="true">true</option>
307+
</select>
308+
) : f.type === 'image' ? (
309+
<ImageFieldInput
310+
manifest={manifest}
311+
value={form[f.name] ?? ''}
312+
disabled={txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'}
313+
onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))}
314+
/>
315+
) : (
316+
<input
317+
className="input"
318+
type={inputType(f)}
319+
value={form[f.name] ?? ''}
320+
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
321+
placeholder={f.type === 'reference' ? 'record id (uint256)' : f.type}
322+
/>
323+
)}
324+
</div>
325+
))}
326+
</div>
308327

309-
<div style={{ marginTop: 16, display: 'flex', gap: 10 }}>
328+
<div className="actionGroup" style={{ marginTop: 16 }}>
310329
<button
311330
className="btn primary"
312331
onClick={() => void submit()}

packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { fetchManifest, getPrimaryDeployment } from '../../../src/lib/manifest';
1212
import { createFields, getCollection, hasCreatePayment, requiredFieldNames, type ThsField } from '../../../src/lib/ths';
1313
import { submitWriteTx } from '../../../src/lib/tx';
1414
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
15+
import ImageFieldInput from '../../../src/components/ImageFieldInput';
1516

1617
function inputType(field: ThsField): 'text' | 'number' {
1718
if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal' || field.type === 'reference') return 'number';
@@ -185,33 +186,41 @@ export default function CreateRecordPage(props: { params: { collection: string }
185186
<div className="muted">No create fee.</div>
186187
)}
187188

188-
{fields.map((f) => (
189-
<div key={f.name}>
190-
<label className="label">
191-
{f.name} {required.has(f.name) ? <span className="badge">required</span> : null}
192-
</label>
193-
{f.type === 'bool' ? (
194-
<select
195-
className="select"
196-
value={form[f.name] ?? 'false'}
197-
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
198-
>
199-
<option value="false">false</option>
200-
<option value="true">true</option>
201-
</select>
202-
) : (
203-
<input
204-
className="input"
205-
type={inputType(f)}
206-
value={form[f.name] ?? ''}
207-
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
208-
placeholder={f.type === 'reference' ? 'record id (uint256)' : f.type}
209-
/>
210-
)}
211-
</div>
212-
))}
189+
<div className="formGrid">
190+
{fields.map((f) => (
191+
<div key={f.name} className="fieldGroup">
192+
<label className="label">
193+
{f.name} {required.has(f.name) ? <span className="badge">required</span> : null}
194+
</label>
195+
{f.type === 'bool' ? (
196+
<select
197+
className="select"
198+
value={form[f.name] ?? 'false'}
199+
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
200+
>
201+
<option value="false">false</option>
202+
<option value="true">true</option>
203+
</select>
204+
) : f.type === 'image' ? (
205+
<ImageFieldInput
206+
manifest={manifest}
207+
value={form[f.name] ?? ''}
208+
onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))}
209+
/>
210+
) : (
211+
<input
212+
className="input"
213+
type={inputType(f)}
214+
value={form[f.name] ?? ''}
215+
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
216+
placeholder={f.type === 'reference' ? 'record id (uint256)' : f.type}
217+
/>
218+
)}
219+
</div>
220+
))}
221+
</div>
213222

214-
<div style={{ marginTop: 16, display: 'flex', gap: 10 }}>
223+
<div className="actionGroup" style={{ marginTop: 16 }}>
215224
<button
216225
className="btn primary"
217226
onClick={() => void submit()}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
'use client';
2+
3+
import React, { useEffect, useMemo, useRef, useState } from 'react';
4+
5+
import { getUploadConfig, uploadFile } from '../lib/upload';
6+
7+
export default function ImageFieldInput(props: {
8+
manifest: any | null;
9+
value: string;
10+
disabled?: boolean;
11+
onChange: (next: string) => void;
12+
}) {
13+
const { manifest, value, disabled, onChange } = props;
14+
const config = useMemo(() => getUploadConfig(manifest), [manifest]);
15+
const inputRef = useRef<HTMLInputElement | null>(null);
16+
const [busy, setBusy] = useState(false);
17+
const [progress, setProgress] = useState(0);
18+
const [error, setError] = useState<string | null>(null);
19+
const [status, setStatus] = useState<string | null>(null);
20+
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
21+
const previewUrl = localPreviewUrl || value || '';
22+
23+
useEffect(() => {
24+
return () => {
25+
if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
26+
};
27+
}, [localPreviewUrl]);
28+
29+
async function handleFile(file: File) {
30+
setError(null);
31+
setStatus(null);
32+
setProgress(0);
33+
34+
if (!config) {
35+
setError('Uploads are not enabled for this app.');
36+
return;
37+
}
38+
39+
const objectUrl = URL.createObjectURL(file);
40+
if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
41+
setLocalPreviewUrl(objectUrl);
42+
setBusy(true);
43+
setStatus(`Uploading via ${config.runnerMode}…`);
44+
45+
try {
46+
const uploaded = await uploadFile({
47+
manifest,
48+
file,
49+
onProgress: setProgress
50+
});
51+
onChange(uploaded.url);
52+
setStatus(uploaded.cid ? `Uploaded (${uploaded.cid.slice(0, 12)}…).` : 'Uploaded.');
53+
} catch (e: any) {
54+
setError(String(e?.message ?? e));
55+
setStatus(null);
56+
} finally {
57+
setBusy(false);
58+
}
59+
}
60+
61+
return (
62+
<div className="fieldGroup">
63+
<div className="actionGroup" style={{ marginBottom: 8 }}>
64+
<button
65+
type="button"
66+
className="btn"
67+
disabled={Boolean(disabled || busy || !config)}
68+
onClick={() => inputRef.current?.click()}
69+
title={config ? `Upload image via ${config.runnerMode}` : 'Uploads disabled'}
70+
>
71+
{busy ? `Uploading ${progress}%` : 'Choose image'}
72+
</button>
73+
<button
74+
type="button"
75+
className="btn"
76+
disabled={Boolean(disabled || busy || !value)}
77+
onClick={() => {
78+
onChange('');
79+
setStatus(null);
80+
setError(null);
81+
setProgress(0);
82+
if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
83+
setLocalPreviewUrl(null);
84+
}}
85+
>
86+
Remove
87+
</button>
88+
</div>
89+
90+
<input
91+
ref={inputRef}
92+
type="file"
93+
accept={config?.accept?.join(',') || 'image/*'}
94+
style={{ display: 'none' }}
95+
onChange={(e) => {
96+
const file = e.target.files?.[0];
97+
if (file) void handleFile(file);
98+
e.currentTarget.value = '';
99+
}}
100+
/>
101+
102+
{previewUrl ? (
103+
// eslint-disable-next-line @next/next/no-img-element
104+
<img src={previewUrl} alt="preview" style={{ maxWidth: 320, borderRadius: 12, border: '1px solid var(--border)', marginBottom: 8 }} />
105+
) : null}
106+
107+
<input
108+
className="input"
109+
type="text"
110+
value={value}
111+
disabled={Boolean(disabled || busy)}
112+
onChange={(e) => onChange(e.target.value)}
113+
placeholder={config ? 'Uploaded image URL/CID appears here' : 'image URL or CID'}
114+
/>
115+
116+
{status ? <div className="muted" style={{ marginTop: 8 }}>{status}</div> : null}
117+
{error ? <div className="pre" style={{ marginTop: 8 }}>{error}</div> : null}
118+
</div>
119+
);
120+
}

0 commit comments

Comments
 (0)