Skip to content

Commit 5c83c56

Browse files
authored
Merge pull request #72 from tokenhost/issue-62/pr-10-port-cyber-grid-shell
[Issue 62 10/10] Port full cyber-grid shell into generated UI
2 parents 0ee5bbc + c2c52c8 commit 5c83c56

37 files changed

Lines changed: 3779 additions & 453 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Suspense } from 'react';
2+
3+
import MicroblogPostRouteClient from '../../src/components/MicroblogPostRouteClient';
4+
5+
export default function PostPage() {
6+
return (
7+
<Suspense fallback={null}>
8+
<MicroblogPostRouteClient />
9+
</Suspense>
10+
);
11+
}
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { useEffect, useMemo, useState } from 'react';
5+
import { useRouter } from 'next/navigation';
6+
7+
import ImageFieldInput from './ImageFieldInput';
8+
import TxStatus, { type TxPhase } from './TxStatus';
9+
import { fnCreate } from '../lib/app';
10+
import { chainWithRpcOverride, requestWalletAddress } from '../lib/clients';
11+
import { getReadRpcUrl } from '../lib/manifest';
12+
import { submitWriteTx } from '../lib/tx';
13+
import { listOwnedProfiles, loadMicroblogRuntime, profileHandle, profileLabel, type ProfileRecord } from '../lib/microblog';
14+
15+
type ComposeState = {
16+
loading: boolean;
17+
runtimeError: string | null;
18+
connectError: string | null;
19+
submitError: string | null;
20+
};
21+
22+
const PROFILE_STORAGE_PREFIX = 'TH_MICROBLOG_PROFILE_ID:';
23+
24+
export default function MicroblogComposeClient() {
25+
const router = useRouter();
26+
const [state, setState] = useState<ComposeState>({
27+
loading: true,
28+
runtimeError: null,
29+
connectError: null,
30+
submitError: null
31+
});
32+
const [runtime, setRuntime] = useState<any | null>(null);
33+
const [account, setAccount] = useState<string | null>(null);
34+
const [profiles, setProfiles] = useState<ProfileRecord[]>([]);
35+
const [selectedProfileId, setSelectedProfileId] = useState<string>('');
36+
const [body, setBody] = useState('');
37+
const [image, setImage] = useState('');
38+
const [imageUploadBusy, setImageUploadBusy] = useState(false);
39+
const [txStatus, setTxStatus] = useState<string | null>(null);
40+
const [txPhase, setTxPhase] = useState<TxPhase>('idle');
41+
const [txHash, setTxHash] = useState<string | null>(null);
42+
43+
useEffect(() => {
44+
let cancelled = false;
45+
46+
(async () => {
47+
try {
48+
const loadedRuntime = await loadMicroblogRuntime();
49+
if (cancelled) return;
50+
setRuntime(loadedRuntime);
51+
52+
try {
53+
const cached = localStorage.getItem('TH_ACCOUNT');
54+
if (cached && !cancelled) setAccount(cached);
55+
} catch {
56+
// ignore
57+
}
58+
} catch (error: any) {
59+
if (cancelled) return;
60+
setState((prev) => ({ ...prev, runtimeError: String(error?.message ?? error), loading: false }));
61+
return;
62+
}
63+
64+
if (!cancelled) setState((prev) => ({ ...prev, loading: false }));
65+
})();
66+
67+
return () => {
68+
cancelled = true;
69+
};
70+
}, []);
71+
72+
useEffect(() => {
73+
let cancelled = false;
74+
if (!runtime || !account) {
75+
setProfiles([]);
76+
setSelectedProfileId('');
77+
return;
78+
}
79+
80+
setState((prev) => ({ ...prev, loading: true, connectError: null }));
81+
void (async () => {
82+
try {
83+
const ownedProfiles = await listOwnedProfiles(runtime, account);
84+
if (cancelled) return;
85+
setProfiles(ownedProfiles);
86+
87+
let preferred = '';
88+
try {
89+
const stored = localStorage.getItem(`${PROFILE_STORAGE_PREFIX}${account.toLowerCase()}`) ?? '';
90+
if (stored && ownedProfiles.some((entry) => String(entry.id) === stored)) preferred = stored;
91+
} catch {
92+
// ignore
93+
}
94+
if (!preferred && ownedProfiles[0]) preferred = String(ownedProfiles[0].id);
95+
setSelectedProfileId(preferred);
96+
} catch (error: any) {
97+
if (cancelled) return;
98+
setState((prev) => ({ ...prev, connectError: String(error?.message ?? error) }));
99+
} finally {
100+
if (!cancelled) setState((prev) => ({ ...prev, loading: false }));
101+
}
102+
})();
103+
104+
return () => {
105+
cancelled = true;
106+
};
107+
}, [runtime, account]);
108+
109+
useEffect(() => {
110+
if (!account || !selectedProfileId) return;
111+
try {
112+
localStorage.setItem(`${PROFILE_STORAGE_PREFIX}${account.toLowerCase()}`, selectedProfileId);
113+
} catch {
114+
// ignore
115+
}
116+
}, [account, selectedProfileId]);
117+
118+
const selectedProfile = useMemo(
119+
() => profiles.find((entry) => String(entry.id) === selectedProfileId) ?? null,
120+
[profiles, selectedProfileId]
121+
);
122+
const walletChain = useMemo(
123+
() => (runtime ? chainWithRpcOverride(runtime.chain, getReadRpcUrl(runtime.manifest) || undefined) : null),
124+
[runtime]
125+
);
126+
127+
async function connectWallet() {
128+
if (!walletChain) return;
129+
setState((prev) => ({ ...prev, connectError: null }));
130+
try {
131+
const nextAccount = await requestWalletAddress(walletChain);
132+
setAccount(nextAccount);
133+
try {
134+
localStorage.setItem('TH_ACCOUNT', nextAccount);
135+
} catch {
136+
// ignore
137+
}
138+
} catch (error: any) {
139+
setState((prev) => ({ ...prev, connectError: String(error?.message ?? error) }));
140+
}
141+
}
142+
143+
async function submit() {
144+
if (!runtime || !walletChain || !selectedProfile || !body.trim() || imageUploadBusy) return;
145+
146+
setState((prev) => ({ ...prev, submitError: null }));
147+
setTxStatus(null);
148+
setTxPhase('idle');
149+
setTxHash(null);
150+
151+
try {
152+
const result = await submitWriteTx({
153+
manifest: runtime.manifest,
154+
deployment: runtime.deployment,
155+
chain: walletChain,
156+
publicClient: runtime.publicClient,
157+
address: runtime.appAddress,
158+
abi: runtime.abi,
159+
functionName: fnCreate('Post'),
160+
contractArgs: [
161+
{
162+
authorProfile: selectedProfile.id,
163+
body: body.trim(),
164+
image: image.trim()
165+
}
166+
],
167+
setStatus: setTxStatus,
168+
onPhase: setTxPhase,
169+
onHash: setTxHash
170+
});
171+
172+
setTxStatus(`Posted (${result.hash.slice(0, 10)}…).`);
173+
router.push('/');
174+
router.refresh();
175+
} catch (error: any) {
176+
setState((prev) => ({ ...prev, submitError: String(error?.message ?? error) }));
177+
setTxStatus(null);
178+
setTxPhase('failed');
179+
}
180+
}
181+
182+
if (state.loading && !runtime) {
183+
return (
184+
<section className="card">
185+
<h2>Loading composer…</h2>
186+
<p className="muted">Resolving the active deployment and wallet state.</p>
187+
</section>
188+
);
189+
}
190+
191+
if (state.runtimeError) {
192+
return (
193+
<section className="card">
194+
<div className="eyebrow">/compose/error</div>
195+
<h2>Unable to load composer</h2>
196+
<p className="muted">{state.runtimeError}</p>
197+
</section>
198+
);
199+
}
200+
201+
return (
202+
<div className="pageStack">
203+
<section className="card heroPanel">
204+
<div className="heroSplit">
205+
<div>
206+
<div className="heroTopline">
207+
<span className="eyebrow">/post/compose</span>
208+
<div className="chipRow">
209+
<span className="badge">normalized author identity</span>
210+
<span className="badge">profile-linked posts</span>
211+
</div>
212+
</div>
213+
<h2 className="displayTitle">
214+
Compose as a profile
215+
<br />
216+
<span>not as a copied handle string.</span>
217+
</h2>
218+
<p className="lead">
219+
Posts now store <span className="badge">authorProfile</span> as an on-chain reference to <span className="badge">Profile</span>,
220+
so handle and avatar changes flow through existing posts automatically.
221+
</p>
222+
<div className="actionGroup">
223+
<Link className="btn" href="/">Back to feed</Link>
224+
<Link className="btn" href="/Profile/">Browse profiles</Link>
225+
</div>
226+
</div>
227+
228+
<div className="heroDataPanel">
229+
<div className="eyebrow">/identity</div>
230+
<div className="heroStatGrid">
231+
<div className="heroStat">
232+
<div className="heroStatValue">{account ? 1 : 0}</div>
233+
<div className="heroStatLabel">Wallet linked</div>
234+
</div>
235+
<div className="heroStat">
236+
<div className="heroStatValue">{profiles.length}</div>
237+
<div className="heroStatLabel">Owned profiles</div>
238+
</div>
239+
</div>
240+
<div className="heroMeta">
241+
<span className="badge">posts reference profiles</span>
242+
<span className="badge">profile changes propagate</span>
243+
</div>
244+
</div>
245+
</div>
246+
</section>
247+
248+
{!account ? (
249+
<section className="card">
250+
<div className="eyebrow">/wallet</div>
251+
<h3>Connect a wallet to compose</h3>
252+
<p className="muted">Posting now requires selecting one of your on-chain profiles. Connect the wallet that owns the profile first.</p>
253+
<div className="actionGroup">
254+
<button className="btn primary" onClick={() => void connectWallet()}>Connect wallet</button>
255+
</div>
256+
{state.connectError ? <p className="muted">{state.connectError}</p> : null}
257+
</section>
258+
) : null}
259+
260+
{account && !profiles.length ? (
261+
<section className="card">
262+
<div className="eyebrow">/profiles/empty</div>
263+
<h3>No owned profiles found</h3>
264+
<p className="muted">Create a profile first. Once it exists on-chain under this wallet, you can compose posts as that profile.</p>
265+
<div className="actionGroup">
266+
<Link className="btn primary" href="/Profile/?mode=new">Create profile</Link>
267+
</div>
268+
{state.connectError ? <p className="muted">{state.connectError}</p> : null}
269+
</section>
270+
) : null}
271+
272+
{account && profiles.length ? (
273+
<section className="card" style={{ display: 'grid', gap: 18 }}>
274+
<div>
275+
<h2>Compose Post</h2>
276+
<p className="muted">Choose the on-chain profile identity for this post, then write the post body and optional image.</p>
277+
</div>
278+
279+
<div className="formGrid">
280+
<div className="fieldGroup">
281+
<label className="label">Profile</label>
282+
<select
283+
className="select"
284+
value={selectedProfileId}
285+
onChange={(event) => setSelectedProfileId(event.target.value)}
286+
>
287+
{profiles.map((entry) => (
288+
<option key={String(entry.id)} value={String(entry.id)}>
289+
{profileLabel(entry.record)}
290+
</option>
291+
))}
292+
</select>
293+
</div>
294+
295+
<div className="fieldGroup">
296+
<label className="label">Current identity</label>
297+
<div className="recordPreviewCell" style={{ minHeight: 110 }}>
298+
{selectedProfile ? (
299+
<div style={{ display: 'grid', gap: 10 }}>
300+
<div className="chipRow">
301+
<span className="badge">profile #{String(selectedProfile.id)}</span>
302+
{profileHandle(selectedProfile.record) ? <span className="badge">@{profileHandle(selectedProfile.record)}</span> : null}
303+
</div>
304+
<strong>{profileLabel(selectedProfile.record)}</strong>
305+
{String(selectedProfile.record?.bio ?? '').trim() ? (
306+
<p className="muted" style={{ margin: 0 }}>{String(selectedProfile.record.bio)}</p>
307+
) : null}
308+
</div>
309+
) : (
310+
<span className="muted">Select a profile.</span>
311+
)}
312+
</div>
313+
</div>
314+
315+
<div className="fieldGroup">
316+
<label className="label">Body <span className="badge">required</span></label>
317+
<textarea
318+
className="input"
319+
value={body}
320+
onChange={(event) => setBody(event.target.value)}
321+
placeholder="Share something on-chain. Hashtags like #tokenhost or #microblog will be indexed automatically."
322+
rows={6}
323+
style={{ resize: 'vertical', minHeight: 160 }}
324+
/>
325+
</div>
326+
327+
<div className="fieldGroup">
328+
<label className="label">Image</label>
329+
<ImageFieldInput
330+
manifest={runtime?.manifest ?? null}
331+
value={image}
332+
onChange={setImage}
333+
onBusyChange={setImageUploadBusy}
334+
/>
335+
</div>
336+
</div>
337+
338+
<div className="actionGroup">
339+
<button
340+
className="btn primary"
341+
onClick={() => void submit()}
342+
disabled={
343+
!selectedProfile ||
344+
!body.trim() ||
345+
imageUploadBusy ||
346+
txPhase === 'submitting' ||
347+
txPhase === 'submitted' ||
348+
txPhase === 'confirming'
349+
}
350+
>
351+
{imageUploadBusy ? 'Waiting for image upload…' : 'Publish post'}
352+
</button>
353+
<Link className="btn" href="/">Cancel</Link>
354+
</div>
355+
356+
{txStatus ? <div className="muted">{txStatus}</div> : null}
357+
<TxStatus phase={txPhase} hash={txHash} chainId={Number(runtime?.deployment?.chainId ?? NaN)} error={state.submitError} />
358+
{state.submitError ? <div className="pre">{state.submitError}</div> : null}
359+
</section>
360+
) : null}
361+
</div>
362+
);
363+
}

0 commit comments

Comments
 (0)