From b5421c70ba09982fcf7261bd41cd48c7eb634f5b Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 09:24:57 -1000
Subject: [PATCH 01/24] Upstream reference-aware generated UI
---
.../app/[collection]/ClientPage.tsx | 2 +-
.../app/[collection]/edit/ClientPage.tsx | 17 +-
.../app/[collection]/new/ClientPage.tsx | 17 +-
.../app/[collection]/view/ClientPage.tsx | 28 ++-
.../src/components/RecordCard.tsx | 24 ++-
.../src/components/ReferenceFieldInput.tsx | 172 ++++++++++++++++++
.../src/components/ResolvedReferenceValue.tsx | 97 ++++++++++
.../templates/next-export-ui/src/lib/ths.ts | 20 +-
test/testCliGenerateUi.js | 75 ++++++++
9 files changed, 441 insertions(+), 11 deletions(-)
create mode 100644 packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
create mode 100644 packages/templates/next-export-ui/src/components/ResolvedReferenceValue.tsx
diff --git a/packages/templates/next-export-ui/app/[collection]/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/ClientPage.tsx
index cb6cd00..9e28011 100644
--- a/packages/templates/next-export-ui/app/[collection]/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/ClientPage.tsx
@@ -244,7 +244,7 @@ function CollectionListModePage(props: { params: { collection: string } }) {
{records.map((r, idx) => (
-
+
))}
diff --git a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
index dae157f..ba6a1b1 100644
--- a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
@@ -13,9 +13,10 @@ import { getCollection, mutableFields, type ThsCollection, type ThsField } from
import { submitWriteTx } from '../../../src/lib/tx';
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
import ImageFieldInput from '../../../src/components/ImageFieldInput';
+import ReferenceFieldInput from '../../../src/components/ReferenceFieldInput';
function inputType(field: ThsField): 'text' | 'number' {
- if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal' || field.type === 'reference') return 'number';
+ if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal') return 'number';
return 'text';
}
@@ -315,13 +316,25 @@ export default function EditRecordPage(props: { params: { collection: string } }
disabled={txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'}
onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))}
/>
+ ) : f.type === 'reference' ? (
+ setForm((prev) => ({ ...prev, [f.name]: next }))}
+ />
) : (
setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
- placeholder={f.type === 'reference' ? 'record id (uint256)' : f.type}
+ placeholder={f.type}
/>
)}
diff --git a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
index 2a5a17e..5a3e502 100644
--- a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
@@ -13,9 +13,10 @@ import { createFields, getCollection, hasCreatePayment, requiredFieldNames, type
import { submitWriteTx } from '../../../src/lib/tx';
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
import ImageFieldInput from '../../../src/components/ImageFieldInput';
+import ReferenceFieldInput from '../../../src/components/ReferenceFieldInput';
function inputType(field: ThsField): 'text' | 'number' {
- if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal' || field.type === 'reference') return 'number';
+ if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal') return 'number';
return 'text';
}
@@ -210,13 +211,25 @@ export default function CreateRecordPage(props: { params: { collection: string }
value={form[f.name] ?? ''}
onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))}
/>
+ ) : f.type === 'reference' ? (
+ setForm((prev) => ({ ...prev, [f.name]: next }))}
+ />
) : (
setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
- placeholder={f.type === 'reference' ? 'record id (uint256)' : f.type}
+ placeholder={f.type}
/>
)}
diff --git a/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
index c28c60f..890004b 100644
--- a/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
@@ -12,6 +12,7 @@ import { fetchManifest, getPrimaryDeployment, getReadRpcUrl } from '../../../src
import { fieldLinkUi, getCollection, transferEnabled, type ThsCollection, type ThsField } from '../../../src/lib/ths';
import { submitWriteTx } from '../../../src/lib/tx';
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
+import ResolvedReferenceValue from '../../../src/components/ResolvedReferenceValue';
function getValue(record: any, key: string, fallbackIndex?: number): any {
if (record && typeof record === 'object' && key in record) {
@@ -28,7 +29,16 @@ function fieldIndex(collection: ThsCollection, field: ThsField): number {
return 9 + Math.max(0, idx);
}
-function renderFieldValue(field: ThsField, rendered: string) {
+function renderFieldValue(args: {
+ collection: ThsCollection;
+ field: ThsField;
+ rendered: string;
+ raw: unknown;
+ abi: any[] | null;
+ publicClient: any | null;
+ address: `0x${string}` | undefined;
+}) {
+ const { collection, field, rendered, raw, abi, publicClient, address } = args;
if (!rendered) return — ;
const linkUi = fieldLinkUi(field);
@@ -45,6 +55,20 @@ function renderFieldValue(field: ThsField, rendered: string) {
);
}
+ if (field.type === 'reference') {
+ return (
+
+ );
+ }
+
return {rendered} ;
}
@@ -331,7 +355,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
return (
{f.name}
- {renderFieldValue(f, rendered)}
+ {renderFieldValue({ collection, field: f, rendered, raw: v, abi, publicClient, address: appAddress })}
);
})}
diff --git a/packages/templates/next-export-ui/src/components/RecordCard.tsx b/packages/templates/next-export-ui/src/components/RecordCard.tsx
index 36c4754..3b6f4d5 100644
--- a/packages/templates/next-export-ui/src/components/RecordCard.tsx
+++ b/packages/templates/next-export-ui/src/components/RecordCard.tsx
@@ -3,6 +3,7 @@ import Link from 'next/link';
import type { ThsCollection, ThsField } from '../lib/ths';
import { displayField, fieldLinkUi } from '../lib/ths';
import { formatFieldValue, shortAddress } from '../lib/format';
+import ResolvedReferenceValue from './ResolvedReferenceValue';
function getValue(record: any, key: string, fallbackIndex?: number): any {
if (record && typeof record === 'object' && key in record) {
@@ -21,8 +22,14 @@ function fieldIndex(collection: ThsCollection, field: ThsField): number {
return 9 + Math.max(0, idx);
}
-export default function RecordCard(props: { collection: ThsCollection; record: any }) {
- const { collection, record } = props;
+export default function RecordCard(props: {
+ collection: ThsCollection;
+ record: any;
+ abi?: any[] | null;
+ publicClient?: any | null;
+ address?: `0x${string}`;
+}) {
+ const { collection, record, abi = null, publicClient = null, address } = props;
const id = getValue(record, 'id', 0);
const owner = getValue(record, 'owner', 3);
const createdBy = getValue(record, 'createdBy', 2);
@@ -44,11 +51,12 @@ export default function RecordCard(props: { collection: ThsCollection; record: a
return {
name: field.name,
type: field.type,
+ raw,
value: formatFieldValue(raw, field.type, (field as any).decimals, field.name)
};
})
.filter(Boolean)
- .slice(0, 3) as Array<{ name: string; type: string; value: string }>;
+ .slice(0, 3) as Array<{ name: string; type: string; raw: unknown; value: string }>;
return (
@@ -79,6 +87,16 @@ export default function RecordCard(props: { collection: ThsCollection; record: a
{field.type === 'image' ? (
// eslint-disable-next-line @next/next/no-img-element
+ ) : field.type === 'reference' ? (
+
candidate.name === field.name) ?? { name: field.name, type: 'reference' }}
+ value={field.raw}
+ abi={abi}
+ publicClient={publicClient}
+ address={address}
+ fallback={field.value}
+ />
) : (
field.value
)}
diff --git a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
new file mode 100644
index 0000000..66e1a4e
--- /dev/null
+++ b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
@@ -0,0 +1,172 @@
+'use client';
+
+import React, { useEffect, useMemo, useState } from 'react';
+
+import { listAllRecords } from '../lib/runtime';
+import { formatFieldValue } from '../lib/format';
+import { displayField, getRelatedCollection, type ThsCollection, type ThsField } from '../lib/ths';
+
+type ReferenceOption = {
+ id: bigint;
+ label: string;
+ owned: boolean;
+};
+
+function getRecordValue(record: any, key: string, fallbackIndex?: number): any {
+ if (record && typeof record === 'object' && key in record) {
+ return (record as any)[key];
+ }
+ if (Array.isArray(record) && typeof fallbackIndex === 'number') {
+ return record[fallbackIndex];
+ }
+ return undefined;
+}
+
+function fieldIndex(collection: ThsCollection, field: ThsField): number {
+ const idx = (collection.fields as any[]).findIndex((candidate) => candidate && candidate.name === field.name);
+ return 9 + Math.max(0, idx);
+}
+
+function recordLabel(collection: ThsCollection, id: bigint, record: any): string {
+ const display = displayField(collection);
+ if (!display) return `${collection.name} #${String(id)}`;
+ const raw = getRecordValue(record, display.name, fieldIndex(collection, display));
+ const rendered = formatFieldValue(raw, display.type, display.decimals, display.name).trim();
+ if (!rendered) return `${collection.name} #${String(id)}`;
+ return `${rendered} (#${String(id)})`;
+}
+
+export default function ReferenceFieldInput(props: {
+ manifest: any;
+ publicClient: any;
+ abi: any[];
+ address: `0x${string}`;
+ collection: ThsCollection;
+ field: ThsField;
+ value: string;
+ disabled?: boolean;
+ onChange: (next: string) => void;
+}) {
+ const { manifest, publicClient, abi, address, collection, field, value, disabled, onChange } = props;
+ const relatedCollection = useMemo(() => getRelatedCollection(collection, field.name), [collection, field.name]);
+
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [options, setOptions] = useState([]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function loadOptions() {
+ if (!relatedCollection || !publicClient || !abi || !address) {
+ setOptions([]);
+ setError(null);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ try {
+ const walletAccount =
+ typeof window !== 'undefined' ? window.localStorage.getItem('TH_ACCOUNT')?.toLowerCase() ?? '' : '';
+ const page = await listAllRecords({
+ publicClient,
+ abi,
+ address,
+ collectionName: relatedCollection.name,
+ manifest
+ });
+
+ const nextOptions = page.ids
+ .map((id, index) => {
+ const record = page.records[index];
+ if (!record) return null;
+ const owner = String(getRecordValue(record, 'owner', 3) ?? '').toLowerCase();
+ return {
+ id,
+ label: recordLabel(relatedCollection, id, record),
+ owned: Boolean(walletAccount) && owner === walletAccount
+ };
+ })
+ .filter(Boolean) as ReferenceOption[];
+
+ nextOptions.sort((left, right) => {
+ if (left.owned !== right.owned) return left.owned ? -1 : 1;
+ if (left.label !== right.label) return left.label.localeCompare(right.label);
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
+ });
+
+ if (cancelled) return;
+ setOptions(nextOptions);
+
+ const ownedOptions = nextOptions.filter((option) => option.owned);
+ if (!value && ownedOptions.length === 1) {
+ onChange(String(ownedOptions[0]?.id ?? ''));
+ }
+ } catch (cause: any) {
+ if (cancelled) return;
+ setOptions([]);
+ setError(String(cause?.message ?? cause));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ }
+
+ void loadOptions();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [abi, address, collection, field.name, manifest, onChange, publicClient, relatedCollection, value]);
+
+ const resolvedValue = value.trim();
+ const hasResolvedValue = resolvedValue !== '' && options.some((option) => String(option.id) === resolvedValue);
+
+ if (!relatedCollection) {
+ return (
+ onChange(event.target.value)}
+ placeholder="record id (uint256)"
+ />
+ );
+ }
+
+ return (
+ <>
+ onChange(event.target.value)}
+ >
+
+ {loading ? `Loading ${relatedCollection.name}…` : options.length > 0 ? `Select ${relatedCollection.name}` : `No ${relatedCollection.name} records found`}
+
+ {options.map((option) => (
+
+ {option.label}{option.owned ? ' · owned by connected wallet' : ''}
+
+ ))}
+
+ onChange(event.target.value)}
+ placeholder={`${relatedCollection.name} record id`}
+ />
+
+ {error
+ ? `Could not load ${relatedCollection.name} records automatically. You can still enter a record id manually. ${error}`
+ : options.length > 0
+ ? `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first.`
+ : `Create a ${relatedCollection.name} record first, or enter a record id manually.`}
+
+ >
+ );
+}
diff --git a/packages/templates/next-export-ui/src/components/ResolvedReferenceValue.tsx b/packages/templates/next-export-ui/src/components/ResolvedReferenceValue.tsx
new file mode 100644
index 0000000..37885e4
--- /dev/null
+++ b/packages/templates/next-export-ui/src/components/ResolvedReferenceValue.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import React, { useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+
+import { assertAbiFunction, fnGet } from '../lib/app';
+import { formatFieldValue } from '../lib/format';
+import { displayField, getRelatedCollection, type ThsCollection, type ThsField } from '../lib/ths';
+
+function getRecordValue(record: any, key: string, fallbackIndex?: number): any {
+ if (record && typeof record === 'object' && key in record) {
+ return (record as any)[key];
+ }
+ if (Array.isArray(record) && typeof fallbackIndex === 'number') {
+ return record[fallbackIndex];
+ }
+ return undefined;
+}
+
+function fieldIndex(collection: ThsCollection, field: ThsField): number {
+ const idx = (collection.fields as any[]).findIndex((candidate) => candidate && candidate.name === field.name);
+ return 9 + Math.max(0, idx);
+}
+
+function recordLabel(collection: ThsCollection, id: bigint, record: any): string {
+ const display = displayField(collection);
+ if (!display) return `${collection.name} #${String(id)}`;
+ const raw = getRecordValue(record, display.name, fieldIndex(collection, display));
+ const rendered = formatFieldValue(raw, display.type, display.decimals, display.name).trim();
+ if (!rendered) return `${collection.name} #${String(id)}`;
+ return rendered;
+}
+
+export default function ResolvedReferenceValue(props: {
+ collection: ThsCollection;
+ field: ThsField;
+ value: unknown;
+ abi: any[] | null;
+ publicClient: any | null;
+ address: `0x${string}` | undefined;
+ fallback?: string;
+}) {
+ const { collection, field, value, abi, publicClient, address, fallback } = props;
+ const relatedCollection = useMemo(() => getRelatedCollection(collection, field.name), [collection, field.name]);
+ const [label, setLabel] = useState(null);
+
+ const id = useMemo(() => {
+ if (value === null || value === undefined || value === '') return null;
+ try {
+ return typeof value === 'bigint' ? value : BigInt(String(value));
+ } catch {
+ return null;
+ }
+ }, [value]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function loadLabel() {
+ if (!relatedCollection || !abi || !publicClient || !address || id === null) {
+ setLabel(null);
+ return;
+ }
+
+ try {
+ assertAbiFunction(abi, fnGet(relatedCollection.name), relatedCollection.name);
+ const record = await publicClient.readContract({
+ address,
+ abi,
+ functionName: fnGet(relatedCollection.name),
+ args: [id]
+ });
+ if (cancelled) return;
+ setLabel(recordLabel(relatedCollection, id, record));
+ } catch {
+ if (!cancelled) setLabel(null);
+ }
+ }
+
+ void loadLabel();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [abi, address, id, publicClient, relatedCollection]);
+
+ const renderedFallback = fallback && fallback.trim() ? fallback : id === null ? '—' : `#${String(id)}`;
+ if (!relatedCollection || id === null) {
+ return {renderedFallback} ;
+ }
+
+ return (
+
+ {label ? `${label} (#${String(id)})` : `${relatedCollection.name} ${renderedFallback}`}
+
+ );
+}
diff --git a/packages/templates/next-export-ui/src/lib/ths.ts b/packages/templates/next-export-ui/src/lib/ths.ts
index cc01f95..3cd4bfd 100644
--- a/packages/templates/next-export-ui/src/lib/ths.ts
+++ b/packages/templates/next-export-ui/src/lib/ths.ts
@@ -39,6 +39,13 @@ export interface PaymentRule {
amountWei: string;
}
+export interface ThsRelation {
+ field: string;
+ to: string;
+ enforce?: boolean;
+ reverseIndex?: boolean;
+}
+
export interface ThsCollection {
name: string;
plural?: string;
@@ -60,7 +67,7 @@ export interface ThsCollection {
transferRules?: {
access: Access;
};
- relations?: Array>;
+ relations?: ThsRelation[];
indexes?: Record;
ui?: Record;
}
@@ -95,6 +102,17 @@ export function getField(collection: ThsCollection, fieldName: string): ThsField
return (collection.fields as any[]).find((f) => f && f.name === fieldName) ?? null;
}
+export function getRelationForField(collection: ThsCollection, fieldName: string): ThsRelation | null {
+ const relations = Array.isArray(collection.relations) ? collection.relations : [];
+ return relations.find((relation) => relation && relation.field === fieldName) ?? null;
+}
+
+export function getRelatedCollection(collection: ThsCollection, fieldName: string): ThsCollection | null {
+ const relation = getRelationForField(collection, fieldName);
+ if (!relation?.to) return null;
+ return getCollection(relation.to);
+}
+
export function displayField(collection: ThsCollection): ThsField | null {
// Prefer first required field, else first string-like, else first.
const required = Array.isArray(collection.createRules?.required) ? collection.createRules.required : [];
diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js
index ea6829e..fbbfbe9 100644
--- a/test/testCliGenerateUi.js
+++ b/test/testCliGenerateUi.js
@@ -143,6 +143,44 @@ function schemaWithUiOverrides() {
};
}
+function schemaWithReferenceRelation() {
+ return {
+ thsVersion: '2025-12',
+ schemaVersion: '0.0.1',
+ app: {
+ name: 'Reference App',
+ slug: 'reference-app',
+ features: { uploads: false, onChainIndexing: true }
+ },
+ collections: [
+ {
+ name: 'Profile',
+ fields: [{ name: 'handle', type: 'string', required: true }],
+ createRules: { required: ['handle'], access: 'public' },
+ visibilityRules: { gets: ['handle'], access: 'public' },
+ updateRules: { mutable: ['handle'], access: 'owner' },
+ deleteRules: { softDelete: true, access: 'owner' },
+ transferRules: { access: 'owner' },
+ indexes: { unique: [{ field: 'handle', scope: 'allTime' }], index: [] }
+ },
+ {
+ name: 'Post',
+ fields: [
+ { name: 'authorProfile', type: 'reference', required: true },
+ { name: 'body', type: 'string', required: true }
+ ],
+ createRules: { required: ['authorProfile', 'body'], access: 'public' },
+ visibilityRules: { gets: ['authorProfile', 'body'], access: 'public' },
+ updateRules: { mutable: ['body'], access: 'owner' },
+ deleteRules: { softDelete: true, access: 'owner' },
+ transferRules: { access: 'owner' },
+ indexes: { unique: [], index: [{ field: 'authorProfile' }] },
+ relations: [{ field: 'authorProfile', to: 'Profile', enforce: true, reverseIndex: true }]
+ }
+ ]
+ };
+}
+
describe('th generate (UI template)', function () {
this.timeout(180000);
@@ -225,6 +263,43 @@ describe('th generate (UI template)', function () {
expect(generatedTokens).to.equal(readTemplateThemeTokens());
});
+ it('upstreams relation metadata into reference-aware generated CRUD UI', function () {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-ui-reference-'));
+ const schemaPath = path.join(dir, 'schema.json');
+ const outDir = path.join(dir, 'out');
+ writeJson(schemaPath, schemaWithReferenceRelation());
+
+ const res = runTh(['generate', schemaPath, '--out', outDir], process.cwd());
+ expect(res.status, res.stderr || res.stdout).to.equal(0);
+
+ const generatedThs = fs.readFileSync(path.join(outDir, 'ui', 'src', 'generated', 'ths.ts'), 'utf-8');
+ expect(generatedThs).to.include('"relations"');
+ expect(generatedThs).to.include('"authorProfile"');
+ expect(generatedThs).to.include('"to": "Profile"');
+
+ const generatedReferenceField = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ReferenceFieldInput.tsx'), 'utf-8');
+ expect(generatedReferenceField).to.include("window.localStorage.getItem('TH_ACCOUNT')");
+ expect(generatedReferenceField).to.include('listAllRecords');
+ expect(generatedReferenceField).to.include('Owned records appear first');
+
+ const generatedResolvedReference = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ResolvedReferenceValue.tsx'), 'utf-8');
+ expect(generatedResolvedReference).to.include('getRelatedCollection');
+ expect(generatedResolvedReference).to.include("href={`/${relatedCollection.name}/?mode=view&id=${String(id)}`}");
+
+ const generatedNewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'new', 'ClientPage.tsx'), 'utf-8');
+ expect(generatedNewPage).to.include('ReferenceFieldInput');
+ expect(generatedNewPage).to.include("f.type === 'reference'");
+
+ const generatedEditPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'edit', 'ClientPage.tsx'), 'utf-8');
+ expect(generatedEditPage).to.include('ReferenceFieldInput');
+
+ const generatedViewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'view', 'ClientPage.tsx'), 'utf-8');
+ expect(generatedViewPage).to.include('ResolvedReferenceValue');
+
+ const generatedRecordCard = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'RecordCard.tsx'), 'utf-8');
+ expect(generatedRecordCard).to.include('ResolvedReferenceValue');
+ });
+
it('generated UI builds (next export)', function () {
this.timeout(180000);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-ui-build-'));
From 5558055080b528d467f26a2360512cf948c79b89 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 09:35:18 -1000
Subject: [PATCH 02/24] Upstream upload and relationship UX patterns
---
.../example/microblog-ui/src/lib/microblog.ts | 65 ++---------
.../app/[collection]/edit/ClientPage.tsx | 11 +-
.../app/[collection]/new/ClientPage.tsx | 11 +-
.../templates/next-export-ui/app/page.tsx | 43 ++++++++
.../src/components/ImageFieldInput.tsx | 36 +++++-
.../next-export-ui/src/lib/relations.ts | 104 ++++++++++++++++++
.../next-export-ui/src/lib/upload.ts | 51 ++++++++-
test/testCliGenerateUi.js | 10 ++
8 files changed, 271 insertions(+), 60 deletions(-)
create mode 100644 packages/templates/next-export-ui/src/lib/relations.ts
diff --git a/apps/example/microblog-ui/src/lib/microblog.ts b/apps/example/microblog-ui/src/lib/microblog.ts
index 4cec4b8..213e5ff 100644
--- a/apps/example/microblog-ui/src/lib/microblog.ts
+++ b/apps/example/microblog-ui/src/lib/microblog.ts
@@ -1,6 +1,6 @@
'use client';
-import { readRecordsByIds } from './app';
+import { getRecordId, listOwnedRecords, recordOwner, resolveReferenceRecords } from './relations';
import { listAllRecords, loadAppRuntime, type AppRuntime } from './runtime';
export type ProfileRecord = {
@@ -15,23 +15,6 @@ export type FeedItem = {
authorProfile: any | null;
};
-export function getRecordId(value: unknown): bigint | null {
- if (typeof value === 'bigint') return value;
- if (typeof value === 'number' && Number.isInteger(value)) return BigInt(value);
- if (typeof value === 'string' && value.trim()) {
- try {
- return BigInt(value);
- } catch {
- return null;
- }
- }
- return null;
-}
-
-export function recordOwner(record: any): string {
- return String(record?.owner ?? '').trim().toLowerCase();
-}
-
export function profileDisplayName(profile: any): string {
const displayName = String(profile?.displayName ?? '').trim();
const handle = String(profile?.handle ?? '').trim();
@@ -71,45 +54,21 @@ export async function listProfiles(runtime: AppRuntime): Promise {
- const normalizedOwner = ownerAddress.trim().toLowerCase();
- const profiles = await listProfiles(runtime);
- return profiles.filter((entry) => recordOwner(entry.record) === normalizedOwner);
-}
-
-export async function loadProfilesByIds(runtime: AppRuntime, ids: bigint[]): Promise> {
- const uniqueIds = Array.from(new Set(ids.map((id) => String(id)))).map((id) => BigInt(id));
- if (!uniqueIds.length) return new Map();
-
- const records = await readRecordsByIds({
- publicClient: runtime.publicClient,
- abi: runtime.abi,
- address: runtime.appAddress,
- collectionName: 'Profile',
- ids: uniqueIds
- });
-
- const out = new Map();
- uniqueIds.forEach((id, index) => {
- out.set(String(id), records[index] ?? null);
- });
- return out;
+ return listOwnedRecords(runtime, 'Profile', ownerAddress);
}
export async function resolveFeedItemsWithProfiles(runtime: AppRuntime, items: Array<{ id: bigint; record: any }>): Promise {
- const profileIds = items
- .map((item) => extractAuthorProfileId(item.record))
- .filter((value): value is bigint => value !== null);
-
- const profilesById = await loadProfilesByIds(runtime, profileIds);
-
- return items.map((item) => {
- const authorProfileId = extractAuthorProfileId(item.record);
- return {
- ...item,
- authorProfileId,
- authorProfile: authorProfileId ? profilesById.get(String(authorProfileId)) ?? null : null
- };
+ const resolved = await resolveReferenceRecords(runtime, items, {
+ fieldName: 'authorProfile',
+ targetCollectionName: 'Profile'
});
+
+ return resolved.map((item) => ({
+ id: item.id,
+ record: item.record,
+ authorProfileId: item.referenceId,
+ authorProfile: item.referenceRecord
+ }));
}
export async function loadMicroblogRuntime(): Promise {
diff --git a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
index ba6a1b1..4ed4fe4 100644
--- a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
@@ -59,6 +59,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
const [record, setRecord] = useState(null);
const [form, setForm] = useState>({});
+ const [busyUploads, setBusyUploads] = useState>({});
const id = useMemo(() => {
if (!idParam) return null;
@@ -104,6 +105,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
const fields = collection ? mutableFields(collection) : [];
const optimistic = Boolean((collection as any)?.updateRules?.optimisticConcurrency);
+ const uploadBusy = Object.values(busyUploads).some(Boolean);
async function fetchRecord(options?: { initial?: boolean }) {
if (!publicClient || !abi || !appAddress || id === null) return;
@@ -153,6 +155,10 @@ export default function EditRecordPage(props: { params: { collection: string } }
async function submit() {
if (!manifest || !deployment || !abi || !publicClient || !appAddress || id === null) return;
if (!record) return;
+ if (uploadBusy) {
+ setError('Wait for media uploads to finish before saving this record.');
+ return;
+ }
setError(null);
setStatus(null);
@@ -315,6 +321,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
value={form[f.name] ?? ''}
disabled={txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'}
onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))}
+ onBusyChange={(busy) => setBusyUploads((prev) => ({ ...prev, [f.name]: busy }))}
/>
) : f.type === 'reference' ? (
void submit()}
- disabled={!abi || !publicClient || !appAddress || txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'}
+ disabled={!abi || !publicClient || !appAddress || uploadBusy || txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'}
>
- Save
+ {uploadBusy ? 'Waiting for media upload…' : 'Save'}
router.push(`/${collectionName}/?mode=view&id=${String(id)}`)}>Cancel
diff --git a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
index 5a3e502..79b81d5 100644
--- a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
@@ -40,6 +40,7 @@ export default function CreateRecordPage(props: { params: { collection: string }
const [publicClient, setPublicClient] = useState(null);
const [form, setForm] = useState>({});
+ const [busyUploads, setBusyUploads] = useState>({});
useEffect(() => {
async function boot() {
@@ -106,6 +107,7 @@ export default function CreateRecordPage(props: { params: { collection: string }
const fields = createFields(collection);
const required = requiredFieldNames(collection);
const payment = hasCreatePayment(collection);
+ const uploadBusy = Object.values(busyUploads).some(Boolean);
async function submit() {
if (!manifest || !deployment || !abi || !publicClient || !appAddress) return;
@@ -113,6 +115,10 @@ export default function CreateRecordPage(props: { params: { collection: string }
setError('App is not deployed yet (manifest has 0x0 address).');
return;
}
+ if (uploadBusy) {
+ setError('Wait for media uploads to finish before submitting this record.');
+ return;
+ }
setError(null);
setStatus(null);
@@ -210,6 +216,7 @@ export default function CreateRecordPage(props: { params: { collection: string }
manifest={manifest}
value={form[f.name] ?? ''}
onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))}
+ onBusyChange={(busy) => setBusyUploads((prev) => ({ ...prev, [f.name]: busy }))}
/>
) : f.type === 'reference' ? (
void submit()}
- disabled={!abi || !publicClient || !appAddress || txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'}
+ disabled={!abi || !publicClient || !appAddress || uploadBusy || txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'}
>
- Create
+ {uploadBusy ? 'Waiting for media upload…' : 'Create'}
router.push(`/${collectionName}/`)}>Cancel
diff --git a/packages/templates/next-export-ui/app/page.tsx b/packages/templates/next-export-ui/app/page.tsx
index ce0f4ec..955b391 100644
--- a/packages/templates/next-export-ui/app/page.tsx
+++ b/packages/templates/next-export-ui/app/page.tsx
@@ -8,6 +8,9 @@ export default function HomePage() {
const editableCollections = ths.collections.filter((collection) => mutableFields(collection).length > 0).length;
const transferCollections = ths.collections.filter((collection) => transferEnabled(collection)).length;
const paidCollections = ths.collections.filter((collection) => Boolean(hasCreatePayment(collection))).length;
+ const totalRelations = ths.collections.reduce((sum, collection) => sum + (Array.isArray(collection.relations) ? collection.relations.length : 0), 0);
+ const indexedCollections = ths.collections.filter((collection) => Array.isArray((collection as any).indexes?.index) && (collection as any).indexes.index.length > 0).length;
+ const imageCollections = ths.collections.filter((collection) => collection.fields.some((field) => field.type === 'image')).length;
return (
@@ -57,6 +60,9 @@ export default function HomePage() {
paid creates: {paidCollections}
+ relations: {totalRelations}
+ indexed collections: {indexedCollections}
+ media collections: {imageCollections}
schema {ths.schemaVersion}
@@ -71,6 +77,13 @@ export default function HomePage() {
The generated app reads /.well-known/tokenhost/manifest.json at runtime, so deployment metadata stays outside the bundle.
+
+
/relationships
+
Reference-aware by default
+
+ Generated forms can resolve related records, prefer owned identities when available, and render linked labels instead of exposing raw foreign keys everywhere.
+
+
/wallet
Public reads, wallet-native writes
@@ -78,6 +91,13 @@ export default function HomePage() {
Read-only pages use the deployment chain's public RPC when available, so browsing does not require MetaMask and does not depend on the wallet being on the right network. Create, update, delete, and transfer flows still use the wallet with clean chain and transaction feedback.
+
+
/uploads
+
Long-running media flows
+
+ Upload fields expose progress, processing, retry, and submit-blocking states so generated apps can handle remote media workflows without custom glue.
+
+
/hosting
Self-hostable release
@@ -122,6 +142,9 @@ export default function HomePage() {
create {collection.createRules.access}
{transferEnabled(collection) ? 'transfer on' : 'transfer off'}
{payment ?
paid create : null}
+ {collection.fields.some((field) => field.type === 'reference') ?
reference-aware : null}
+ {collection.fields.some((field) => field.type === 'image') ?
media upload : null}
+ {Array.isArray((collection as any).indexes?.index) && (collection as any).indexes.index.length > 0 ?
indexed queries : null}
@@ -133,6 +156,26 @@ export default function HomePage() {
{collection.fields.length > fieldPreview.length ? +{collection.fields.length - fieldPreview.length} more : null}
+ {Array.isArray(collection.relations) && collection.relations.length > 0 ? (
+
+ {collection.relations.map((relation) => (
+
+ {relation.field} → {relation.to}
+
+ ))}
+
+ ) : null}
+
+ {Array.isArray((collection as any).indexes?.index) && (collection as any).indexes.index.length > 0 ? (
+
+ {(collection as any).indexes.index.map((index: any) => (
+
+ {String(index?.field ?? 'field')} {String(index?.mode ?? 'equality')}
+
+ ))}
+
+ ) : null}
+
Browse
Create
diff --git a/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx b/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx
index 5c3e2e3..346538d 100644
--- a/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx
+++ b/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx
@@ -2,7 +2,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
-import { getUploadConfig, uploadFile } from '../lib/upload';
+import { getUploadConfig, uploadFile, type UploadStateUpdate } from '../lib/upload';
export default function ImageFieldInput(props: {
manifest: any | null;
@@ -18,7 +18,9 @@ export default function ImageFieldInput(props: {
const [progress, setProgress] = useState(0);
const [error, setError] = useState(null);
const [status, setStatus] = useState(null);
+ const [phase, setPhase] = useState('requesting');
const [localPreviewUrl, setLocalPreviewUrl] = useState(null);
+ const [lastSelectedFile, setLastSelectedFile] = useState(null);
const previewUrl = localPreviewUrl || value || '';
useEffect(() => {
@@ -28,6 +30,7 @@ export default function ImageFieldInput(props: {
}, [localPreviewUrl]);
async function handleFile(file: File) {
+ setLastSelectedFile(file);
setError(null);
setStatus(null);
setProgress(0);
@@ -42,13 +45,23 @@ export default function ImageFieldInput(props: {
setLocalPreviewUrl(objectUrl);
setBusy(true);
onBusyChange?.(true);
+ setPhase('requesting');
setStatus(`Uploading via ${config.runnerMode}…`);
try {
const uploaded = await uploadFile({
manifest,
file,
- onProgress: setProgress
+ onProgress: setProgress,
+ onStateChange: (next) => {
+ setPhase(next.phase);
+ setProgress(next.progress);
+ setStatus(
+ next.elapsedMs && next.phase === 'processing'
+ ? `${next.message} ${Math.max(1, Math.round(next.elapsedMs / 1000))}s elapsed.`
+ : next.message
+ );
+ }
});
onChange(uploaded.url);
setStatus(uploaded.cid ? `Uploaded (${uploaded.cid.slice(0, 12)}…).` : 'Uploaded.');
@@ -88,6 +101,16 @@ export default function ImageFieldInput(props: {
>
Remove
+ {
+ if (lastSelectedFile) void handleFile(lastSelectedFile);
+ }}
+ >
+ Retry
+
+ {config ? (
+
+ {busy
+ ? phase === 'processing' || phase === 'accepted'
+ ? `Long-running upload: Token Host is finalizing this media item via ${config.provider || config.runnerMode}.`
+ : `Upload in progress via ${config.provider || config.runnerMode}.`
+ : `Uploads run via ${config.provider || config.runnerMode}.`}
+
+ ) : null}
{status ? {status}
: null}
{error ? {error}
: null}
diff --git a/packages/templates/next-export-ui/src/lib/relations.ts b/packages/templates/next-export-ui/src/lib/relations.ts
new file mode 100644
index 0000000..d4ad16f
--- /dev/null
+++ b/packages/templates/next-export-ui/src/lib/relations.ts
@@ -0,0 +1,104 @@
+'use client';
+
+import { readRecordsByIds } from './app';
+import { displayField, getCollection } from './ths';
+import { formatFieldValue } from './format';
+import type { AppRuntime } from './runtime';
+import { listAllRecords } from './runtime';
+
+export type OwnedRecord = {
+ id: bigint;
+ record: any;
+};
+
+export type ResolvedReferenceItem = {
+ id: bigint;
+ record: any;
+ referenceId: bigint | null;
+ referenceRecord: any | null;
+};
+
+export function getRecordId(value: unknown): bigint | null {
+ if (typeof value === 'bigint') return value;
+ if (typeof value === 'number' && Number.isInteger(value)) return BigInt(value);
+ if (typeof value === 'string' && value.trim()) {
+ try {
+ return BigInt(value);
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+export function recordOwner(record: any): string {
+ return String(record?.owner ?? '').trim().toLowerCase();
+}
+
+export function relatedRecordLabel(collectionName: string, id: bigint, record: any): string {
+ const collection = getCollection(collectionName);
+ if (!collection) return `${collectionName} #${String(id)}`;
+
+ const field = displayField(collection);
+ if (!field) return `${collection.name} #${String(id)}`;
+
+ const raw = record?.[field.name];
+ const rendered = formatFieldValue(raw, field.type, field.decimals, field.name).trim();
+ if (!rendered) return `${collection.name} #${String(id)}`;
+ return `${rendered} (#${String(id)})`;
+}
+
+export async function listOwnedRecords(runtime: AppRuntime, collectionName: string, ownerAddress: string): Promise {
+ const normalizedOwner = ownerAddress.trim().toLowerCase();
+ const page = await listAllRecords({
+ manifest: runtime.manifest,
+ publicClient: runtime.publicClient,
+ abi: runtime.abi,
+ address: runtime.appAddress,
+ collectionName
+ });
+
+ return page.ids
+ .map((id, index) => ({ id, record: page.records[index] }))
+ .filter((entry) => recordOwner(entry.record) === normalizedOwner);
+}
+
+export async function loadRecordsByIds(runtime: AppRuntime, collectionName: string, ids: bigint[]): Promise> {
+ const uniqueIds = Array.from(new Set(ids.map((id) => String(id)))).map((id) => BigInt(id));
+ if (!uniqueIds.length) return new Map();
+
+ const records = await readRecordsByIds({
+ publicClient: runtime.publicClient,
+ abi: runtime.abi,
+ address: runtime.appAddress,
+ collectionName,
+ ids: uniqueIds
+ });
+
+ const out = new Map();
+ uniqueIds.forEach((id, index) => {
+ out.set(String(id), records[index] ?? null);
+ });
+ return out;
+}
+
+export async function resolveReferenceRecords(
+ runtime: AppRuntime,
+ items: Array<{ id: bigint; record: any }>,
+ options: { fieldName: string; targetCollectionName: string }
+): Promise {
+ const referenceIds = items
+ .map((item) => getRecordId(item.record?.[options.fieldName]))
+ .filter((value): value is bigint => value !== null);
+
+ const recordsById = await loadRecordsByIds(runtime, options.targetCollectionName, referenceIds);
+
+ return items.map((item) => {
+ const referenceId = getRecordId(item.record?.[options.fieldName]);
+ return {
+ ...item,
+ referenceId,
+ referenceRecord: referenceId ? recordsById.get(String(referenceId)) ?? null : null
+ };
+ });
+}
diff --git a/packages/templates/next-export-ui/src/lib/upload.ts b/packages/templates/next-export-ui/src/lib/upload.ts
index 1b9e716..3326533 100644
--- a/packages/templates/next-export-ui/src/lib/upload.ts
+++ b/packages/templates/next-export-ui/src/lib/upload.ts
@@ -22,6 +22,16 @@ export type UploadConfig = {
maxBytes: number | null;
};
+export type UploadPhase = 'requesting' | 'accepted' | 'processing' | 'completed' | 'failed';
+
+export type UploadStateUpdate = {
+ phase: UploadPhase;
+ message: string;
+ progress: number;
+ jobId?: string | null;
+ elapsedMs?: number;
+};
+
type PendingUploadResponse = {
ok: true;
pending: true;
@@ -97,6 +107,7 @@ export async function uploadFile(args: {
manifest: any;
file: File;
onProgress?: (percent: number) => void;
+ onStateChange?: (state: UploadStateUpdate) => void;
}): Promise {
const config = getUploadConfig(args.manifest);
if (!config) throw new Error('Uploads are not enabled for this app.');
@@ -116,14 +127,27 @@ export async function uploadFile(args: {
return await new Promise((resolve, reject) => {
let settled = false;
+ const notify = (state: UploadStateUpdate) => {
+ args.onStateChange?.(state);
+ };
const finishResolve = (value: UploadResult) => {
if (settled) return;
settled = true;
+ notify({
+ phase: 'completed',
+ message: value.cid ? `Upload completed (${value.cid.slice(0, 12)}…).` : 'Upload completed.',
+ progress: 100
+ });
resolve(value);
};
const finishReject = (error: Error) => {
if (settled) return;
settled = true;
+ notify({
+ phase: 'failed',
+ message: error.message || 'Upload failed.',
+ progress: 100
+ });
reject(error);
};
const xhr = new XMLHttpRequest();
@@ -137,9 +161,21 @@ export async function uploadFile(args: {
xhr.setRequestHeader('X-TokenHost-Upload-Mode', 'async');
}
+ notify({
+ phase: 'requesting',
+ message: `Uploading ${args.file.name || 'file'} to Token Host…`,
+ progress: 0
+ });
+
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable || !args.onProgress) return;
- args.onProgress(Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100))));
+ const percent = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)));
+ args.onProgress(percent);
+ notify({
+ phase: 'requesting',
+ message: `Uploading ${args.file.name || 'file'} to Token Host…`,
+ progress: percent
+ });
};
async function pollUploadJob(statusUrl: string): Promise {
@@ -156,6 +192,13 @@ export async function uploadFile(args: {
}
if (body?.pending) {
args.onProgress?.(100);
+ notify({
+ phase: 'processing',
+ message: `Token Host accepted the upload and is processing it via ${config.runnerMode}. This can take a minute or two.`,
+ progress: 100,
+ jobId: body?.jobId ? String(body.jobId) : null,
+ elapsedMs: Date.now() - startedAt
+ });
continue;
}
if (!body?.ok || !body?.upload?.url) {
@@ -189,6 +232,12 @@ export async function uploadFile(args: {
if (xhr.status === 202 && body?.pending && body?.jobId) {
const pending = body as PendingUploadResponse;
+ notify({
+ phase: 'accepted',
+ message: `Upload accepted. Token Host is processing it via ${config.runnerMode}.`,
+ progress: 100,
+ jobId: pending.jobId
+ });
const statusUrl = normalizeUrl(pending.statusUrl || '', `${config.statusUrl}?jobId=${encodeURIComponent(pending.jobId)}`);
const completed = await pollUploadJob(statusUrl);
finishResolve(completed);
diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js
index fbbfbe9..33911c9 100644
--- a/test/testCliGenerateUi.js
+++ b/test/testCliGenerateUi.js
@@ -234,10 +234,14 @@ describe('th generate (UI template)', function () {
const generatedImageField = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ImageFieldInput.tsx'), 'utf-8');
expect(generatedImageField).to.include('onBusyChange');
+ expect(generatedImageField).to.include('Long-running upload');
+ expect(generatedImageField).to.include('Retry');
const generatedUpload = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'upload.ts'), 'utf-8');
expect(generatedUpload).to.include('buildUploadNetworkError');
expect(generatedUpload).to.include('xhr.timeout = 5 * 60 * 1000;');
+ expect(generatedUpload).to.include("phase: 'accepted'");
+ expect(generatedUpload).to.include('Token Host accepted the upload');
const generatedClients = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'clients.ts'), 'utf-8');
expect(generatedClients).to.include('async function refreshWalletChainConfig');
@@ -286,12 +290,18 @@ describe('th generate (UI template)', function () {
expect(generatedResolvedReference).to.include('getRelatedCollection');
expect(generatedResolvedReference).to.include("href={`/${relatedCollection.name}/?mode=view&id=${String(id)}`}");
+ const generatedRelations = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'relations.ts'), 'utf-8');
+ expect(generatedRelations).to.include('resolveReferenceRecords');
+ expect(generatedRelations).to.include('listOwnedRecords');
+
const generatedNewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'new', 'ClientPage.tsx'), 'utf-8');
expect(generatedNewPage).to.include('ReferenceFieldInput');
expect(generatedNewPage).to.include("f.type === 'reference'");
+ expect(generatedNewPage).to.include('Waiting for media upload…');
const generatedEditPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'edit', 'ClientPage.tsx'), 'utf-8');
expect(generatedEditPage).to.include('ReferenceFieldInput');
+ expect(generatedEditPage).to.include('Waiting for media upload…');
const generatedViewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'view', 'ClientPage.tsx'), 'utf-8');
expect(generatedViewPage).to.include('ResolvedReferenceValue');
From c00875b1bce84be72f23600787236f56a9177d19 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 09:50:07 -1000
Subject: [PATCH 03/24] Upstream owned identity selection
---
.../src/components/MicroblogComposeClient.tsx | 135 ++++++---------
.../src/components/ReferenceFieldInput.tsx | 153 ++++++-----------
.../next-export-ui/src/lib/relations.ts | 162 ++++++++++++++++++
test/testCliGenerateUi.js | 6 +-
4 files changed, 263 insertions(+), 193 deletions(-)
diff --git a/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx b/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx
index 3676518..b8e3fea 100644
--- a/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx
+++ b/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx
@@ -5,12 +5,15 @@ import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import ImageFieldInput from './ImageFieldInput';
+import ReferenceFieldInput from './ReferenceFieldInput';
import TxStatus, { type TxPhase } from './TxStatus';
import { fnCreate } from '../lib/app';
import { chainWithRpcOverride, requestWalletAddress } from '../lib/clients';
import { getReadRpcUrl } from '../lib/manifest';
+import { useOwnedReferenceOptions } from '../lib/relations';
+import { getCollection, getField } from '../lib/ths';
import { submitWriteTx } from '../lib/tx';
-import { listOwnedProfiles, loadMicroblogRuntime, profileHandle, profileLabel, type ProfileRecord } from '../lib/microblog';
+import { loadMicroblogRuntime } from '../lib/microblog';
type ComposeState = {
loading: boolean;
@@ -19,8 +22,6 @@ type ComposeState = {
submitError: string | null;
};
-const PROFILE_STORAGE_PREFIX = 'TH_MICROBLOG_PROFILE_ID:';
-
export default function MicroblogComposeClient() {
const router = useRouter();
const [state, setState] = useState({
@@ -31,7 +32,6 @@ export default function MicroblogComposeClient() {
});
const [runtime, setRuntime] = useState(null);
const [account, setAccount] = useState(null);
- const [profiles, setProfiles] = useState([]);
const [selectedProfileId, setSelectedProfileId] = useState('');
const [body, setBody] = useState('');
const [image, setImage] = useState('');
@@ -69,60 +69,44 @@ export default function MicroblogComposeClient() {
};
}, []);
+ const walletChain = useMemo(
+ () => (runtime ? chainWithRpcOverride(runtime.chain, getReadRpcUrl(runtime.manifest) || undefined) : null),
+ [runtime]
+ );
+ const ownedReference = useOwnedReferenceOptions(
+ runtime
+ ? {
+ manifest: runtime.manifest,
+ publicClient: runtime.publicClient,
+ abi: runtime.abi,
+ address: runtime.appAddress,
+ collectionName: 'Post',
+ fieldName: 'authorProfile',
+ value: selectedProfileId,
+ onChange: setSelectedProfileId
+ }
+ : {
+ manifest: null,
+ publicClient: null,
+ abi: [],
+ address: undefined as any,
+ collectionName: 'Post',
+ fieldName: 'authorProfile',
+ value: selectedProfileId,
+ onChange: setSelectedProfileId
+ }
+ );
+ const selectedProfile = ownedReference.selectedOption;
+ const postCollection = useMemo(() => getCollection('Post'), []);
+ const authorProfileField = useMemo(() => (postCollection ? getField(postCollection, 'authorProfile') : null), [postCollection]);
+
useEffect(() => {
- let cancelled = false;
if (!runtime || !account) {
- setProfiles([]);
setSelectedProfileId('');
return;
}
-
- setState((prev) => ({ ...prev, loading: true, connectError: null }));
- void (async () => {
- try {
- const ownedProfiles = await listOwnedProfiles(runtime, account);
- if (cancelled) return;
- setProfiles(ownedProfiles);
-
- let preferred = '';
- try {
- const stored = localStorage.getItem(`${PROFILE_STORAGE_PREFIX}${account.toLowerCase()}`) ?? '';
- if (stored && ownedProfiles.some((entry) => String(entry.id) === stored)) preferred = stored;
- } catch {
- // ignore
- }
- if (!preferred && ownedProfiles[0]) preferred = String(ownedProfiles[0].id);
- setSelectedProfileId(preferred);
- } catch (error: any) {
- if (cancelled) return;
- setState((prev) => ({ ...prev, connectError: String(error?.message ?? error) }));
- } finally {
- if (!cancelled) setState((prev) => ({ ...prev, loading: false }));
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, [runtime, account]);
-
- useEffect(() => {
- if (!account || !selectedProfileId) return;
- try {
- localStorage.setItem(`${PROFILE_STORAGE_PREFIX}${account.toLowerCase()}`, selectedProfileId);
- } catch {
- // ignore
- }
- }, [account, selectedProfileId]);
-
- const selectedProfile = useMemo(
- () => profiles.find((entry) => String(entry.id) === selectedProfileId) ?? null,
- [profiles, selectedProfileId]
- );
- const walletChain = useMemo(
- () => (runtime ? chainWithRpcOverride(runtime.chain, getReadRpcUrl(runtime.manifest) || undefined) : null),
- [runtime]
- );
+ setState((prev) => ({ ...prev, loading: ownedReference.loading, connectError: ownedReference.error }));
+ }, [account, ownedReference.error, ownedReference.loading, runtime]);
async function connectWallet() {
if (!walletChain) return;
@@ -233,7 +217,7 @@ export default function MicroblogComposeClient() {
Wallet linked
-
{profiles.length}
+
{ownedReference.ownedOptions.length}
Owned profiles
@@ -257,7 +241,7 @@ export default function MicroblogComposeClient() {
) : null}
- {account && !profiles.length ? (
+ {account && !ownedReference.ownedOptions.length ? (
/profiles/empty
No owned profiles found
@@ -269,7 +253,7 @@ export default function MicroblogComposeClient() {
) : null}
- {account && profiles.length ? (
+ {account && ownedReference.ownedOptions.length ? (
Compose Post
@@ -279,37 +263,16 @@ export default function MicroblogComposeClient() {
Profile
- setSelectedProfileId(event.target.value)}
- >
- {profiles.map((entry) => (
-
- {profileLabel(entry.record)}
-
- ))}
-
-
-
-
-
Current identity
-
- {selectedProfile ? (
-
-
- profile #{String(selectedProfile.id)}
- {profileHandle(selectedProfile.record) ? @{profileHandle(selectedProfile.record)} : null}
-
-
{profileLabel(selectedProfile.record)}
- {String(selectedProfile.record?.bio ?? '').trim() ? (
-
{String(selectedProfile.record.bio)}
- ) : null}
-
- ) : (
-
Select a profile.
- )}
-
+ onChange={setSelectedProfileId}
+ />
diff --git a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
index 66e1a4e..b2b5c6d 100644
--- a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
+++ b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
@@ -1,40 +1,10 @@
'use client';
-import React, { useEffect, useMemo, useState } from 'react';
+import React from 'react';
+import Link from 'next/link';
-import { listAllRecords } from '../lib/runtime';
-import { formatFieldValue } from '../lib/format';
-import { displayField, getRelatedCollection, type ThsCollection, type ThsField } from '../lib/ths';
-
-type ReferenceOption = {
- id: bigint;
- label: string;
- owned: boolean;
-};
-
-function getRecordValue(record: any, key: string, fallbackIndex?: number): any {
- if (record && typeof record === 'object' && key in record) {
- return (record as any)[key];
- }
- if (Array.isArray(record) && typeof fallbackIndex === 'number') {
- return record[fallbackIndex];
- }
- return undefined;
-}
-
-function fieldIndex(collection: ThsCollection, field: ThsField): number {
- const idx = (collection.fields as any[]).findIndex((candidate) => candidate && candidate.name === field.name);
- return 9 + Math.max(0, idx);
-}
-
-function recordLabel(collection: ThsCollection, id: bigint, record: any): string {
- const display = displayField(collection);
- if (!display) return `${collection.name} #${String(id)}`;
- const raw = getRecordValue(record, display.name, fieldIndex(collection, display));
- const rendered = formatFieldValue(raw, display.type, display.decimals, display.name).trim();
- if (!rendered) return `${collection.name} #${String(id)}`;
- return `${rendered} (#${String(id)})`;
-}
+import { recordSummary, useOwnedReferenceOptions } from '../lib/relations';
+import { type ThsCollection, type ThsField } from '../lib/ths';
export default function ReferenceFieldInput(props: {
manifest: any;
@@ -48,79 +18,20 @@ export default function ReferenceFieldInput(props: {
onChange: (next: string) => void;
}) {
const { manifest, publicClient, abi, address, collection, field, value, disabled, onChange } = props;
- const relatedCollection = useMemo(() => getRelatedCollection(collection, field.name), [collection, field.name]);
-
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState
(null);
- const [options, setOptions] = useState([]);
-
- useEffect(() => {
- let cancelled = false;
-
- async function loadOptions() {
- if (!relatedCollection || !publicClient || !abi || !address) {
- setOptions([]);
- setError(null);
- return;
- }
-
- setLoading(true);
- setError(null);
- try {
- const walletAccount =
- typeof window !== 'undefined' ? window.localStorage.getItem('TH_ACCOUNT')?.toLowerCase() ?? '' : '';
- const page = await listAllRecords({
- publicClient,
- abi,
- address,
- collectionName: relatedCollection.name,
- manifest
- });
-
- const nextOptions = page.ids
- .map((id, index) => {
- const record = page.records[index];
- if (!record) return null;
- const owner = String(getRecordValue(record, 'owner', 3) ?? '').toLowerCase();
- return {
- id,
- label: recordLabel(relatedCollection, id, record),
- owned: Boolean(walletAccount) && owner === walletAccount
- };
- })
- .filter(Boolean) as ReferenceOption[];
-
- nextOptions.sort((left, right) => {
- if (left.owned !== right.owned) return left.owned ? -1 : 1;
- if (left.label !== right.label) return left.label.localeCompare(right.label);
- return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
- });
-
- if (cancelled) return;
- setOptions(nextOptions);
-
- const ownedOptions = nextOptions.filter((option) => option.owned);
- if (!value && ownedOptions.length === 1) {
- onChange(String(ownedOptions[0]?.id ?? ''));
- }
- } catch (cause: any) {
- if (cancelled) return;
- setOptions([]);
- setError(String(cause?.message ?? cause));
- } finally {
- if (!cancelled) setLoading(false);
- }
- }
-
- void loadOptions();
-
- return () => {
- cancelled = true;
- };
- }, [abi, address, collection, field.name, manifest, onChange, publicClient, relatedCollection, value]);
+ const { account, loading, error, options, relatedCollection, ownedOptions, selectedOption } = useOwnedReferenceOptions({
+ manifest,
+ publicClient,
+ abi,
+ address,
+ collectionName: collection.name,
+ fieldName: field.name,
+ value,
+ onChange
+ });
const resolvedValue = value.trim();
const hasResolvedValue = resolvedValue !== '' && options.some((option) => String(option.id) === resolvedValue);
+ const selectedSummary = selectedOption ? recordSummary(selectedOption.record) : null;
if (!relatedCollection) {
return (
@@ -152,6 +63,30 @@ export default function ReferenceFieldInput(props: {
))}
+ {selectedSummary ? (
+
+
+
+ {relatedCollection.name} #{String(selectedOption?.id ?? '')}
+ {selectedSummary.subtitle ? {selectedSummary.subtitle} : null}
+
+
+ {selectedSummary.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+ {selectedSummary.title}
+ {selectedSummary.body ? {selectedSummary.body} : null}
+
+
+
+
+ ) : null}
0
- ? `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first.`
+ ? ownedOptions.length > 0
+ ? `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first and your last choice is remembered${account ? ` for ${account.slice(0, 6)}…` : ''}.`
+ : `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry.`
: `Create a ${relatedCollection.name} record first, or enter a record id manually.`}
+ {!loading && options.length === 0 ? (
+
+ Create {relatedCollection.name}
+ Browse {relatedCollection.name}
+
+ ) : null}
>
);
}
diff --git a/packages/templates/next-export-ui/src/lib/relations.ts b/packages/templates/next-export-ui/src/lib/relations.ts
index d4ad16f..ff877ca 100644
--- a/packages/templates/next-export-ui/src/lib/relations.ts
+++ b/packages/templates/next-export-ui/src/lib/relations.ts
@@ -1,5 +1,7 @@
'use client';
+import { useEffect, useMemo, useState } from 'react';
+
import { readRecordsByIds } from './app';
import { displayField, getCollection } from './ths';
import { formatFieldValue } from './format';
@@ -18,6 +20,13 @@ export type ResolvedReferenceItem = {
referenceRecord: any | null;
};
+export type ReferenceOption = {
+ id: bigint;
+ label: string;
+ owned: boolean;
+ record: any;
+};
+
export function getRecordId(value: unknown): bigint | null {
if (typeof value === 'bigint') return value;
if (typeof value === 'number' && Number.isInteger(value)) return BigInt(value);
@@ -48,6 +57,24 @@ export function relatedRecordLabel(collectionName: string, id: bigint, record: a
return `${rendered} (#${String(id)})`;
}
+export function recordSummary(record: any): { title: string; subtitle: string | null; imageUrl: string | null; body: string | null } {
+ const title =
+ String(record?.displayName ?? '').trim() ||
+ String(record?.title ?? '').trim() ||
+ String(record?.name ?? '').trim() ||
+ String(record?.handle ?? '').trim() ||
+ 'Unnamed record';
+
+ const subtitle = String(record?.handle ?? '').trim()
+ ? `@${String(record.handle).trim()}`
+ : String(record?.slug ?? '').trim() || null;
+
+ const imageUrl = String(record?.avatar ?? '').trim() || String(record?.image ?? '').trim() || null;
+ const body = String(record?.bio ?? '').trim() || String(record?.description ?? '').trim() || null;
+
+ return { title, subtitle, imageUrl, body };
+}
+
export async function listOwnedRecords(runtime: AppRuntime, collectionName: string, ownerAddress: string): Promise
{
const normalizedOwner = ownerAddress.trim().toLowerCase();
const page = await listAllRecords({
@@ -102,3 +129,138 @@ export async function resolveReferenceRecords(
};
});
}
+
+function selectionStorageKey(args: { collectionName: string; fieldName: string; account: string }) {
+ return `TH_REFERENCE_SELECTION:${args.collectionName}:${args.fieldName}:${args.account.toLowerCase()}`;
+}
+
+export function useOwnedReferenceOptions(args: {
+ manifest: any;
+ publicClient: any;
+ abi: any[];
+ address: `0x${string}`;
+ collectionName: string;
+ fieldName: string;
+ value: string;
+ onChange: (next: string) => void;
+}) {
+ const [account, setAccount] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [options, setOptions] = useState([]);
+
+ const collection = useMemo(() => getCollection(args.collectionName), [args.collectionName]);
+ const relation = useMemo(
+ () => (collection?.relations ?? []).find((entry) => entry.field === args.fieldName) ?? null,
+ [collection, args.fieldName]
+ );
+ const relatedCollection = useMemo(() => (relation?.to ? getCollection(relation.to) : null), [relation]);
+
+ useEffect(() => {
+ try {
+ setAccount(window.localStorage.getItem('TH_ACCOUNT'));
+ } catch {
+ setAccount(null);
+ }
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function load() {
+ if (!relatedCollection || !args.publicClient || !args.abi || !args.address) {
+ setOptions([]);
+ setError(null);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ try {
+ const page = await listAllRecords({
+ manifest: args.manifest,
+ publicClient: args.publicClient,
+ abi: args.abi,
+ address: args.address,
+ collectionName: relatedCollection.name
+ });
+
+ const normalizedAccount = account?.trim().toLowerCase() ?? '';
+ const nextOptions = page.ids
+ .map((id, index) => {
+ const record = page.records[index];
+ if (!record) return null;
+ return {
+ id,
+ label: relatedRecordLabel(relatedCollection.name, id, record),
+ owned: Boolean(normalizedAccount) && recordOwner(record) === normalizedAccount,
+ record
+ };
+ })
+ .filter(Boolean) as ReferenceOption[];
+
+ nextOptions.sort((left, right) => {
+ if (left.owned !== right.owned) return left.owned ? -1 : 1;
+ if (left.label !== right.label) return left.label.localeCompare(right.label);
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
+ });
+
+ if (cancelled) return;
+ setOptions(nextOptions);
+
+ const ownedOptions = nextOptions.filter((option) => option.owned);
+ if (!args.value && account) {
+ let preferred = '';
+ try {
+ preferred = window.localStorage.getItem(
+ selectionStorageKey({ collectionName: args.collectionName, fieldName: args.fieldName, account })
+ ) ?? '';
+ } catch {
+ preferred = '';
+ }
+ if (preferred && nextOptions.some((option) => String(option.id) === preferred)) {
+ args.onChange(preferred);
+ } else if (ownedOptions.length === 1) {
+ args.onChange(String(ownedOptions[0]?.id ?? ''));
+ }
+ }
+ } catch (cause: any) {
+ if (cancelled) return;
+ setOptions([]);
+ setError(String(cause?.message ?? cause));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ }
+
+ void load();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [account, args.abi, args.address, args.collectionName, args.fieldName, args.manifest, args.onChange, args.publicClient, args.value, relatedCollection]);
+
+ useEffect(() => {
+ if (!account || !args.value) return;
+ try {
+ window.localStorage.setItem(
+ selectionStorageKey({ collectionName: args.collectionName, fieldName: args.fieldName, account }),
+ args.value
+ );
+ } catch {
+ // ignore
+ }
+ }, [account, args.collectionName, args.fieldName, args.value]);
+
+ const selectedOption = options.find((option) => String(option.id) === args.value) ?? null;
+
+ return {
+ account,
+ loading,
+ error,
+ options,
+ relatedCollection,
+ ownedOptions: options.filter((option) => option.owned),
+ selectedOption
+ };
+}
diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js
index 33911c9..f1cd659 100644
--- a/test/testCliGenerateUi.js
+++ b/test/testCliGenerateUi.js
@@ -282,9 +282,9 @@ describe('th generate (UI template)', function () {
expect(generatedThs).to.include('"to": "Profile"');
const generatedReferenceField = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ReferenceFieldInput.tsx'), 'utf-8');
- expect(generatedReferenceField).to.include("window.localStorage.getItem('TH_ACCOUNT')");
- expect(generatedReferenceField).to.include('listAllRecords');
+ expect(generatedReferenceField).to.include('useOwnedReferenceOptions');
expect(generatedReferenceField).to.include('Owned records appear first');
+ expect(generatedReferenceField).to.include("href={`/${relatedCollection.name}/?mode=new`}");
const generatedResolvedReference = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ResolvedReferenceValue.tsx'), 'utf-8');
expect(generatedResolvedReference).to.include('getRelatedCollection');
@@ -293,6 +293,8 @@ describe('th generate (UI template)', function () {
const generatedRelations = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'relations.ts'), 'utf-8');
expect(generatedRelations).to.include('resolveReferenceRecords');
expect(generatedRelations).to.include('listOwnedRecords');
+ expect(generatedRelations).to.include('useOwnedReferenceOptions');
+ expect(generatedRelations).to.include('TH_REFERENCE_SELECTION');
const generatedNewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'new', 'ClientPage.tsx'), 'utf-8');
expect(generatedNewPage).to.include('ReferenceFieldInput');
From 4fa7c2a10808421d09062f6916d02c9eb8eaff28 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 09:54:13 -1000
Subject: [PATCH 04/24] Use generated post create flow in microblog
---
.../src/components/MicroblogComposeClient.tsx | 326 ------------------
.../components/MicroblogPostRouteClient.tsx | 10 -
2 files changed, 336 deletions(-)
delete mode 100644 apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx
diff --git a/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx b/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx
deleted file mode 100644
index b8e3fea..0000000
--- a/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx
+++ /dev/null
@@ -1,326 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-import { useEffect, useMemo, useState } from 'react';
-import { useRouter } from 'next/navigation';
-
-import ImageFieldInput from './ImageFieldInput';
-import ReferenceFieldInput from './ReferenceFieldInput';
-import TxStatus, { type TxPhase } from './TxStatus';
-import { fnCreate } from '../lib/app';
-import { chainWithRpcOverride, requestWalletAddress } from '../lib/clients';
-import { getReadRpcUrl } from '../lib/manifest';
-import { useOwnedReferenceOptions } from '../lib/relations';
-import { getCollection, getField } from '../lib/ths';
-import { submitWriteTx } from '../lib/tx';
-import { loadMicroblogRuntime } from '../lib/microblog';
-
-type ComposeState = {
- loading: boolean;
- runtimeError: string | null;
- connectError: string | null;
- submitError: string | null;
-};
-
-export default function MicroblogComposeClient() {
- const router = useRouter();
- const [state, setState] = useState({
- loading: true,
- runtimeError: null,
- connectError: null,
- submitError: null
- });
- const [runtime, setRuntime] = useState(null);
- const [account, setAccount] = useState(null);
- const [selectedProfileId, setSelectedProfileId] = useState('');
- const [body, setBody] = useState('');
- const [image, setImage] = useState('');
- const [imageUploadBusy, setImageUploadBusy] = useState(false);
- const [txStatus, setTxStatus] = useState(null);
- const [txPhase, setTxPhase] = useState('idle');
- const [txHash, setTxHash] = useState(null);
-
- useEffect(() => {
- let cancelled = false;
-
- (async () => {
- try {
- const loadedRuntime = await loadMicroblogRuntime();
- if (cancelled) return;
- setRuntime(loadedRuntime);
-
- try {
- const cached = localStorage.getItem('TH_ACCOUNT');
- if (cached && !cancelled) setAccount(cached);
- } catch {
- // ignore
- }
- } catch (error: any) {
- if (cancelled) return;
- setState((prev) => ({ ...prev, runtimeError: String(error?.message ?? error), loading: false }));
- return;
- }
-
- if (!cancelled) setState((prev) => ({ ...prev, loading: false }));
- })();
-
- return () => {
- cancelled = true;
- };
- }, []);
-
- const walletChain = useMemo(
- () => (runtime ? chainWithRpcOverride(runtime.chain, getReadRpcUrl(runtime.manifest) || undefined) : null),
- [runtime]
- );
- const ownedReference = useOwnedReferenceOptions(
- runtime
- ? {
- manifest: runtime.manifest,
- publicClient: runtime.publicClient,
- abi: runtime.abi,
- address: runtime.appAddress,
- collectionName: 'Post',
- fieldName: 'authorProfile',
- value: selectedProfileId,
- onChange: setSelectedProfileId
- }
- : {
- manifest: null,
- publicClient: null,
- abi: [],
- address: undefined as any,
- collectionName: 'Post',
- fieldName: 'authorProfile',
- value: selectedProfileId,
- onChange: setSelectedProfileId
- }
- );
- const selectedProfile = ownedReference.selectedOption;
- const postCollection = useMemo(() => getCollection('Post'), []);
- const authorProfileField = useMemo(() => (postCollection ? getField(postCollection, 'authorProfile') : null), [postCollection]);
-
- useEffect(() => {
- if (!runtime || !account) {
- setSelectedProfileId('');
- return;
- }
- setState((prev) => ({ ...prev, loading: ownedReference.loading, connectError: ownedReference.error }));
- }, [account, ownedReference.error, ownedReference.loading, runtime]);
-
- async function connectWallet() {
- if (!walletChain) return;
- setState((prev) => ({ ...prev, connectError: null }));
- try {
- const nextAccount = await requestWalletAddress(walletChain);
- setAccount(nextAccount);
- try {
- localStorage.setItem('TH_ACCOUNT', nextAccount);
- } catch {
- // ignore
- }
- } catch (error: any) {
- setState((prev) => ({ ...prev, connectError: String(error?.message ?? error) }));
- }
- }
-
- async function submit() {
- if (!runtime || !walletChain || !selectedProfile || !body.trim() || imageUploadBusy) return;
-
- setState((prev) => ({ ...prev, submitError: null }));
- setTxStatus(null);
- setTxPhase('idle');
- setTxHash(null);
-
- try {
- const result = await submitWriteTx({
- manifest: runtime.manifest,
- deployment: runtime.deployment,
- chain: walletChain,
- publicClient: runtime.publicClient,
- address: runtime.appAddress,
- abi: runtime.abi,
- functionName: fnCreate('Post'),
- contractArgs: [
- {
- authorProfile: selectedProfile.id,
- body: body.trim(),
- image: image.trim()
- }
- ],
- setStatus: setTxStatus,
- onPhase: setTxPhase,
- onHash: setTxHash
- });
-
- setTxStatus(`Posted (${result.hash.slice(0, 10)}…).`);
- router.push('/');
- router.refresh();
- } catch (error: any) {
- setState((prev) => ({ ...prev, submitError: String(error?.message ?? error) }));
- setTxStatus(null);
- setTxPhase('failed');
- }
- }
-
- if (state.loading && !runtime) {
- return (
-
- Loading composer…
- Resolving the active deployment and wallet state.
-
- );
- }
-
- if (state.runtimeError) {
- return (
-
- /compose/error
- Unable to load composer
- {state.runtimeError}
-
- );
- }
-
- return (
-
-
-
-
-
-
/post/compose
-
- normalized author identity
- profile-linked posts
-
-
-
- Compose as a profile
-
- not as a copied handle string.
-
-
- Posts now store authorProfile as an on-chain reference to Profile ,
- so handle and avatar changes flow through existing posts automatically.
-
-
- Back to feed
- Browse profiles
-
-
-
-
-
/identity
-
-
-
{account ? 1 : 0}
-
Wallet linked
-
-
-
{ownedReference.ownedOptions.length}
-
Owned profiles
-
-
-
- posts reference profiles
- profile changes propagate
-
-
-
-
-
- {!account ? (
-
- /wallet
- Connect a wallet to compose
- Posting now requires selecting one of your on-chain profiles. Connect the wallet that owns the profile first.
-
- void connectWallet()}>Connect wallet
-
- {state.connectError ? {state.connectError}
: null}
-
- ) : null}
-
- {account && !ownedReference.ownedOptions.length ? (
-
- /profiles/empty
- No owned profiles found
- Create a profile first. Once it exists on-chain under this wallet, you can compose posts as that profile.
-
- Create profile
-
- {state.connectError ? {state.connectError}
: null}
-
- ) : null}
-
- {account && ownedReference.ownedOptions.length ? (
-
-
-
Compose Post
-
Choose the on-chain profile identity for this post, then write the post body and optional image.
-
-
-
-
- Profile
-
-
-
-
- Body required
-
-
-
- Image
-
-
-
-
-
- void submit()}
- disabled={
- !selectedProfile ||
- !body.trim() ||
- imageUploadBusy ||
- txPhase === 'submitting' ||
- txPhase === 'submitted' ||
- txPhase === 'confirming'
- }
- >
- {imageUploadBusy ? 'Waiting for image upload…' : 'Publish post'}
-
- Cancel
-
-
- {txStatus ? {txStatus}
: null}
-
- {state.submitError ? {state.submitError}
: null}
-
- ) : null}
-
- );
-}
diff --git a/apps/example/microblog-ui/src/components/MicroblogPostRouteClient.tsx b/apps/example/microblog-ui/src/components/MicroblogPostRouteClient.tsx
index 8309268..5eadb9f 100644
--- a/apps/example/microblog-ui/src/components/MicroblogPostRouteClient.tsx
+++ b/apps/example/microblog-ui/src/components/MicroblogPostRouteClient.tsx
@@ -1,17 +1,7 @@
'use client';
-import { useSearchParams } from 'next/navigation';
-
import CollectionPage from '../collection-route/CollectionPage';
-import MicroblogComposeClient from './MicroblogComposeClient';
export default function MicroblogPostRouteClient() {
- const searchParams = useSearchParams();
- const mode = String(searchParams.get('mode') ?? '').trim();
-
- if (mode === 'new') {
- return ;
- }
-
return ;
}
From 823466382b14009af8d370c10743438c0b1227b3 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 09:58:08 -1000
Subject: [PATCH 05/24] Trim redundant microblog route overrides
---
apps/example/microblog-ui/app/Post/page.tsx | 11 ------
.../components/MicroblogPostRouteClient.tsx | 7 ----
.../example/microblog-ui/src/lib/microblog.ts | 39 +------------------
3 files changed, 2 insertions(+), 55 deletions(-)
delete mode 100644 apps/example/microblog-ui/app/Post/page.tsx
delete mode 100644 apps/example/microblog-ui/src/components/MicroblogPostRouteClient.tsx
diff --git a/apps/example/microblog-ui/app/Post/page.tsx b/apps/example/microblog-ui/app/Post/page.tsx
deleted file mode 100644
index 91aa12d..0000000
--- a/apps/example/microblog-ui/app/Post/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Suspense } from 'react';
-
-import MicroblogPostRouteClient from '../../src/components/MicroblogPostRouteClient';
-
-export default function PostPage() {
- return (
-
-
-
- );
-}
diff --git a/apps/example/microblog-ui/src/components/MicroblogPostRouteClient.tsx b/apps/example/microblog-ui/src/components/MicroblogPostRouteClient.tsx
deleted file mode 100644
index 5eadb9f..0000000
--- a/apps/example/microblog-ui/src/components/MicroblogPostRouteClient.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-'use client';
-
-import CollectionPage from '../collection-route/CollectionPage';
-
-export default function MicroblogPostRouteClient() {
- return ;
-}
diff --git a/apps/example/microblog-ui/src/lib/microblog.ts b/apps/example/microblog-ui/src/lib/microblog.ts
index 213e5ff..5b2f043 100644
--- a/apps/example/microblog-ui/src/lib/microblog.ts
+++ b/apps/example/microblog-ui/src/lib/microblog.ts
@@ -1,12 +1,7 @@
'use client';
-import { getRecordId, listOwnedRecords, recordOwner, resolveReferenceRecords } from './relations';
-import { listAllRecords, loadAppRuntime, type AppRuntime } from './runtime';
-
-export type ProfileRecord = {
- id: bigint;
- record: any;
-};
+import { resolveReferenceRecords } from './relations';
+import { loadAppRuntime, type AppRuntime } from './runtime';
export type FeedItem = {
id: bigint;
@@ -27,36 +22,6 @@ export function profileHandle(profile: any): string {
return String(profile?.handle ?? '').trim();
}
-export function profileLabel(profile: any): string {
- const handle = profileHandle(profile);
- const displayName = String(profile?.displayName ?? '').trim();
- if (displayName && handle) return `${displayName} (@${handle})`;
- if (handle) return `@${handle}`;
- if (displayName) return displayName;
- return 'Unnamed profile';
-}
-
-export function extractAuthorProfileId(postRecord: any): bigint | null {
- return getRecordId(postRecord?.authorProfile);
-}
-
-export async function listProfiles(runtime: AppRuntime): Promise {
- const page = await listAllRecords({
- manifest: runtime.manifest,
- publicClient: runtime.publicClient,
- abi: runtime.abi,
- address: runtime.appAddress,
- collectionName: 'Profile',
- pageSize: 50
- });
-
- return page.ids.map((id, index) => ({ id, record: page.records[index] }));
-}
-
-export async function listOwnedProfiles(runtime: AppRuntime, ownerAddress: string): Promise {
- return listOwnedRecords(runtime, 'Profile', ownerAddress);
-}
-
export async function resolveFeedItemsWithProfiles(runtime: AppRuntime, items: Array<{ id: bigint; record: any }>): Promise {
const resolved = await resolveReferenceRecords(runtime, items, {
fieldName: 'authorProfile',
From 633903e00b0dcddf28fb83158b4dcf9ab5a54549 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 10:02:02 -1000
Subject: [PATCH 06/24] Collapse microblog helper layer
---
.../src/components/MicroblogHomeClient.tsx | 24 +++++++----
.../src/components/MicroblogTagClient.tsx | 24 +++++++----
.../src/components/PostStream.tsx | 20 ++++++++-
.../example/microblog-ui/src/lib/microblog.ts | 41 -------------------
4 files changed, 51 insertions(+), 58 deletions(-)
delete mode 100644 apps/example/microblog-ui/src/lib/microblog.ts
diff --git a/apps/example/microblog-ui/src/components/MicroblogHomeClient.tsx b/apps/example/microblog-ui/src/components/MicroblogHomeClient.tsx
index 31f0b98..89715cd 100644
--- a/apps/example/microblog-ui/src/components/MicroblogHomeClient.tsx
+++ b/apps/example/microblog-ui/src/components/MicroblogHomeClient.tsx
@@ -3,9 +3,10 @@
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
-import { resolveFeedItemsWithProfiles, loadMicroblogRuntime, type FeedItem } from '../lib/microblog';
-import { listAllRecords } from '../lib/runtime';
+import { resolveReferenceRecords } from '../lib/relations';
+import { listAllRecords, loadAppRuntime } from '../lib/runtime';
import PostStream, { collectTrendingTags, sortFeedItemsDesc } from './PostStream';
+import type { FeedItem } from './PostStream';
type LoadState = {
items: FeedItem[];
@@ -21,7 +22,7 @@ export default function MicroblogHomeClient() {
(async () => {
try {
- const runtime = await loadMicroblogRuntime();
+ const runtime = await loadAppRuntime();
const page = await listAllRecords({
manifest: runtime.manifest,
publicClient: runtime.publicClient,
@@ -33,13 +34,20 @@ export default function MicroblogHomeClient() {
if (cancelled) return;
- const resolved = await resolveFeedItemsWithProfiles(
- runtime,
- page.ids.map((id, index) => ({ id, record: page.records[index] }))
- );
+ const resolved = await resolveReferenceRecords(runtime, page.ids.map((id, index) => ({ id, record: page.records[index] })), {
+ fieldName: 'authorProfile',
+ targetCollectionName: 'Profile'
+ });
if (cancelled) return;
- const items = sortFeedItemsDesc(resolved).slice(0, 24);
+ const items = sortFeedItemsDesc(
+ resolved.map((item) => ({
+ id: item.id,
+ record: item.record,
+ authorProfileId: item.referenceId,
+ authorProfile: item.referenceRecord
+ }))
+ ).slice(0, 24);
setState({ items, loading: false, error: null });
} catch (error: any) {
if (cancelled) return;
diff --git a/apps/example/microblog-ui/src/components/MicroblogTagClient.tsx b/apps/example/microblog-ui/src/components/MicroblogTagClient.tsx
index 9e8da94..c5ecd72 100644
--- a/apps/example/microblog-ui/src/components/MicroblogTagClient.tsx
+++ b/apps/example/microblog-ui/src/components/MicroblogTagClient.tsx
@@ -4,9 +4,10 @@ import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
-import { resolveFeedItemsWithProfiles, loadMicroblogRuntime, type FeedItem } from '../lib/microblog';
-import { listHashtagRecords } from '../lib/runtime';
+import { resolveReferenceRecords } from '../lib/relations';
+import { listHashtagRecords, loadAppRuntime } from '../lib/runtime';
import PostStream, { sortFeedItemsDesc } from './PostStream';
+import type { FeedItem } from './PostStream';
type TagState = {
hashtag: string;
@@ -30,7 +31,7 @@ export default function MicroblogTagClient() {
}
try {
- const runtime = await loadMicroblogRuntime();
+ const runtime = await loadAppRuntime();
const page = await listHashtagRecords({
manifest: runtime.manifest,
publicClient: runtime.publicClient,
@@ -44,13 +45,20 @@ export default function MicroblogTagClient() {
if (cancelled) return;
- const resolved = await resolveFeedItemsWithProfiles(
- runtime,
- page.ids.map((id, index) => ({ id, record: page.records[index] }))
- );
+ const resolved = await resolveReferenceRecords(runtime, page.ids.map((id, index) => ({ id, record: page.records[index] })), {
+ fieldName: 'authorProfile',
+ targetCollectionName: 'Profile'
+ });
if (cancelled) return;
- const items = sortFeedItemsDesc(resolved);
+ const items = sortFeedItemsDesc(
+ resolved.map((item) => ({
+ id: item.id,
+ record: item.record,
+ authorProfileId: item.referenceId,
+ authorProfile: item.referenceRecord
+ }))
+ );
setState({ hashtag: page.hashtag, items, loading: false, error: null });
} catch (error: any) {
if (cancelled) return;
diff --git a/apps/example/microblog-ui/src/components/PostStream.tsx b/apps/example/microblog-ui/src/components/PostStream.tsx
index fead484..ccf4c9f 100644
--- a/apps/example/microblog-ui/src/components/PostStream.tsx
+++ b/apps/example/microblog-ui/src/components/PostStream.tsx
@@ -4,7 +4,25 @@ import Link from 'next/link';
import { formatDateTime } from '../lib/format';
import { extractHashtagTokens } from '../lib/indexing';
-import { profileDisplayName, profileHandle, type FeedItem } from '../lib/microblog';
+
+export type FeedItem = {
+ id: bigint;
+ record: any;
+ authorProfileId: bigint | null;
+ authorProfile: any | null;
+};
+
+function profileDisplayName(profile: any): string {
+ const displayName = String(profile?.displayName ?? '').trim();
+ const handle = String(profile?.handle ?? '').trim();
+ if (displayName) return displayName;
+ if (handle) return `@${handle}`;
+ return 'Anonymous';
+}
+
+function profileHandle(profile: any): string {
+ return String(profile?.handle ?? '').trim();
+}
export function sortFeedItemsDesc(items: FeedItem[]): FeedItem[] {
return [...items].sort((a, b) => {
diff --git a/apps/example/microblog-ui/src/lib/microblog.ts b/apps/example/microblog-ui/src/lib/microblog.ts
deleted file mode 100644
index 5b2f043..0000000
--- a/apps/example/microblog-ui/src/lib/microblog.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-'use client';
-
-import { resolveReferenceRecords } from './relations';
-import { loadAppRuntime, type AppRuntime } from './runtime';
-
-export type FeedItem = {
- id: bigint;
- record: any;
- authorProfileId: bigint | null;
- authorProfile: any | null;
-};
-
-export function profileDisplayName(profile: any): string {
- const displayName = String(profile?.displayName ?? '').trim();
- const handle = String(profile?.handle ?? '').trim();
- if (displayName) return displayName;
- if (handle) return `@${handle}`;
- return 'Anonymous';
-}
-
-export function profileHandle(profile: any): string {
- return String(profile?.handle ?? '').trim();
-}
-
-export async function resolveFeedItemsWithProfiles(runtime: AppRuntime, items: Array<{ id: bigint; record: any }>): Promise {
- const resolved = await resolveReferenceRecords(runtime, items, {
- fieldName: 'authorProfile',
- targetCollectionName: 'Profile'
- });
-
- return resolved.map((item) => ({
- id: item.id,
- record: item.record,
- authorProfileId: item.referenceId,
- authorProfile: item.referenceRecord
- }));
-}
-
-export async function loadMicroblogRuntime(): Promise {
- return loadAppRuntime();
-}
From 7a56ef397e737cf4fe140dc090d011349ffa5ccd Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 10:17:11 -1000
Subject: [PATCH 07/24] Generate feeds and token pages from schema
---
apps/example/microblog-ui/app/page.tsx | 5 -
.../src/components/MicroblogHomeClient.tsx | 183 ------------------
.../src/components/MicroblogTagClient.tsx | 149 --------------
.../src/components/PostStream.tsx | 139 -------------
apps/example/microblog.schema.json | 56 +++++-
packages/cli/src/index.ts | 4 +
.../schema/schemas/tokenhost-ths.schema.json | 108 +++++++++++
packages/schema/src/lint.ts | 60 ++++++
packages/schema/src/types.ts | 65 +++++++
.../templates/next-export-ui/app/page.tsx | 5 +
.../next-export-ui}/app/tag/page.tsx | 4 +-
.../src/components/GeneratedFeedStream.tsx | 87 +++++++++
.../components/GeneratedHomePageClient.tsx | 167 ++++++++++++++++
.../components/GeneratedTokenPageClient.tsx | 107 ++++++++++
.../next-export-ui/src/lib/generated-ui.ts | 138 +++++++++++++
.../templates/next-export-ui/src/lib/ths.ts | 47 +++++
schemas/tokenhost-ths.schema.json | 149 +++++++++++++-
test/testCliGenerateUi.js | 11 +-
test/testThsSchema.js | 105 ++++++++++
19 files changed, 1107 insertions(+), 482 deletions(-)
delete mode 100644 apps/example/microblog-ui/app/page.tsx
delete mode 100644 apps/example/microblog-ui/src/components/MicroblogHomeClient.tsx
delete mode 100644 apps/example/microblog-ui/src/components/MicroblogTagClient.tsx
delete mode 100644 apps/example/microblog-ui/src/components/PostStream.tsx
rename {apps/example/microblog-ui => packages/templates/next-export-ui}/app/tag/page.tsx (53%)
create mode 100644 packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx
create mode 100644 packages/templates/next-export-ui/src/components/GeneratedHomePageClient.tsx
create mode 100644 packages/templates/next-export-ui/src/components/GeneratedTokenPageClient.tsx
create mode 100644 packages/templates/next-export-ui/src/lib/generated-ui.ts
diff --git a/apps/example/microblog-ui/app/page.tsx b/apps/example/microblog-ui/app/page.tsx
deleted file mode 100644
index 6bfea9a..0000000
--- a/apps/example/microblog-ui/app/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import MicroblogHomeClient from '../src/components/MicroblogHomeClient';
-
-export default function HomePage() {
- return ;
-}
diff --git a/apps/example/microblog-ui/src/components/MicroblogHomeClient.tsx b/apps/example/microblog-ui/src/components/MicroblogHomeClient.tsx
deleted file mode 100644
index 89715cd..0000000
--- a/apps/example/microblog-ui/src/components/MicroblogHomeClient.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-import { useEffect, useMemo, useState } from 'react';
-
-import { resolveReferenceRecords } from '../lib/relations';
-import { listAllRecords, loadAppRuntime } from '../lib/runtime';
-import PostStream, { collectTrendingTags, sortFeedItemsDesc } from './PostStream';
-import type { FeedItem } from './PostStream';
-
-type LoadState = {
- items: FeedItem[];
- loading: boolean;
- error: string | null;
-};
-
-export default function MicroblogHomeClient() {
- const [state, setState] = useState({ items: [], loading: true, error: null });
-
- useEffect(() => {
- let cancelled = false;
-
- (async () => {
- try {
- const runtime = await loadAppRuntime();
- const page = await listAllRecords({
- manifest: runtime.manifest,
- publicClient: runtime.publicClient,
- abi: runtime.abi,
- address: runtime.appAddress,
- collectionName: 'Post',
- pageSize: 25
- });
-
- if (cancelled) return;
-
- const resolved = await resolveReferenceRecords(runtime, page.ids.map((id, index) => ({ id, record: page.records[index] })), {
- fieldName: 'authorProfile',
- targetCollectionName: 'Profile'
- });
- if (cancelled) return;
-
- const items = sortFeedItemsDesc(
- resolved.map((item) => ({
- id: item.id,
- record: item.record,
- authorProfileId: item.referenceId,
- authorProfile: item.referenceRecord
- }))
- ).slice(0, 24);
- setState({ items, loading: false, error: null });
- } catch (error: any) {
- if (cancelled) return;
- setState({ items: [], loading: false, error: String(error?.message ?? error) });
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, []);
-
- const trending = useMemo(() => collectTrendingTags(state.items, 10), [state.items]);
- const imagePostCount = useMemo(
- () => state.items.filter((item) => String(item.record?.image ?? '').trim()).length,
- [state.items]
- );
-
- return (
-
-
-
-
-
-
/tokenhost/microblog
-
- native hashtags
- native image uploads
- filecoin-ready
-
-
-
- Microblog posts
-
- with on-chain discovery and first-class media.
-
-
- This example app uses Token Host's native hashtag index on Post.body and the native
- upload field flow for Post.image . Posts reference Profile records
- instead of copying handles into each post.
-
-
- Compose post
- Create profile
- Browse raw records
-
-
-
-
-
/demo/status
-
-
-
{state.loading ? '…' : state.items.length}
-
Feed posts
-
-
-
{state.loading ? '…' : imagePostCount}
-
Image posts
-
-
-
{state.loading ? '…' : trending.length}
-
Active tags
-
-
-
-
- author profile reference
- body hashtag tokenizer
-
-
-
-
-
- {state.error ? (
-
- /runtime/error
- Unable to load the live feed
- {state.error}
-
- ) : null}
-
-
-
- /tags
-
Trending hashtags
-
-
-
Hashtags come from the native tokenized index on post bodies, not an app-specific join table.
-
-
-
-
-
- {trending.length ? (
- trending.map((entry) => (
-
- #{entry.tag} · {entry.count}
-
- ))
- ) : (
- Create a few posts with hashtags like #tokenhost, #foc, or #microblog to populate the index.
- )}
-
-
-
-
-
- /feed
-
Latest posts
-
-
-
Text-only and image posts render through the same generated `Post` collection.
-
-
-
- {state.loading ? (
-
- /feed/loading
- Loading posts from the current deployment…
-
- ) : (
-
- )}
-
- );
-}
diff --git a/apps/example/microblog-ui/src/components/MicroblogTagClient.tsx b/apps/example/microblog-ui/src/components/MicroblogTagClient.tsx
deleted file mode 100644
index c5ecd72..0000000
--- a/apps/example/microblog-ui/src/components/MicroblogTagClient.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-import { useEffect, useState } from 'react';
-import { useSearchParams } from 'next/navigation';
-
-import { resolveReferenceRecords } from '../lib/relations';
-import { listHashtagRecords, loadAppRuntime } from '../lib/runtime';
-import PostStream, { sortFeedItemsDesc } from './PostStream';
-import type { FeedItem } from './PostStream';
-
-type TagState = {
- hashtag: string;
- items: FeedItem[];
- loading: boolean;
- error: string | null;
-};
-
-export default function MicroblogTagClient() {
- const searchParams = useSearchParams();
- const tag = String(searchParams.get('value') ?? '').trim();
- const [state, setState] = useState({ hashtag: '', items: [], loading: true, error: null });
-
- useEffect(() => {
- let cancelled = false;
-
- (async () => {
- if (!tag) {
- setState({ hashtag: '', items: [], loading: false, error: null });
- return;
- }
-
- try {
- const runtime = await loadAppRuntime();
- const page = await listHashtagRecords({
- manifest: runtime.manifest,
- publicClient: runtime.publicClient,
- abi: runtime.abi,
- address: runtime.appAddress,
- collectionName: 'Post',
- fieldName: 'body',
- hashtag: tag,
- pageSize: 25
- });
-
- if (cancelled) return;
-
- const resolved = await resolveReferenceRecords(runtime, page.ids.map((id, index) => ({ id, record: page.records[index] })), {
- fieldName: 'authorProfile',
- targetCollectionName: 'Profile'
- });
- if (cancelled) return;
-
- const items = sortFeedItemsDesc(
- resolved.map((item) => ({
- id: item.id,
- record: item.record,
- authorProfileId: item.referenceId,
- authorProfile: item.referenceRecord
- }))
- );
- setState({ hashtag: page.hashtag, items, loading: false, error: null });
- } catch (error: any) {
- if (cancelled) return;
- setState({ hashtag: '', items: [], loading: false, error: String(error?.message ?? error) });
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, [tag]);
-
- return (
-
-
-
-
-
-
/tag/query
-
- {state.hashtag ? `#${state.hashtag}` : 'no tag selected'}
- tokenized index read
-
-
-
- {state.hashtag ? `#${state.hashtag}` : 'Hashtag feed'}
-
- resolved from the native on-chain index.
-
-
- Token Host queries the generated listByIndexPost_body accessor, then filters current records
- against live post content and resolves each post's current author profile at render time.
-
-
- Compose tagged post
- Back to feed
-
-
-
-
-
/result
-
-
-
{state.loading ? '…' : state.items.length}
-
Matches
-
-
-
{state.hashtag ? `#${state.hashtag}` : '—'}
-
Token
-
-
-
-
-
-
- {!tag ? (
-
- /tag/missing
- No hashtag selected
- Open a tag from the home feed or visit a route like /tag?value=tokenhost .
-
- ) : null}
-
- {state.error ? (
-
- /tag/error
- Unable to load hashtag feed
- {state.error}
-
- ) : null}
-
- {tag && !state.loading && !state.error ? (
-
- ) : null}
-
- {tag && state.loading ? (
-
- /tag/loading
- Loading hashtag feed…
-
- ) : null}
-
- );
-}
diff --git a/apps/example/microblog-ui/src/components/PostStream.tsx b/apps/example/microblog-ui/src/components/PostStream.tsx
deleted file mode 100644
index ccf4c9f..0000000
--- a/apps/example/microblog-ui/src/components/PostStream.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-
-import { formatDateTime } from '../lib/format';
-import { extractHashtagTokens } from '../lib/indexing';
-
-export type FeedItem = {
- id: bigint;
- record: any;
- authorProfileId: bigint | null;
- authorProfile: any | null;
-};
-
-function profileDisplayName(profile: any): string {
- const displayName = String(profile?.displayName ?? '').trim();
- const handle = String(profile?.handle ?? '').trim();
- if (displayName) return displayName;
- if (handle) return `@${handle}`;
- return 'Anonymous';
-}
-
-function profileHandle(profile: any): string {
- return String(profile?.handle ?? '').trim();
-}
-
-export function sortFeedItemsDesc(items: FeedItem[]): FeedItem[] {
- return [...items].sort((a, b) => {
- if (a.id === b.id) return 0;
- return a.id < b.id ? 1 : -1;
- });
-}
-
-export function collectTrendingTags(items: FeedItem[], limit = 8): Array<{ tag: string; count: number }> {
- const counts = new Map();
- for (const item of items) {
- for (const tag of extractHashtagTokens(String(item.record?.body ?? ''))) {
- counts.set(tag, (counts.get(tag) ?? 0) + 1);
- }
- }
-
- return Array.from(counts.entries())
- .sort((a, b) => (b[1] !== a[1] ? b[1] - a[1] : a[0].localeCompare(b[0])))
- .slice(0, limit)
- .map(([tag, count]) => ({ tag, count }));
-}
-
-function PostCard({ item }: { item: FeedItem }) {
- const body = String(item.record?.body ?? '').trim();
- const image = String(item.record?.image ?? '').trim();
- const authorHandle = profileHandle(item.authorProfile);
- const authorName = profileDisplayName(item.authorProfile);
- const avatar = String(item.authorProfile?.avatar ?? '').trim();
- const tags = extractHashtagTokens(body);
- const timestamp = item.record?.updatedAt ?? item.record?.createdAt ?? null;
-
- return (
-
-
-
-
/post/{String(item.id)}
-
- {avatar ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : null}
-
-
{authorName}
-
- {authorHandle ? `@${authorHandle}` : 'Unresolved profile'}
-
-
-
-
- {timestamp ? formatDateTime(timestamp, 'compact') : 'On-chain post'}
-
-
-
- {image ? 'image post' : 'text post'}
- id {String(item.id)}
- {item.authorProfileId ? profile {String(item.authorProfileId)} : null}
-
-
-
- {body ? {body}
: null}
-
- {image ? (
-
-
-
- ) : null}
-
- {tags.length ? (
-
- {tags.map((tag) => (
-
- #{tag}
-
- ))}
-
- ) : null}
-
- );
-}
-
-export default function PostStream(props: { items: FeedItem[]; emptyTitle: string; emptyBody: string }) {
- if (!props.items.length) {
- return (
-
- /feed/empty
- {props.emptyTitle}
- {props.emptyBody}
-
- );
- }
-
- return (
-
- {props.items.map((item) => (
-
- ))}
-
- );
-}
diff --git a/apps/example/microblog.schema.json b/apps/example/microblog.schema.json
index b3a4c86..cc1523f 100644
--- a/apps/example/microblog.schema.json
+++ b/apps/example/microblog.schema.json
@@ -12,8 +12,60 @@
"onChainIndexing": true
},
"ui": {
- "homePage": { "mode": "custom" },
- "extensions": { "directory": "microblog-ui" }
+ "homePage": { "mode": "generated" },
+ "generated": {
+ "feeds": [
+ {
+ "id": "posts",
+ "collection": "Post",
+ "limit": 25,
+ "card": {
+ "referenceField": "authorProfile",
+ "textField": "body",
+ "mediaField": "image"
+ }
+ }
+ ],
+ "tokenPages": [
+ {
+ "id": "hashtags",
+ "collection": "Post",
+ "field": "body",
+ "tokenizer": "hashtag",
+ "feed": "posts",
+ "title": "Hashtag feed",
+ "emptyBody": "Create a post with that hashtag and reload the feed."
+ }
+ ],
+ "homeSections": [
+ {
+ "type": "hero",
+ "eyebrow": "/tokenhost/microblog",
+ "title": "Microblog posts",
+ "accent": "with on-chain discovery and first-class media.",
+ "description": "This example app uses Token Host's native hashtag index on Post.body and the native upload field flow for Post.image. Posts reference Profile records instead of copying handles into each post.",
+ "badges": ["native hashtags", "native image uploads", "filecoin-ready"],
+ "actions": [
+ { "label": "Compose post", "href": "/Post/?mode=new", "variant": "primary" },
+ { "label": "Create profile", "href": "/Profile/?mode=new" },
+ { "label": "Browse raw records", "href": "/Post/" }
+ ]
+ },
+ {
+ "type": "tokenList",
+ "tokenPage": "hashtags",
+ "title": "Trending hashtags",
+ "emptyBody": "Create a few posts with hashtags like #tokenhost, #foc, or #microblog to populate the index."
+ },
+ {
+ "type": "feed",
+ "feed": "posts",
+ "title": "Latest posts",
+ "emptyTitle": "No posts yet",
+ "emptyBody": "Compose the first post, add a hashtag, and optionally attach an image to see the native upload flow."
+ }
+ ]
+ }
}
},
"collections": [
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 828f1d3..8e7ad1c 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -179,6 +179,10 @@ function normalizeStudioFormState(input: any): ThsSchema {
directory:
appIn.ui.extensions.directory == null ? undefined : String(appIn.ui.extensions.directory)
}
+ : undefined,
+ generated:
+ appIn.ui?.generated && typeof appIn.ui.generated === 'object'
+ ? JSON.parse(JSON.stringify(appIn.ui.generated))
: undefined
}
},
diff --git a/packages/schema/schemas/tokenhost-ths.schema.json b/packages/schema/schemas/tokenhost-ths.schema.json
index 4e489f6..ec9d4f9 100644
--- a/packages/schema/schemas/tokenhost-ths.schema.json
+++ b/packages/schema/schemas/tokenhost-ths.schema.json
@@ -63,6 +63,24 @@
"description": "Path to UI override files, resolved relative to the schema file."
}
}
+ },
+ "generated": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "feeds": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/generatedFeed" }
+ },
+ "tokenPages": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/generatedTokenPage" }
+ },
+ "homeSections": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/generatedHomeSection" }
+ }
+ }
}
}
},
@@ -311,6 +329,96 @@
"enforce": { "type": "boolean" },
"reverseIndex": { "type": "boolean" }
}
+ },
+ "generatedAction": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["label", "href"],
+ "properties": {
+ "label": { "type": "string", "minLength": 1 },
+ "href": { "type": "string", "minLength": 1 },
+ "variant": { "type": "string", "enum": ["default", "primary"] }
+ }
+ },
+ "generatedFeedCard": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "referenceField": { "type": "string" },
+ "textField": { "type": "string" },
+ "mediaField": { "type": "string" }
+ }
+ },
+ "generatedFeed": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "collection"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_-]*$" },
+ "collection": { "type": "string" },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 100 },
+ "card": { "$ref": "#/$defs/generatedFeedCard" }
+ }
+ },
+ "generatedTokenPage": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "collection", "field", "tokenizer"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_-]*$" },
+ "collection": { "type": "string" },
+ "field": { "type": "string" },
+ "tokenizer": { "type": "string", "enum": ["hashtag"] },
+ "feed": { "type": "string" },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 100 },
+ "title": { "type": "string" },
+ "emptyTitle": { "type": "string" },
+ "emptyBody": { "type": "string" }
+ }
+ },
+ "generatedHeroSection": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "title"],
+ "properties": {
+ "type": { "const": "hero" },
+ "eyebrow": { "type": "string" },
+ "title": { "type": "string" },
+ "accent": { "type": "string" },
+ "description": { "type": "string" },
+ "badges": { "type": "array", "items": { "type": "string" } },
+ "actions": { "type": "array", "items": { "$ref": "#/$defs/generatedAction" } }
+ }
+ },
+ "generatedFeedSection": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "feed", "title"],
+ "properties": {
+ "type": { "const": "feed" },
+ "feed": { "type": "string" },
+ "title": { "type": "string" },
+ "emptyTitle": { "type": "string" },
+ "emptyBody": { "type": "string" }
+ }
+ },
+ "generatedTokenListSection": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "tokenPage", "title"],
+ "properties": {
+ "type": { "const": "tokenList" },
+ "tokenPage": { "type": "string" },
+ "title": { "type": "string" },
+ "emptyBody": { "type": "string" }
+ }
+ },
+ "generatedHomeSection": {
+ "oneOf": [
+ { "$ref": "#/$defs/generatedHeroSection" },
+ { "$ref": "#/$defs/generatedFeedSection" },
+ { "$ref": "#/$defs/generatedTokenListSection" }
+ ]
}
}
}
diff --git a/packages/schema/src/lint.ts b/packages/schema/src/lint.ts
index 5114944..ad76ebe 100644
--- a/packages/schema/src/lint.ts
+++ b/packages/schema/src/lint.ts
@@ -71,6 +71,7 @@ function isSafeAutoExpr(expr: string): boolean {
export function lintThs(schema: ThsSchema): Issue[] {
const issues: Issue[] = [];
const themePreset = String(schema.app.theme?.preset ?? '').trim();
+ const generatedUi = schema.app.ui?.generated;
if (themePreset && themePreset !== 'cyber-grid') {
issues.push(
@@ -88,6 +89,65 @@ export function lintThs(schema: ThsSchema): Issue[] {
);
}
+ const generatedFeedIds = new Set();
+ const generatedFeeds = Array.isArray(generatedUi?.feeds) ? generatedUi.feeds : [];
+ for (const [index, feed] of generatedFeeds.entries()) {
+ const feedPath = `/app/ui/generated/feeds/${index}`;
+ if (generatedFeedIds.has(feed.id)) {
+ issues.push(err(`${feedPath}/id`, 'lint.app.ui.generated.feed_duplicate', `Duplicate generated feed id "${feed.id}".`));
+ }
+ generatedFeedIds.add(feed.id);
+ const collection = schema.collections.find((candidate) => candidate.name === feed.collection);
+ if (!collection) {
+ issues.push(err(`${feedPath}/collection`, 'lint.app.ui.generated.feed_unknown_collection', `Generated feed references unknown collection "${feed.collection}".`));
+ continue;
+ }
+ const fields = fieldMap(collection);
+ for (const key of ['referenceField', 'textField', 'mediaField'] as const) {
+ const value = feed.card?.[key];
+ if (value && !fields.has(value)) {
+ issues.push(err(`${feedPath}/card/${key}`, 'lint.app.ui.generated.feed_unknown_field', `Generated feed card references unknown field "${value}".`));
+ }
+ }
+ }
+
+ const generatedTokenPageIds = new Set();
+ const generatedTokenPages = Array.isArray(generatedUi?.tokenPages) ? generatedUi.tokenPages : [];
+ for (const [index, tokenPage] of generatedTokenPages.entries()) {
+ const tokenPath = `/app/ui/generated/tokenPages/${index}`;
+ if (generatedTokenPageIds.has(tokenPage.id)) {
+ issues.push(err(`${tokenPath}/id`, 'lint.app.ui.generated.token_duplicate', `Duplicate generated token page id "${tokenPage.id}".`));
+ }
+ generatedTokenPageIds.add(tokenPage.id);
+ const collection = schema.collections.find((candidate) => candidate.name === tokenPage.collection);
+ if (!collection) {
+ issues.push(err(`${tokenPath}/collection`, 'lint.app.ui.generated.token_unknown_collection', `Generated token page references unknown collection "${tokenPage.collection}".`));
+ continue;
+ }
+ const field = fieldMap(collection).get(tokenPage.field);
+ if (!field) {
+ issues.push(err(`${tokenPath}/field`, 'lint.app.ui.generated.token_unknown_field', `Generated token page references unknown field "${tokenPage.field}".`));
+ continue;
+ }
+ if (field.type !== 'string') {
+ issues.push(err(`${tokenPath}/field`, 'lint.app.ui.generated.token_field_type', `Generated token page field "${tokenPage.field}" must be type "string".`));
+ }
+ if (tokenPage.feed && !generatedFeedIds.has(tokenPage.feed)) {
+ issues.push(err(`${tokenPath}/feed`, 'lint.app.ui.generated.token_unknown_feed', `Generated token page references unknown generated feed "${tokenPage.feed}".`));
+ }
+ }
+
+ const homeSections = Array.isArray(generatedUi?.homeSections) ? generatedUi.homeSections : [];
+ for (const [index, section] of homeSections.entries()) {
+ const sectionPath = `/app/ui/generated/homeSections/${index}`;
+ if (section.type === 'feed' && !generatedFeedIds.has(section.feed)) {
+ issues.push(err(`${sectionPath}/feed`, 'lint.app.ui.generated.section_unknown_feed', `Generated home section references unknown feed "${section.feed}".`));
+ }
+ if (section.type === 'tokenList' && !generatedTokenPageIds.has(section.tokenPage)) {
+ issues.push(err(`${sectionPath}/tokenPage`, 'lint.app.ui.generated.section_unknown_token_page', `Generated home section references unknown token page "${section.tokenPage}".`));
+ }
+ }
+
const collectionNames = new Set();
for (let i = 0; i < schema.collections.length; i++) {
const c = schema.collections[i]!;
diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts
index 5b3e859..8ef328d 100644
--- a/packages/schema/src/types.ts
+++ b/packages/schema/src/types.ts
@@ -17,9 +17,74 @@ export interface ThsAppUiExtensions {
directory?: string;
}
+export interface ThsGeneratedAction {
+ label: string;
+ href: string;
+ variant?: 'default' | 'primary';
+}
+
+export interface ThsGeneratedFeedCard {
+ referenceField?: string;
+ textField?: string;
+ mediaField?: string;
+}
+
+export interface ThsGeneratedFeed {
+ id: string;
+ collection: string;
+ limit?: number;
+ card?: ThsGeneratedFeedCard;
+}
+
+export interface ThsGeneratedTokenPage {
+ id: string;
+ collection: string;
+ field: string;
+ tokenizer: 'hashtag';
+ feed?: string;
+ limit?: number;
+ title?: string;
+ emptyTitle?: string;
+ emptyBody?: string;
+}
+
+export interface ThsGeneratedHeroSection {
+ type: 'hero';
+ eyebrow?: string;
+ title: string;
+ accent?: string;
+ description?: string;
+ badges?: string[];
+ actions?: ThsGeneratedAction[];
+}
+
+export interface ThsGeneratedFeedSection {
+ type: 'feed';
+ feed: string;
+ title: string;
+ emptyTitle?: string;
+ emptyBody?: string;
+}
+
+export interface ThsGeneratedTokenListSection {
+ type: 'tokenList';
+ tokenPage: string;
+ title: string;
+ emptyBody?: string;
+}
+
+export type ThsGeneratedHomeSection = ThsGeneratedHeroSection | ThsGeneratedFeedSection | ThsGeneratedTokenListSection;
+
+export interface ThsAppUiGenerated {
+ feeds?: ThsGeneratedFeed[];
+ tokenPages?: ThsGeneratedTokenPage[];
+ homeSections?: ThsGeneratedHomeSection[];
+}
+
export interface ThsAppUi {
homePage?: ThsAppUiHomePage;
extensions?: ThsAppUiExtensions;
+ generated?: ThsAppUiGenerated;
}
export type ThsThemePreset = 'cyber-grid';
diff --git a/packages/templates/next-export-ui/app/page.tsx b/packages/templates/next-export-ui/app/page.tsx
index 955b391..cf33e41 100644
--- a/packages/templates/next-export-ui/app/page.tsx
+++ b/packages/templates/next-export-ui/app/page.tsx
@@ -1,8 +1,13 @@
import Link from 'next/link';
+import GeneratedHomePageClient from '../src/components/GeneratedHomePageClient';
import { displayField, hasCreatePayment, mutableFields, ths, transferEnabled } from '../src/lib/ths';
export default function HomePage() {
+ if (Array.isArray(ths.app.ui?.generated?.homeSections) && ths.app.ui.generated.homeSections.length > 0) {
+ return ;
+ }
+
const firstCollection = ths.collections[0] ?? null;
const totalFields = ths.collections.reduce((sum, collection) => sum + collection.fields.length, 0);
const editableCollections = ths.collections.filter((collection) => mutableFields(collection).length > 0).length;
diff --git a/apps/example/microblog-ui/app/tag/page.tsx b/packages/templates/next-export-ui/app/tag/page.tsx
similarity index 53%
rename from apps/example/microblog-ui/app/tag/page.tsx
rename to packages/templates/next-export-ui/app/tag/page.tsx
index dae4385..38fec27 100644
--- a/apps/example/microblog-ui/app/tag/page.tsx
+++ b/packages/templates/next-export-ui/app/tag/page.tsx
@@ -1,11 +1,11 @@
import { Suspense } from 'react';
-import MicroblogTagClient from '../../src/components/MicroblogTagClient';
+import GeneratedTokenPageClient from '../../src/components/GeneratedTokenPageClient';
export default function TagPage() {
return (
-
+
);
}
diff --git a/packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx b/packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx
new file mode 100644
index 0000000..eb8d151
--- /dev/null
+++ b/packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import Link from 'next/link';
+
+import { formatDateTime } from '../lib/format';
+import { extractHashtagTokens } from '../lib/indexing';
+import { feedCardSummary, type GeneratedFeedConfig, type GeneratedFeedItem } from '../lib/generated-ui';
+
+export default function GeneratedFeedStream(props: {
+ feed: GeneratedFeedConfig;
+ items: GeneratedFeedItem[];
+ emptyTitle: string;
+ emptyBody: string;
+ tokenPageId?: string;
+}) {
+ const textField = props.feed.card?.textField ?? 'body';
+ const tagBaseHref = props.tokenPageId
+ ? `/tag?page=${encodeURIComponent(props.tokenPageId)}&value=`
+ : '/tag?value=';
+
+ if (!props.items.length) {
+ return (
+
+ /feed/empty
+ {props.emptyTitle}
+ {props.emptyBody}
+
+ );
+ }
+
+ return (
+
+ {props.items.map((item) => {
+ const summary = feedCardSummary(item, props.feed);
+ const tags = extractHashtagTokens(summary.body);
+ const timestamp = item.record?.updatedAt ?? item.record?.createdAt ?? null;
+ return (
+
+
+
+
/{props.feed.collection.toLowerCase()}/{String(item.id)}
+
+ {summary.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+
{summary.title}
+ {summary.subtitle ?
{summary.subtitle}
: null}
+
+
+
{timestamp ? formatDateTime(timestamp, 'compact') : 'On-chain record'}
+
+
+ {summary.mediaUrl ? 'image post' : 'text post'}
+ id {String(item.id)}
+ {item.referenceId ? profile {String(item.referenceId)} : null}
+
+
+
+ {summary.body ? {summary.body}
: null}
+
+ {summary.mediaUrl ? (
+
+
+
+ ) : null}
+
+ {tags.length ? (
+
+ {tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/templates/next-export-ui/src/components/GeneratedHomePageClient.tsx b/packages/templates/next-export-ui/src/components/GeneratedHomePageClient.tsx
new file mode 100644
index 0000000..dfe0705
--- /dev/null
+++ b/packages/templates/next-export-ui/src/components/GeneratedHomePageClient.tsx
@@ -0,0 +1,167 @@
+'use client';
+
+import Link from 'next/link';
+import { useEffect, useMemo, useState } from 'react';
+
+import GeneratedFeedStream from './GeneratedFeedStream';
+import {
+ collectGeneratedTrendingTokens,
+ generatedHomeSections,
+ getGeneratedFeed,
+ getGeneratedTokenPage,
+ loadGeneratedFeed,
+ loadGeneratedUiRuntime,
+ sortGeneratedFeedItemsDesc,
+ type GeneratedFeedItem
+} from '../lib/generated-ui';
+import { ths } from '../lib/ths';
+
+export default function GeneratedHomePageClient() {
+ const sections = generatedHomeSections();
+ const tokenListSection = useMemo(
+ () => sections.find((candidate) => candidate.type === 'tokenList'),
+ [sections]
+ );
+ const [feeds, setFeeds] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const runtime = await loadGeneratedUiRuntime();
+ const nextFeeds: Record = {};
+ for (const section of sections) {
+ if (section.type !== 'feed') continue;
+ const feed = getGeneratedFeed(section.feed);
+ if (!feed) continue;
+ nextFeeds[feed.id] = sortGeneratedFeedItemsDesc(await loadGeneratedFeed(runtime, feed));
+ }
+ if (cancelled) return;
+ setFeeds(nextFeeds);
+ setLoading(false);
+ } catch (cause: any) {
+ if (cancelled) return;
+ setError(String(cause?.message ?? cause));
+ setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [sections]);
+
+ const collectionCount = ths.collections.length;
+
+ return (
+
+ {sections.map((section, index) => {
+ if (section.type === 'hero') {
+ return (
+
+
+
+
+
{section.eyebrow || '/generated/hero'}
+
+ {(section.badges ?? []).map((badge) => (
+ {badge}
+ ))}
+
+
+
+ {section.title}
+ {section.accent ? (
+ <>
+
+ {section.accent}
+ >
+ ) : null}
+
+ {section.description ?
{section.description}
: null}
+
+ {(section.actions ?? []).map((action) => (
+
+ {action.label}
+
+ ))}
+
+
+
+
/generated/summary
+
+
+
{loading ? '…' : Object.values(feeds).reduce((sum, items) => sum + items.length, 0)}
+
Feed items
+
+
+
{collectionCount}
+
Collections
+
+
+
+
+
+ );
+ }
+
+ if (section.type === 'tokenList') {
+ const tokenPage = getGeneratedTokenPage(section.tokenPage);
+ const feed = tokenPage?.feed ? getGeneratedFeed(tokenPage.feed) : null;
+ const feedItems = feed ? feeds[feed.id] ?? [] : [];
+ const trending = tokenPage && feed ? collectGeneratedTrendingTokens(feedItems, tokenPage.field, 10) : [];
+ return (
+
+
+
+ /generated/tokens
+
{section.title}
+
+
+
+ {trending.length ? trending.map((entry) => (
+
+ #{entry.token} · {entry.count}
+
+ )) : {section.emptyBody || 'No indexed tokens yet.'} }
+
+
+ );
+ }
+
+ const feed = getGeneratedFeed(section.feed);
+ if (!feed) return null;
+ return (
+
+
+
+ /generated/feed
+
{section.title}
+
+
+ {loading ? (
+
+ /feed/loading
+ Loading feed…
+
+ ) : error ? (
+
+ /feed/error
+ {error}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/templates/next-export-ui/src/components/GeneratedTokenPageClient.tsx b/packages/templates/next-export-ui/src/components/GeneratedTokenPageClient.tsx
new file mode 100644
index 0000000..2532634
--- /dev/null
+++ b/packages/templates/next-export-ui/src/components/GeneratedTokenPageClient.tsx
@@ -0,0 +1,107 @@
+'use client';
+
+import Link from 'next/link';
+import { useEffect, useState } from 'react';
+import { useSearchParams } from 'next/navigation';
+
+import GeneratedFeedStream from './GeneratedFeedStream';
+import {
+ generatedTokenPages,
+ getGeneratedFeed,
+ getGeneratedTokenPage,
+ loadGeneratedTokenFeed,
+ loadGeneratedUiRuntime,
+ sortGeneratedFeedItemsDesc,
+ type GeneratedFeedItem
+} from '../lib/generated-ui';
+
+type TokenState = {
+ token: string;
+ items: GeneratedFeedItem[];
+ loading: boolean;
+ error: string | null;
+};
+
+export default function GeneratedTokenPageClient() {
+ const searchParams = useSearchParams();
+ const pageId = String(searchParams.get('page') ?? '').trim();
+ const value = String(searchParams.get('value') ?? '').trim();
+ const tokenPage = getGeneratedTokenPage(pageId || generatedTokenPages()[0]?.id || '');
+ const feed = tokenPage?.feed ? getGeneratedFeed(tokenPage.feed) : null;
+ const [state, setState] = useState({ token: '', items: [], loading: true, error: null });
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ if (!tokenPage || !value) {
+ setState({ token: '', items: [], loading: false, error: null });
+ return;
+ }
+ try {
+ const runtime = await loadGeneratedUiRuntime();
+ const result = await loadGeneratedTokenFeed(runtime, tokenPage, value);
+ if (cancelled) return;
+ setState({ token: result.token, items: sortGeneratedFeedItemsDesc(result.items), loading: false, error: null });
+ } catch (cause: any) {
+ if (cancelled) return;
+ setState({ token: '', items: [], loading: false, error: String(cause?.message ?? cause) });
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [tokenPage, value]);
+
+ return (
+
+
+
+
+
+
/generated/token-page
+
+ {state.token ? `#${state.token}` : 'no token selected'}
+ tokenized index read
+
+
+
+ {tokenPage?.title || 'Token feed'}
+
+ {state.token ? `#${state.token}` : 'resolved from generated config'}
+
+
+ Back to home
+ Create record
+
+
+
+
+
+ {!value ? (
+
+ /token/missing
+ No token selected
+ Open a generated token link or use a route like /tag?page={pageId || tokenPage?.id || 'hashtags'}&value=tokenhost .
+
+ ) : state.loading ? (
+
+ /token/loading
+ Loading token feed…
+
+ ) : state.error ? (
+
+ /token/error
+ {state.error}
+
+ ) : feed ? (
+
+ ) : null}
+
+ );
+}
diff --git a/packages/templates/next-export-ui/src/lib/generated-ui.ts b/packages/templates/next-export-ui/src/lib/generated-ui.ts
new file mode 100644
index 0000000..22db7b3
--- /dev/null
+++ b/packages/templates/next-export-ui/src/lib/generated-ui.ts
@@ -0,0 +1,138 @@
+'use client';
+
+import { extractHashtagTokens } from './indexing';
+import { resolveReferenceRecords, recordSummary } from './relations';
+import { listAllRecords, listHashtagRecords, loadAppRuntime, type AppRuntime } from './runtime';
+import { ths } from './ths';
+
+export type GeneratedFeedConfig = NonNullable['generated']>['feeds'] extends Array ? T : never;
+export type GeneratedTokenPageConfig = NonNullable['generated']>['tokenPages'] extends Array ? T : never;
+export type GeneratedHomeSection = NonNullable['generated']>['homeSections'] extends Array ? T : never;
+
+export type GeneratedFeedItem = {
+ id: bigint;
+ record: any;
+ referenceId: bigint | null;
+ referenceRecord: any | null;
+};
+
+export function generatedFeeds(): GeneratedFeedConfig[] {
+ return Array.isArray(ths.app.ui?.generated?.feeds) ? ths.app.ui.generated.feeds : [];
+}
+
+export function generatedTokenPages(): GeneratedTokenPageConfig[] {
+ return Array.isArray(ths.app.ui?.generated?.tokenPages) ? ths.app.ui.generated.tokenPages : [];
+}
+
+export function generatedHomeSections(): GeneratedHomeSection[] {
+ return Array.isArray(ths.app.ui?.generated?.homeSections) ? ths.app.ui.generated.homeSections : [];
+}
+
+export function getGeneratedFeed(id: string): GeneratedFeedConfig | null {
+ return generatedFeeds().find((feed) => feed.id === id) ?? null;
+}
+
+export function getGeneratedTokenPage(id: string): GeneratedTokenPageConfig | null {
+ return generatedTokenPages().find((page) => page.id === id) ?? null;
+}
+
+export async function loadGeneratedFeed(runtime: AppRuntime, feed: GeneratedFeedConfig): Promise {
+ const page = await listAllRecords({
+ manifest: runtime.manifest,
+ publicClient: runtime.publicClient,
+ abi: runtime.abi,
+ address: runtime.appAddress,
+ collectionName: feed.collection,
+ pageSize: feed.limit ?? 25
+ });
+
+ const baseItems = page.ids.map((id, index) => ({ id, record: page.records[index] }));
+ const referenceField = feed.card?.referenceField;
+ if (!referenceField) {
+ return baseItems.map((item) => ({ ...item, referenceId: null, referenceRecord: null }));
+ }
+
+ const collection = ths.collections.find((candidate) => candidate.name === feed.collection);
+ const relation = collection?.relations?.find((entry) => entry.field === referenceField);
+ if (!relation?.to) {
+ return baseItems.map((item) => ({ ...item, referenceId: null, referenceRecord: null }));
+ }
+
+ return resolveReferenceRecords(runtime, baseItems, {
+ fieldName: referenceField,
+ targetCollectionName: relation.to
+ });
+}
+
+export async function loadGeneratedTokenFeed(runtime: AppRuntime, tokenPage: GeneratedTokenPageConfig, value: string): Promise<{ token: string; items: GeneratedFeedItem[] }> {
+ const feed = tokenPage.feed ? getGeneratedFeed(tokenPage.feed) : null;
+ const page = await listHashtagRecords({
+ manifest: runtime.manifest,
+ publicClient: runtime.publicClient,
+ abi: runtime.abi,
+ address: runtime.appAddress,
+ collectionName: tokenPage.collection,
+ fieldName: tokenPage.field,
+ hashtag: value,
+ pageSize: tokenPage.limit ?? feed?.limit ?? 25
+ });
+
+ const baseItems = page.ids.map((id, index) => ({ id, record: page.records[index] }));
+ if (!feed?.card?.referenceField) {
+ return {
+ token: page.hashtag,
+ items: baseItems.map((item) => ({ ...item, referenceId: null, referenceRecord: null }))
+ };
+ }
+
+ const collection = ths.collections.find((candidate) => candidate.name === tokenPage.collection);
+ const relation = collection?.relations?.find((entry) => entry.field === feed.card?.referenceField);
+ if (!relation?.to) {
+ return {
+ token: page.hashtag,
+ items: baseItems.map((item) => ({ ...item, referenceId: null, referenceRecord: null }))
+ };
+ }
+
+ return {
+ token: page.hashtag,
+ items: await resolveReferenceRecords(runtime, baseItems, {
+ fieldName: feed.card.referenceField,
+ targetCollectionName: relation.to
+ })
+ };
+}
+
+export function sortGeneratedFeedItemsDesc(items: GeneratedFeedItem[]): GeneratedFeedItem[] {
+ return [...items].sort((a, b) => (a.id === b.id ? 0 : a.id < b.id ? 1 : -1));
+}
+
+export function collectGeneratedTrendingTokens(items: GeneratedFeedItem[], textField: string, limit = 8): Array<{ token: string; count: number }> {
+ const counts = new Map();
+ for (const item of items) {
+ for (const token of extractHashtagTokens(String(item.record?.[textField] ?? ''))) {
+ counts.set(token, (counts.get(token) ?? 0) + 1);
+ }
+ }
+ return Array.from(counts.entries())
+ .sort((a, b) => (b[1] !== a[1] ? b[1] - a[1] : a[0].localeCompare(b[0])))
+ .slice(0, limit)
+ .map(([token, count]) => ({ token, count }));
+}
+
+export function feedCardSummary(item: GeneratedFeedItem, feed: GeneratedFeedConfig) {
+ const referenceSummary = item.referenceRecord ? recordSummary(item.referenceRecord) : null;
+ const textField = feed.card?.textField ?? 'body';
+ const mediaField = feed.card?.mediaField ?? 'image';
+ return {
+ title: referenceSummary?.title ?? `Record #${String(item.id)}`,
+ subtitle: referenceSummary?.subtitle ?? null,
+ body: String(item.record?.[textField] ?? '').trim(),
+ mediaUrl: String(item.record?.[mediaField] ?? '').trim() || null,
+ imageUrl: referenceSummary?.imageUrl ?? null
+ };
+}
+
+export async function loadGeneratedUiRuntime(): Promise {
+ return loadAppRuntime();
+}
diff --git a/packages/templates/next-export-ui/src/lib/ths.ts b/packages/templates/next-export-ui/src/lib/ths.ts
index 3cd4bfd..0208f68 100644
--- a/packages/templates/next-export-ui/src/lib/ths.ts
+++ b/packages/templates/next-export-ui/src/lib/ths.ts
@@ -86,6 +86,53 @@ export interface ThsSchema {
extensions?: {
directory?: string;
};
+ generated?: {
+ feeds?: Array<{
+ id: string;
+ collection: string;
+ limit?: number;
+ card?: {
+ referenceField?: string;
+ textField?: string;
+ mediaField?: string;
+ };
+ }>;
+ tokenPages?: Array<{
+ id: string;
+ collection: string;
+ field: string;
+ tokenizer: 'hashtag';
+ feed?: string;
+ limit?: number;
+ title?: string;
+ emptyTitle?: string;
+ emptyBody?: string;
+ }>;
+ homeSections?: Array<
+ | {
+ type: 'hero';
+ eyebrow?: string;
+ title: string;
+ accent?: string;
+ description?: string;
+ badges?: string[];
+ actions?: Array<{ label: string; href: string; variant?: 'default' | 'primary' }>;
+ }
+ | {
+ type: 'feed';
+ feed: string;
+ title: string;
+ emptyTitle?: string;
+ emptyBody?: string;
+ }
+ | {
+ type: 'tokenList';
+ tokenPage: string;
+ title: string;
+ emptyBody?: string;
+ }
+ >;
+ };
};
};
collections: ThsCollection[];
diff --git a/schemas/tokenhost-ths.schema.json b/schemas/tokenhost-ths.schema.json
index 7a29ddd..ec9d4f9 100644
--- a/schemas/tokenhost-ths.schema.json
+++ b/schemas/tokenhost-ths.schema.json
@@ -40,6 +40,50 @@
}
}
},
+ "ui": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "homePage": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["generated", "custom"]
+ }
+ }
+ },
+ "extensions": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "directory": {
+ "type": "string",
+ "description": "Path to UI override files, resolved relative to the schema file."
+ }
+ }
+ },
+ "generated": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "feeds": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/generatedFeed" }
+ },
+ "tokenPages": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/generatedTokenPage" }
+ },
+ "homeSections": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/generatedHomeSection" }
+ }
+ }
+ }
+ }
+ },
"features": {
"type": "object",
"additionalProperties": false,
@@ -130,7 +174,20 @@
},
"ui": {
"type": "object",
- "additionalProperties": true
+ "additionalProperties": true,
+ "properties": {
+ "component": {
+ "type": "string",
+ "enum": ["default", "externalLink"]
+ },
+ "label": {
+ "type": "string"
+ },
+ "target": {
+ "type": "string",
+ "enum": ["_blank", "_self"]
+ }
+ }
}
},
"allOf": [
@@ -272,6 +329,96 @@
"enforce": { "type": "boolean" },
"reverseIndex": { "type": "boolean" }
}
+ },
+ "generatedAction": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["label", "href"],
+ "properties": {
+ "label": { "type": "string", "minLength": 1 },
+ "href": { "type": "string", "minLength": 1 },
+ "variant": { "type": "string", "enum": ["default", "primary"] }
+ }
+ },
+ "generatedFeedCard": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "referenceField": { "type": "string" },
+ "textField": { "type": "string" },
+ "mediaField": { "type": "string" }
+ }
+ },
+ "generatedFeed": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "collection"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_-]*$" },
+ "collection": { "type": "string" },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 100 },
+ "card": { "$ref": "#/$defs/generatedFeedCard" }
+ }
+ },
+ "generatedTokenPage": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "collection", "field", "tokenizer"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_-]*$" },
+ "collection": { "type": "string" },
+ "field": { "type": "string" },
+ "tokenizer": { "type": "string", "enum": ["hashtag"] },
+ "feed": { "type": "string" },
+ "limit": { "type": "integer", "minimum": 1, "maximum": 100 },
+ "title": { "type": "string" },
+ "emptyTitle": { "type": "string" },
+ "emptyBody": { "type": "string" }
+ }
+ },
+ "generatedHeroSection": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "title"],
+ "properties": {
+ "type": { "const": "hero" },
+ "eyebrow": { "type": "string" },
+ "title": { "type": "string" },
+ "accent": { "type": "string" },
+ "description": { "type": "string" },
+ "badges": { "type": "array", "items": { "type": "string" } },
+ "actions": { "type": "array", "items": { "$ref": "#/$defs/generatedAction" } }
+ }
+ },
+ "generatedFeedSection": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "feed", "title"],
+ "properties": {
+ "type": { "const": "feed" },
+ "feed": { "type": "string" },
+ "title": { "type": "string" },
+ "emptyTitle": { "type": "string" },
+ "emptyBody": { "type": "string" }
+ }
+ },
+ "generatedTokenListSection": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "tokenPage", "title"],
+ "properties": {
+ "type": { "const": "tokenList" },
+ "tokenPage": { "type": "string" },
+ "title": { "type": "string" },
+ "emptyBody": { "type": "string" }
+ }
+ },
+ "generatedHomeSection": {
+ "oneOf": [
+ { "$ref": "#/$defs/generatedHeroSection" },
+ { "$ref": "#/$defs/generatedFeedSection" },
+ { "$ref": "#/$defs/generatedTokenListSection" }
+ ]
}
}
}
diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js
index f1cd659..b2b0b63 100644
--- a/test/testCliGenerateUi.js
+++ b/test/testCliGenerateUi.js
@@ -365,7 +365,7 @@ describe('th generate (UI template)', function () {
expect(generatedThs).to.include('"directory": "ui-overrides"');
});
- it('builds the canonical microblog example UI with custom home/tag routes', function () {
+ it('builds the canonical microblog example UI from generated feed/token config', function () {
this.timeout(180000);
const schemaPath = path.join(process.cwd(), 'apps', 'example', 'microblog.schema.json');
@@ -378,10 +378,19 @@ describe('th generate (UI template)', function () {
expect(fs.existsSync(path.join(uiDir, 'app', 'page.tsx'))).to.equal(true);
expect(fs.existsSync(path.join(uiDir, 'app', 'tag', 'page.tsx'))).to.equal(true);
expect(fs.existsSync(path.join(uiDir, 'app', 'Post', 'page.tsx'))).to.equal(true);
+ expect(fs.existsSync(path.join(uiDir, 'src', 'components', 'GeneratedHomePageClient.tsx'))).to.equal(true);
+ expect(fs.existsSync(path.join(uiDir, 'src', 'components', 'GeneratedTokenPageClient.tsx'))).to.equal(true);
+ expect(fs.existsSync(path.join(uiDir, 'src', 'components', 'GeneratedFeedStream.tsx'))).to.equal(true);
+ expect(fs.existsSync(path.join(uiDir, 'src', 'components', 'MicroblogHomeClient.tsx'))).to.equal(false);
+ expect(fs.existsSync(path.join(uiDir, 'src', 'components', 'MicroblogTagClient.tsx'))).to.equal(false);
const generatedThs = fs.readFileSync(path.join(uiDir, 'src', 'generated', 'ths.ts'), 'utf-8');
expect(generatedThs).to.include('"preset": "cyber-grid"');
expect(generatedThs).to.include('"authorProfile"');
expect(generatedThs).to.not.include('"authorHandle"');
+ expect(generatedThs).to.include('"homeSections"');
+ expect(generatedThs).to.include('"tokenPages"');
+ expect(generatedThs).to.include('"feeds"');
+ expect(generatedThs).to.not.include('"extensions": {');
const install = runCmd('pnpm', ['install'], uiDir);
expect(install.status, install.stderr || install.stdout).to.equal(0);
diff --git a/test/testThsSchema.js b/test/testThsSchema.js
index 51089b4..2da72fd 100644
--- a/test/testThsSchema.js
+++ b/test/testThsSchema.js
@@ -147,6 +147,58 @@ describe('THS schema validation + lint', function () {
expect(res.ok).to.equal(true);
});
+ it('validateThsStructural accepts generated feed/token/home UI primitives', function () {
+ const input = minimalSchema({
+ app: {
+ name: 'Test App',
+ slug: 'test-app',
+ features: { uploads: true, onChainIndexing: true },
+ ui: {
+ homePage: { mode: 'generated' },
+ generated: {
+ feeds: [
+ {
+ id: 'items',
+ collection: 'Item',
+ card: {
+ textField: 'title'
+ }
+ }
+ ],
+ tokenPages: [
+ {
+ id: 'itemTokens',
+ collection: 'Item',
+ field: 'title',
+ tokenizer: 'hashtag',
+ feed: 'items'
+ }
+ ],
+ homeSections: [
+ {
+ type: 'hero',
+ title: 'Test app'
+ },
+ {
+ type: 'tokenList',
+ tokenPage: 'itemTokens',
+ title: 'Trending tags'
+ },
+ {
+ type: 'feed',
+ feed: 'items',
+ title: 'Latest items'
+ }
+ ]
+ }
+ }
+ }
+ });
+
+ const res = validateThsStructural(input);
+ expect(res.ok).to.equal(true);
+ });
+
it('validateThsStructural rejects unknown app.theme.preset values', function () {
const input = minimalSchema({
app: {
@@ -180,6 +232,59 @@ describe('THS schema validation + lint', function () {
expect(issues.some((i) => i.code === 'lint.app.ui.custom_home_without_extensions')).to.equal(true);
});
+ it('lintThs rejects generated UI references to unknown feeds and token pages', function () {
+ const input = minimalSchema({
+ app: {
+ name: 'Test App',
+ slug: 'test-app',
+ features: { uploads: false, onChainIndexing: true },
+ ui: {
+ homePage: { mode: 'generated' },
+ generated: {
+ feeds: [
+ {
+ id: 'items',
+ collection: 'Item',
+ card: {
+ textField: 'missingField'
+ }
+ }
+ ],
+ tokenPages: [
+ {
+ id: 'itemTokens',
+ collection: 'Item',
+ field: 'missingField',
+ tokenizer: 'hashtag',
+ feed: 'missingFeed'
+ }
+ ],
+ homeSections: [
+ {
+ type: 'tokenList',
+ tokenPage: 'missingTokenPage',
+ title: 'Broken token page'
+ },
+ {
+ type: 'feed',
+ feed: 'missingFeed',
+ title: 'Broken feed'
+ }
+ ]
+ }
+ }
+ }
+ });
+
+ const res = validateThsStructural(input);
+ expect(res.ok).to.equal(true);
+ const issues = lintThs(res.data);
+ expect(issues.some((i) => i.code === 'lint.app.ui.generated.feed_unknown_field')).to.equal(true);
+ expect(issues.some((i) => i.code === 'lint.app.ui.generated.token_unknown_field')).to.equal(true);
+ expect(issues.some((i) => i.code === 'lint.app.ui.generated.section_unknown_token_page')).to.equal(true);
+ expect(issues.some((i) => i.code === 'lint.app.ui.generated.section_unknown_feed')).to.equal(true);
+ });
+
it('validateThsStructural accepts tokenized query index primitives', function () {
const input = minimalSchema({
collections: [
From 12999e54e3f8b62e41c59b18f7d76b3097f23c96 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 10:34:56 -1000
Subject: [PATCH 08/24] Gate create forms on missing references
---
.../app/[collection]/new/ClientPage.tsx | 44 +++++++++
.../next-export-ui/src/lib/relations.ts | 98 +++++++++++++++++++
test/testCliGenerateUi.js | 3 +
3 files changed, 145 insertions(+)
diff --git a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
index 79b81d5..99d230f 100644
--- a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
@@ -14,6 +14,7 @@ import { submitWriteTx } from '../../../src/lib/tx';
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
import ImageFieldInput from '../../../src/components/ImageFieldInput';
import ReferenceFieldInput from '../../../src/components/ReferenceFieldInput';
+import { useRequiredReferenceCreationGates } from '../../../src/lib/relations';
function inputType(field: ThsField): 'text' | 'number' {
if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal') return 'number';
@@ -108,6 +109,16 @@ export default function CreateRecordPage(props: { params: { collection: string }
const required = requiredFieldNames(collection);
const payment = hasCreatePayment(collection);
const uploadBusy = Object.values(busyUploads).some(Boolean);
+ const requiredReferenceFields = fields.filter((field) => field.type === 'reference' && required.has(field.name));
+ const referenceCreationGates = useRequiredReferenceCreationGates({
+ manifest,
+ publicClient,
+ abi,
+ address: appAddress,
+ collection,
+ requiredFieldNames: required
+ });
+ const activeReferenceGate = referenceCreationGates.blockers[0] ?? null;
async function submit() {
if (!manifest || !deployment || !abi || !publicClient || !appAddress) return;
@@ -185,6 +196,39 @@ export default function CreateRecordPage(props: { params: { collection: string }
);
}
+ if (requiredReferenceFields.length > 0 && referenceCreationGates.loading) {
+ return (
+
+
Create {collection.name}
+
Checking required linked records before showing the form.
+
+ );
+ }
+
+ if (activeReferenceGate?.relatedCollection) {
+ return (
+
+
Create {collection.name}
+
/create/gated
+
+ You must create a {activeReferenceGate.relatedCollection.name} before creating this {collection.name}.
+
+
+ This form requires a linked {activeReferenceGate.relatedCollection.name} via{' '}
+ {activeReferenceGate.fieldName} , and there are no {activeReferenceGate.relatedCollection.name} records yet.
+
+
+ router.push(`/${activeReferenceGate.relatedCollection.name}/?mode=new`)}>
+ Create {activeReferenceGate.relatedCollection.name}
+
+ router.push(`/${activeReferenceGate.relatedCollection.name}/`)}>
+ Browse {activeReferenceGate.relatedCollection.name}
+
+
+
+ );
+ }
+
return (
Create {collection.name}
diff --git a/packages/templates/next-export-ui/src/lib/relations.ts b/packages/templates/next-export-ui/src/lib/relations.ts
index ff877ca..3e09f40 100644
--- a/packages/templates/next-export-ui/src/lib/relations.ts
+++ b/packages/templates/next-export-ui/src/lib/relations.ts
@@ -27,6 +27,14 @@ export type ReferenceOption = {
record: any;
};
+export type ReferenceCreationGate = {
+ fieldName: string;
+ relatedCollection: ReturnType
;
+ count: number;
+ loading: boolean;
+ error: string | null;
+};
+
export function getRecordId(value: unknown): bigint | null {
if (typeof value === 'bigint') return value;
if (typeof value === 'number' && Number.isInteger(value)) return BigInt(value);
@@ -264,3 +272,93 @@ export function useOwnedReferenceOptions(args: {
selectedOption
};
}
+
+export function useRequiredReferenceCreationGates(args: {
+ manifest: any;
+ publicClient: any;
+ abi: any[] | null;
+ address: `0x${string}` | undefined;
+ collection: ReturnType | null;
+ requiredFieldNames: Set;
+}) {
+ const [gates, setGates] = useState([]);
+
+ const requiredReferenceTargets = useMemo(() => {
+ if (!args.collection) return [];
+ return args.collection.fields
+ .filter((field) => field.type === 'reference' && args.requiredFieldNames.has(field.name))
+ .map((field) => {
+ const relation = (args.collection?.relations ?? []).find((entry) => entry.field === field.name) ?? null;
+ const relatedCollection = relation?.to ? getCollection(relation.to) : null;
+ if (!relatedCollection) return null;
+ return {
+ fieldName: field.name,
+ relatedCollection
+ };
+ })
+ .filter(Boolean) as Array<{ fieldName: string; relatedCollection: NonNullable> }>;
+ }, [args.collection, args.requiredFieldNames]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function load() {
+ if (!requiredReferenceTargets.length || !args.publicClient || !args.abi || !args.address) {
+ setGates([]);
+ return;
+ }
+
+ setGates(
+ requiredReferenceTargets.map((entry) => ({
+ fieldName: entry.fieldName,
+ relatedCollection: entry.relatedCollection,
+ count: 0,
+ loading: true,
+ error: null
+ }))
+ );
+
+ const next: ReferenceCreationGate[] = [];
+ for (const entry of requiredReferenceTargets) {
+ try {
+ const page = await listAllRecords({
+ manifest: args.manifest,
+ publicClient: args.publicClient,
+ abi: args.abi,
+ address: args.address,
+ collectionName: entry.relatedCollection.name
+ });
+ next.push({
+ fieldName: entry.fieldName,
+ relatedCollection: entry.relatedCollection,
+ count: page.ids.length,
+ loading: false,
+ error: null
+ });
+ } catch (cause: any) {
+ next.push({
+ fieldName: entry.fieldName,
+ relatedCollection: entry.relatedCollection,
+ count: 0,
+ loading: false,
+ error: String(cause?.message ?? cause)
+ });
+ }
+ }
+
+ if (!cancelled) setGates(next);
+ }
+
+ void load();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [args.abi, args.address, args.manifest, args.publicClient, requiredReferenceTargets]);
+
+ return {
+ gates,
+ loading: gates.some((gate) => gate.loading),
+ blockers: gates.filter((gate) => !gate.loading && !gate.error && gate.count === 0)
+ };
+}
diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js
index b2b0b63..da326db 100644
--- a/test/testCliGenerateUi.js
+++ b/test/testCliGenerateUi.js
@@ -294,11 +294,14 @@ describe('th generate (UI template)', function () {
expect(generatedRelations).to.include('resolveReferenceRecords');
expect(generatedRelations).to.include('listOwnedRecords');
expect(generatedRelations).to.include('useOwnedReferenceOptions');
+ expect(generatedRelations).to.include('useRequiredReferenceCreationGates');
expect(generatedRelations).to.include('TH_REFERENCE_SELECTION');
const generatedNewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'new', 'ClientPage.tsx'), 'utf-8');
expect(generatedNewPage).to.include('ReferenceFieldInput');
expect(generatedNewPage).to.include("f.type === 'reference'");
+ expect(generatedNewPage).to.include('You must create a');
+ expect(generatedNewPage).to.include('Checking required linked records before showing the form.');
expect(generatedNewPage).to.include('Waiting for media upload…');
const generatedEditPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'edit', 'ClientPage.tsx'), 'utf-8');
From 1599dc16313f95b109a7eec043f73c4b72a20df0 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 10:44:30 -1000
Subject: [PATCH 09/24] Use count reads for reference gating
---
.../templates/next-export-ui/src/lib/app.ts | 21 +++++++++++++++++++
.../next-export-ui/src/lib/relations.ts | 10 ++++-----
2 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/packages/templates/next-export-ui/src/lib/app.ts b/packages/templates/next-export-ui/src/lib/app.ts
index adef1ac..79800fb 100644
--- a/packages/templates/next-export-ui/src/lib/app.ts
+++ b/packages/templates/next-export-ui/src/lib/app.ts
@@ -14,6 +14,10 @@ export function fnListIds(collectionName: string): string {
return `listIds${collectionName}`;
}
+export function fnGetCount(collectionName: string): string {
+ return `getCount${collectionName}`;
+}
+
export function fnGet(collectionName: string): string {
// viem expects the function name (not full signature) and resolves overloads from args.
return `get${collectionName}`;
@@ -202,6 +206,23 @@ export async function listRecords(args: {
return { ids, records };
}
+export async function countRecords(args: {
+ publicClient: any;
+ abi: any[];
+ address: `0x${string}`;
+ collectionName: string;
+ includeDeleted?: boolean;
+}): Promise {
+ assertAbiFunction(args.abi, fnGetCount(args.collectionName), args.collectionName);
+
+ return (await args.publicClient.readContract({
+ address: args.address,
+ abi: args.abi,
+ functionName: fnGetCount(args.collectionName),
+ args: [Boolean(args.includeDeleted)]
+ })) as bigint;
+}
+
export async function listRecordsByIndex(args: {
publicClient: any;
abi: any[];
diff --git a/packages/templates/next-export-ui/src/lib/relations.ts b/packages/templates/next-export-ui/src/lib/relations.ts
index 3e09f40..cc54347 100644
--- a/packages/templates/next-export-ui/src/lib/relations.ts
+++ b/packages/templates/next-export-ui/src/lib/relations.ts
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from 'react';
-import { readRecordsByIds } from './app';
+import { countRecords, readRecordsByIds } from './app';
import { displayField, getCollection } from './ths';
import { formatFieldValue } from './format';
import type { AppRuntime } from './runtime';
@@ -321,17 +321,17 @@ export function useRequiredReferenceCreationGates(args: {
const next: ReferenceCreationGate[] = [];
for (const entry of requiredReferenceTargets) {
try {
- const page = await listAllRecords({
- manifest: args.manifest,
+ const count = await countRecords({
publicClient: args.publicClient,
abi: args.abi,
address: args.address,
- collectionName: entry.relatedCollection.name
+ collectionName: entry.relatedCollection.name,
+ includeDeleted: false
});
next.push({
fieldName: entry.fieldName,
relatedCollection: entry.relatedCollection,
- count: page.ids.length,
+ count: Number(count),
loading: false,
error: null
});
From 3852e290ef5b0b193d13f5525e8fced0369b66b6 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 10:47:35 -1000
Subject: [PATCH 10/24] Remove animated cyber grid layer
---
.../templates/next-export-ui/app/globals.css | 52 -------------------
1 file changed, 52 deletions(-)
diff --git a/packages/templates/next-export-ui/app/globals.css b/packages/templates/next-export-ui/app/globals.css
index ae96912..73f5af6 100644
--- a/packages/templates/next-export-ui/app/globals.css
+++ b/packages/templates/next-export-ui/app/globals.css
@@ -184,11 +184,6 @@ svg {
mask-composite: add;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
- animation:
- boilSpotOne 13s ease-in-out infinite alternate,
- boilSpotTwo 17s ease-in-out infinite alternate,
- boilSpotThree 19s ease-in-out infinite alternate,
- gridBreathe 6s ease-in-out infinite;
}
.siteLivingGridCanvas {
@@ -1368,53 +1363,6 @@ html[data-theme='dark'] .btn.primary {
}
}
-@keyframes gridBreathe {
- 0%,
- 100% {
- opacity: 0.2;
- }
-
- 50% {
- opacity: 0.5;
- }
-}
-
-@keyframes boilSpotOne {
- 0% {
- --th-boil-x1: 10%;
- --th-boil-y1: 20%;
- }
-
- 100% {
- --th-boil-x1: 80%;
- --th-boil-y1: 40%;
- }
-}
-
-@keyframes boilSpotTwo {
- 0% {
- --th-boil-x2: 90%;
- --th-boil-y2: 80%;
- }
-
- 100% {
- --th-boil-x2: 20%;
- --th-boil-y2: 30%;
- }
-}
-
-@keyframes boilSpotThree {
- 0% {
- --th-boil-x3: 40%;
- --th-boil-y3: 40%;
- }
-
- 100% {
- --th-boil-x3: 60%;
- --th-boil-y3: 60%;
- }
-}
-
@keyframes panelEnter {
from {
opacity: 0;
From b30889595983582e3f6f4cd420b18825f75c667d Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 10:56:47 -1000
Subject: [PATCH 11/24] Stabilize reference gate reads
---
.../app/[collection]/new/ClientPage.tsx | 13 ++++++++-----
.../templates/next-export-ui/src/lib/relations.ts | 15 +++++++++------
2 files changed, 17 insertions(+), 11 deletions(-)
diff --git a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
index 99d230f..5fce8f4 100644
--- a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
@@ -105,18 +105,21 @@ export default function CreateRecordPage(props: { params: { collection: string }
);
}
- const fields = createFields(collection);
- const required = requiredFieldNames(collection);
+ const fields = useMemo(() => createFields(collection), [collection]);
+ const required = useMemo(() => requiredFieldNames(collection), [collection]);
+ const requiredReferenceFieldNames = useMemo(
+ () => fields.filter((field) => field.type === 'reference' && required.has(field.name)).map((field) => field.name),
+ [fields, required]
+ );
const payment = hasCreatePayment(collection);
const uploadBusy = Object.values(busyUploads).some(Boolean);
- const requiredReferenceFields = fields.filter((field) => field.type === 'reference' && required.has(field.name));
const referenceCreationGates = useRequiredReferenceCreationGates({
manifest,
publicClient,
abi,
address: appAddress,
collection,
- requiredFieldNames: required
+ requiredFieldNames: requiredReferenceFieldNames
});
const activeReferenceGate = referenceCreationGates.blockers[0] ?? null;
@@ -196,7 +199,7 @@ export default function CreateRecordPage(props: { params: { collection: string }
);
}
- if (requiredReferenceFields.length > 0 && referenceCreationGates.loading) {
+ if (requiredReferenceFieldNames.length > 0 && referenceCreationGates.loading) {
return (
Create {collection.name}
diff --git a/packages/templates/next-export-ui/src/lib/relations.ts b/packages/templates/next-export-ui/src/lib/relations.ts
index cc54347..6a81cf1 100644
--- a/packages/templates/next-export-ui/src/lib/relations.ts
+++ b/packages/templates/next-export-ui/src/lib/relations.ts
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { countRecords, readRecordsByIds } from './app';
import { displayField, getCollection } from './ths';
@@ -156,6 +156,9 @@ export function useOwnedReferenceOptions(args: {
const [loading, setLoading] = useState(false);
const [error, setError] = useState
(null);
const [options, setOptions] = useState([]);
+ const onChangeRef = useRef(args.onChange);
+
+ onChangeRef.current = args.onChange;
const collection = useMemo(() => getCollection(args.collectionName), [args.collectionName]);
const relation = useMemo(
@@ -227,9 +230,9 @@ export function useOwnedReferenceOptions(args: {
preferred = '';
}
if (preferred && nextOptions.some((option) => String(option.id) === preferred)) {
- args.onChange(preferred);
+ onChangeRef.current(preferred);
} else if (ownedOptions.length === 1) {
- args.onChange(String(ownedOptions[0]?.id ?? ''));
+ onChangeRef.current(String(ownedOptions[0]?.id ?? ''));
}
}
} catch (cause: any) {
@@ -246,7 +249,7 @@ export function useOwnedReferenceOptions(args: {
return () => {
cancelled = true;
};
- }, [account, args.abi, args.address, args.collectionName, args.fieldName, args.manifest, args.onChange, args.publicClient, args.value, relatedCollection]);
+ }, [account, args.abi, args.address, args.collectionName, args.fieldName, args.manifest, args.publicClient, args.value, relatedCollection]);
useEffect(() => {
if (!account || !args.value) return;
@@ -279,14 +282,14 @@ export function useRequiredReferenceCreationGates(args: {
abi: any[] | null;
address: `0x${string}` | undefined;
collection: ReturnType | null;
- requiredFieldNames: Set;
+ requiredFieldNames: string[];
}) {
const [gates, setGates] = useState([]);
const requiredReferenceTargets = useMemo(() => {
if (!args.collection) return [];
return args.collection.fields
- .filter((field) => field.type === 'reference' && args.requiredFieldNames.has(field.name))
+ .filter((field) => field.type === 'reference' && args.requiredFieldNames.includes(field.name))
.map((field) => {
const relation = (args.collection?.relations ?? []).find((entry) => entry.field === field.name) ?? null;
const relatedCollection = relation?.to ? getCollection(relation.to) : null;
From 14fd41384e3576b9fa52c39d6de60b0d9979430b Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 11:04:58 -1000
Subject: [PATCH 12/24] Polish profile labels and create gating
---
apps/example/microblog.schema.json | 2 +-
.../next-export-ui/app/[collection]/edit/ClientPage.tsx | 4 ++--
.../next-export-ui/app/[collection]/new/ClientPage.tsx | 9 +++------
.../next-export-ui/app/[collection]/view/ClientPage.tsx | 4 ++--
packages/templates/next-export-ui/app/page.tsx | 6 +++---
.../src/collection-route/CollectionLayout.tsx | 6 +++---
.../next-export-ui/src/components/RecordCard.tsx | 6 ++++--
packages/templates/next-export-ui/src/lib/ths.ts | 5 +++++
8 files changed, 23 insertions(+), 19 deletions(-)
diff --git a/apps/example/microblog.schema.json b/apps/example/microblog.schema.json
index cc1523f..de6ec2a 100644
--- a/apps/example/microblog.schema.json
+++ b/apps/example/microblog.schema.json
@@ -72,7 +72,7 @@
{
"name": "Profile",
"fields": [
- { "name": "handle", "type": "string", "required": true },
+ { "name": "handle", "type": "string", "required": true, "ui": { "label": "username" } },
{ "name": "displayName", "type": "string" },
{ "name": "bio", "type": "string" },
{ "name": "avatar", "type": "image" }
diff --git a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
index 4ed4fe4..8a4950f 100644
--- a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
@@ -9,7 +9,7 @@ import { chainFromId } from '../../../src/lib/chains';
import { chainWithRpcOverride, makePublicClient } from '../../../src/lib/clients';
import { formatNumeric, parseFieldValue } from '../../../src/lib/format';
import { fetchManifest, getPrimaryDeployment, getReadRpcUrl } from '../../../src/lib/manifest';
-import { getCollection, mutableFields, type ThsCollection, type ThsField } from '../../../src/lib/ths';
+import { fieldDisplayName, getCollection, mutableFields, type ThsCollection, type ThsField } from '../../../src/lib/ths';
import { submitWriteTx } from '../../../src/lib/tx';
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
import ImageFieldInput from '../../../src/components/ImageFieldInput';
@@ -305,7 +305,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
{fields.map((f) => (
- {f.name}
+ {fieldDisplayName(f)}
{f.type === 'bool' ? (
router.push(`/${activeReferenceGate.relatedCollection.name}/?mode=new`)}>
Create {activeReferenceGate.relatedCollection.name}
- router.push(`/${activeReferenceGate.relatedCollection.name}/`)}>
- Browse {activeReferenceGate.relatedCollection.name}
-
);
@@ -247,7 +244,7 @@ export default function CreateRecordPage(props: { params: { collection: string }
{fields.map((f) => (
- {f.name} {required.has(f.name) ? required : null}
+ {fieldDisplayName(f)} {required.has(f.name) ? required : null}
{f.type === 'bool' ? (
- {f.name}
+ {fieldDisplayName(f)}
{renderFieldValue({ collection, field: f, rendered, raw: v, abi, publicClient, address: appAddress })}
);
diff --git a/packages/templates/next-export-ui/app/page.tsx b/packages/templates/next-export-ui/app/page.tsx
index cf33e41..ba2b731 100644
--- a/packages/templates/next-export-ui/app/page.tsx
+++ b/packages/templates/next-export-ui/app/page.tsx
@@ -1,7 +1,7 @@
import Link from 'next/link';
import GeneratedHomePageClient from '../src/components/GeneratedHomePageClient';
-import { displayField, hasCreatePayment, mutableFields, ths, transferEnabled } from '../src/lib/ths';
+import { displayField, fieldDisplayName, hasCreatePayment, mutableFields, ths, transferEnabled } from '../src/lib/ths';
export default function HomePage() {
if (Array.isArray(ths.app.ui?.generated?.homeSections) && ths.app.ui.generated.homeSections.length > 0) {
@@ -136,7 +136,7 @@ export default function HomePage() {
{collection.name}
{collection.fields.length} field{collection.fields.length === 1 ? '' : 's'}
- {display ? ` · display field ${display.name}` : ''}
+ {display ? ` · display field ${fieldDisplayName(display)}` : ''}
{collection.plural || collection.name}
@@ -155,7 +155,7 @@ export default function HomePage() {
{fieldPreview.map((field) => (
- {field.name}
+ {fieldDisplayName(field)}
))}
{collection.fields.length > fieldPreview.length ?
+{collection.fields.length - fieldPreview.length} more : null}
diff --git a/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx b/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
index 08d6913..3fdd90d 100644
--- a/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
+++ b/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
-import { displayField, hasCreatePayment, mutableFields, ths, transferEnabled } from '../lib/ths';
+import { displayField, fieldDisplayName, hasCreatePayment, mutableFields, ths, transferEnabled } from '../lib/ths';
export default function CollectionLayout(props: { children: ReactNode; collectionName: string }) {
const collection = ths.collections.find((c) => c.name === props.collectionName);
@@ -43,7 +43,7 @@ export default function CollectionLayout(props: { children: ReactNode; collectio
{collection.fields.slice(0, 6).map((field) => (
- {field.name}
+ {fieldDisplayName(field)}
))}
@@ -63,7 +63,7 @@ export default function CollectionLayout(props: { children: ReactNode; collectio
- display {displayField(collection)?.name ?? 'auto'}
+ display {displayField(collection) ? fieldDisplayName(displayField(collection)!) : 'auto'}
{hasCreatePayment(collection) ? paid create : free create }
diff --git a/packages/templates/next-export-ui/src/components/RecordCard.tsx b/packages/templates/next-export-ui/src/components/RecordCard.tsx
index 3b6f4d5..e2ef8b1 100644
--- a/packages/templates/next-export-ui/src/components/RecordCard.tsx
+++ b/packages/templates/next-export-ui/src/components/RecordCard.tsx
@@ -1,7 +1,7 @@
import Link from 'next/link';
import type { ThsCollection, ThsField } from '../lib/ths';
-import { displayField, fieldLinkUi } from '../lib/ths';
+import { displayField, fieldDisplayName, fieldLinkUi } from '../lib/ths';
import { formatFieldValue, shortAddress } from '../lib/format';
import ResolvedReferenceValue from './ResolvedReferenceValue';
@@ -82,7 +82,9 @@ export default function RecordCard(props: {
{previewFields.map((field) => (
-
{field.name}
+
+ {fieldDisplayName(collection.fields.find((candidate) => candidate.name === field.name) ?? { name: field.name, type: 'string' })}
+
{field.type === 'image' ? (
// eslint-disable-next-line @next/next/no-img-element
diff --git a/packages/templates/next-export-ui/src/lib/ths.ts b/packages/templates/next-export-ui/src/lib/ths.ts
index 0208f68..c919953 100644
--- a/packages/templates/next-export-ui/src/lib/ths.ts
+++ b/packages/templates/next-export-ui/src/lib/ths.ts
@@ -203,3 +203,8 @@ export function fieldLinkUi(field: ThsField): { label: string | null; target: '_
target: field.ui.target === '_self' ? '_self' : '_blank'
};
}
+
+export function fieldDisplayName(field: ThsField): string {
+ if (typeof field.ui?.label === 'string' && field.ui.label.trim()) return field.ui.label.trim();
+ return field.name;
+}
From 990537159a93c7965dad5b222678be5b7cf86715 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 12:15:51 -1000
Subject: [PATCH 13/24] Enforce owned references in core model
---
apps/example/microblog.schema.json | 2 +-
.../src/solidity/generateAppSolidity.ts | 21 +++++
.../schema/schemas/tokenhost-ths.schema.json | 1 +
packages/schema/src/types.ts | 1 +
.../app/[collection]/new/ClientPage.tsx | 11 ++-
.../src/components/ReferenceFieldInput.tsx | 34 ++++---
.../next-export-ui/src/lib/relations.ts | 92 ++++++++++++++++---
.../templates/next-export-ui/src/lib/ths.ts | 1 +
schemas/tokenhost-ths.schema.json | 1 +
test/testCrudGenerator.js | 49 ++++++++++
test/testThsSchema.js | 29 ++++++
11 files changed, 211 insertions(+), 31 deletions(-)
diff --git a/apps/example/microblog.schema.json b/apps/example/microblog.schema.json
index de6ec2a..56f5c42 100644
--- a/apps/example/microblog.schema.json
+++ b/apps/example/microblog.schema.json
@@ -107,7 +107,7 @@
]
},
"relations": [
- { "field": "authorProfile", "to": "Profile", "enforce": true, "reverseIndex": true }
+ { "field": "authorProfile", "to": "Profile", "enforce": true, "mustOwn": true, "reverseIndex": true }
]
}
]
diff --git a/packages/generator/src/solidity/generateAppSolidity.ts b/packages/generator/src/solidity/generateAppSolidity.ts
index 42b7d06..8650612 100644
--- a/packages/generator/src/solidity/generateAppSolidity.ts
+++ b/packages/generator/src/solidity/generateAppSolidity.ts
@@ -193,6 +193,7 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
w.line('error VersionMismatch(uint256 expected, uint256 got);');
w.line('error TooManyIndexTokens();');
w.line('error IndexTokenTooLong();');
+ w.line('error RelatedRecordNotOwned();');
w.line();
// Events (SPEC 7.9)
@@ -453,6 +454,14 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
});
w.line();
+ w.block(`function _requireOwned${C}(uint256 id) internal view`, () => {
+ w.line(`${record} storage r = ${cVar}Records[id];`);
+ w.line('if (r.createdBy == address(0)) revert RecordNotFound();');
+ w.line('if (r.isDeleted) revert RecordIsDeleted();');
+ w.line('if (r.owner != _msgSender()) revert RelatedRecordNotOwned();');
+ });
+ w.line();
+
w.block(`function getCount${C}(bool includeDeleted) external view returns (uint256)`, () => {
w.line('if (includeDeleted) {');
w.line(` return nextId${C} - 1;`);
@@ -597,6 +606,9 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
for (const rel of c.relations.filter((r) => r.enforce)) {
w.line(`_requireExists${rel.to}(input.${rel.field});`);
}
+ for (const rel of c.relations.filter((r) => r.mustOwn)) {
+ w.line(`_requireOwned${rel.to}(input.${rel.field});`);
+ }
}
// uniqueness enforcement
@@ -675,6 +687,15 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
w.line('if (r.version != expectedVersion) revert VersionMismatch(expectedVersion, r.version);');
}
+ if (c.relations) {
+ for (const rel of c.relations.filter((r) => r.enforce && c.updateRules.mutable.includes(r.field))) {
+ w.line(`_requireExists${rel.to}(${rel.field});`);
+ }
+ for (const rel of c.relations.filter((r) => r.mustOwn && c.updateRules.mutable.includes(r.field))) {
+ w.line(`_requireOwned${rel.to}(${rel.field});`);
+ }
+ }
+
// uniqueness updates for mutable unique fields
const uniqueByField = new Map(c.indexes.unique.map((u) => [u.field, u]));
for (const field of c.updateRules.mutable) {
diff --git a/packages/schema/schemas/tokenhost-ths.schema.json b/packages/schema/schemas/tokenhost-ths.schema.json
index ec9d4f9..533a28a 100644
--- a/packages/schema/schemas/tokenhost-ths.schema.json
+++ b/packages/schema/schemas/tokenhost-ths.schema.json
@@ -327,6 +327,7 @@
"field": { "type": "string" },
"to": { "type": "string" },
"enforce": { "type": "boolean" },
+ "mustOwn": { "type": "boolean" },
"reverseIndex": { "type": "boolean" }
}
},
diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts
index 8ef328d..b167110 100644
--- a/packages/schema/src/types.ts
+++ b/packages/schema/src/types.ts
@@ -187,6 +187,7 @@ export interface Relation {
field: string;
to: string;
enforce?: boolean;
+ mustOwn?: boolean;
reverseIndex?: boolean;
}
diff --git a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
index bfc5812..1f3bd5f 100644
--- a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
@@ -214,11 +214,16 @@ export default function CreateRecordPage(props: { params: { collection: string }
Create {collection.name}
/create/gated
- You must create a {activeReferenceGate.relatedCollection.name} before creating this {collection.name}.
+ {activeReferenceGate.mustOwn
+ ? `You must create a ${activeReferenceGate.relatedCollection.name} owned by your wallet before creating this ${collection.name}.`
+ : `You must create a ${activeReferenceGate.relatedCollection.name} before creating this ${collection.name}.`}
- This form requires a linked {activeReferenceGate.relatedCollection.name} via{' '}
- {activeReferenceGate.fieldName} , and there are no {activeReferenceGate.relatedCollection.name} records yet.
+ {activeReferenceGate.missingWallet
+ ? `This form requires a wallet-owned ${activeReferenceGate.relatedCollection.name} via ${activeReferenceGate.fieldName}. Connect a wallet, then create your ${activeReferenceGate.relatedCollection.name} first.`
+ : activeReferenceGate.mustOwn
+ ? `This form requires a wallet-owned ${activeReferenceGate.relatedCollection.name} via ${activeReferenceGate.fieldName}, and this wallet does not own one yet.`
+ : `This form requires a linked ${activeReferenceGate.relatedCollection.name} via ${activeReferenceGate.fieldName}, and there are no ${activeReferenceGate.relatedCollection.name} records yet.`}
router.push(`/${activeReferenceGate.relatedCollection.name}/?mode=new`)}>
diff --git a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
index b2b5c6d..5dd8898 100644
--- a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
+++ b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
@@ -18,7 +18,7 @@ export default function ReferenceFieldInput(props: {
onChange: (next: string) => void;
}) {
const { manifest, publicClient, abi, address, collection, field, value, disabled, onChange } = props;
- const { account, loading, error, options, relatedCollection, ownedOptions, selectedOption } = useOwnedReferenceOptions({
+ const { account, loading, error, options, mustOwn, relatedCollection, ownedOptions, selectedOption } = useOwnedReferenceOptions({
manifest,
publicClient,
abi,
@@ -87,27 +87,35 @@ export default function ReferenceFieldInput(props: {
) : null}
-
onChange(event.target.value)}
- placeholder={`${relatedCollection.name} record id`}
- />
+ {!mustOwn ? (
+
onChange(event.target.value)}
+ placeholder={`${relatedCollection.name} record id`}
+ />
+ ) : null}
{error
- ? `Could not load ${relatedCollection.name} records automatically. You can still enter a record id manually. ${error}`
+ ? `Could not load ${relatedCollection.name} records automatically.${mustOwn ? '' : ' You can still enter a record id manually.'} ${error}`
: options.length > 0
? ownedOptions.length > 0
- ? `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first and your last choice is remembered${account ? ` for ${account.slice(0, 6)}…` : ''}.`
+ ? mustOwn
+ ? `This relation requires a ${relatedCollection.name} owned by the connected wallet. Your owned records are shown here${account ? ` for ${account.slice(0, 6)}…` : ''}.`
+ : `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first and your last choice is remembered${account ? ` for ${account.slice(0, 6)}…` : ''}.`
: `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry.`
- : `Create a ${relatedCollection.name} record first, or enter a record id manually.`}
+ : mustOwn
+ ? account
+ ? `Create a ${relatedCollection.name} owned by the connected wallet before selecting it here.`
+ : `Connect a wallet to create or select an owned ${relatedCollection.name}.`
+ : `Create a ${relatedCollection.name} record first, or enter a record id manually.`}
{!loading && options.length === 0 ? (
Create {relatedCollection.name}
- Browse {relatedCollection.name}
+ {!mustOwn ? Browse {relatedCollection.name} : null}
) : null}
>
diff --git a/packages/templates/next-export-ui/src/lib/relations.ts b/packages/templates/next-export-ui/src/lib/relations.ts
index 6a81cf1..3315008 100644
--- a/packages/templates/next-export-ui/src/lib/relations.ts
+++ b/packages/templates/next-export-ui/src/lib/relations.ts
@@ -5,9 +5,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { countRecords, readRecordsByIds } from './app';
import { displayField, getCollection } from './ths';
import { formatFieldValue } from './format';
-import type { AppRuntime } from './runtime';
import { listAllRecords } from './runtime';
+type RelationRuntime = {
+ manifest: any;
+ publicClient: any;
+ abi: any[];
+ appAddress: `0x${string}`;
+};
+
export type OwnedRecord = {
id: bigint;
record: any;
@@ -33,6 +39,8 @@ export type ReferenceCreationGate = {
count: number;
loading: boolean;
error: string | null;
+ mustOwn: boolean;
+ missingWallet: boolean;
};
export function getRecordId(value: unknown): bigint | null {
@@ -83,7 +91,7 @@ export function recordSummary(record: any): { title: string; subtitle: string |
return { title, subtitle, imageUrl, body };
}
-export async function listOwnedRecords(runtime: AppRuntime, collectionName: string, ownerAddress: string): Promise
{
+export async function listOwnedRecords(runtime: RelationRuntime, collectionName: string, ownerAddress: string): Promise {
const normalizedOwner = ownerAddress.trim().toLowerCase();
const page = await listAllRecords({
manifest: runtime.manifest,
@@ -98,7 +106,7 @@ export async function listOwnedRecords(runtime: AppRuntime, collectionName: stri
.filter((entry) => recordOwner(entry.record) === normalizedOwner);
}
-export async function loadRecordsByIds(runtime: AppRuntime, collectionName: string, ids: bigint[]): Promise> {
+export async function loadRecordsByIds(runtime: RelationRuntime, collectionName: string, ids: bigint[]): Promise> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id)))).map((id) => BigInt(id));
if (!uniqueIds.length) return new Map();
@@ -118,7 +126,7 @@ export async function loadRecordsByIds(runtime: AppRuntime, collectionName: stri
}
export async function resolveReferenceRecords(
- runtime: AppRuntime,
+ runtime: RelationRuntime,
items: Array<{ id: bigint; record: any }>,
options: { fieldName: string; targetCollectionName: string }
): Promise {
@@ -166,6 +174,7 @@ export function useOwnedReferenceOptions(args: {
[collection, args.fieldName]
);
const relatedCollection = useMemo(() => (relation?.to ? getCollection(relation.to) : null), [relation]);
+ const mustOwn = Boolean(relation?.mustOwn);
useEffect(() => {
try {
@@ -216,10 +225,12 @@ export function useOwnedReferenceOptions(args: {
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
});
+ const visibleOptions = mustOwn ? nextOptions.filter((option) => option.owned) : nextOptions;
+
if (cancelled) return;
- setOptions(nextOptions);
+ setOptions(visibleOptions);
- const ownedOptions = nextOptions.filter((option) => option.owned);
+ const ownedOptions = visibleOptions.filter((option) => option.owned);
if (!args.value && account) {
let preferred = '';
try {
@@ -229,7 +240,7 @@ export function useOwnedReferenceOptions(args: {
} catch {
preferred = '';
}
- if (preferred && nextOptions.some((option) => String(option.id) === preferred)) {
+ if (preferred && visibleOptions.some((option) => String(option.id) === preferred)) {
onChangeRef.current(preferred);
} else if (ownedOptions.length === 1) {
onChangeRef.current(String(ownedOptions[0]?.id ?? ''));
@@ -249,7 +260,7 @@ export function useOwnedReferenceOptions(args: {
return () => {
cancelled = true;
};
- }, [account, args.abi, args.address, args.collectionName, args.fieldName, args.manifest, args.publicClient, args.value, relatedCollection]);
+ }, [account, args.abi, args.address, args.collectionName, args.fieldName, args.manifest, args.publicClient, args.value, relatedCollection, mustOwn]);
useEffect(() => {
if (!account || !args.value) return;
@@ -270,6 +281,7 @@ export function useOwnedReferenceOptions(args: {
loading,
error,
options,
+ mustOwn,
relatedCollection,
ownedOptions: options.filter((option) => option.owned),
selectedOption
@@ -285,6 +297,7 @@ export function useRequiredReferenceCreationGates(args: {
requiredFieldNames: string[];
}) {
const [gates, setGates] = useState([]);
+ const [account, setAccount] = useState(null);
const requiredReferenceTargets = useMemo(() => {
if (!args.collection) return [];
@@ -296,12 +309,21 @@ export function useRequiredReferenceCreationGates(args: {
if (!relatedCollection) return null;
return {
fieldName: field.name,
- relatedCollection
+ relatedCollection,
+ mustOwn: Boolean(relation?.mustOwn)
};
})
- .filter(Boolean) as Array<{ fieldName: string; relatedCollection: NonNullable> }>;
+ .filter(Boolean) as Array<{ fieldName: string; relatedCollection: NonNullable>; mustOwn: boolean }>;
}, [args.collection, args.requiredFieldNames]);
+ useEffect(() => {
+ try {
+ setAccount(window.localStorage.getItem('TH_ACCOUNT'));
+ } catch {
+ setAccount(null);
+ }
+ }, []);
+
useEffect(() => {
let cancelled = false;
@@ -317,13 +339,51 @@ export function useRequiredReferenceCreationGates(args: {
relatedCollection: entry.relatedCollection,
count: 0,
loading: true,
- error: null
+ error: null,
+ mustOwn: entry.mustOwn,
+ missingWallet: false
}))
);
const next: ReferenceCreationGate[] = [];
for (const entry of requiredReferenceTargets) {
try {
+ if (entry.mustOwn) {
+ if (!account) {
+ next.push({
+ fieldName: entry.fieldName,
+ relatedCollection: entry.relatedCollection,
+ count: 0,
+ loading: false,
+ error: null,
+ mustOwn: true,
+ missingWallet: true
+ });
+ continue;
+ }
+
+ const owned = await listOwnedRecords(
+ {
+ manifest: args.manifest,
+ publicClient: args.publicClient,
+ abi: args.abi,
+ appAddress: args.address
+ },
+ entry.relatedCollection.name,
+ account
+ );
+ next.push({
+ fieldName: entry.fieldName,
+ relatedCollection: entry.relatedCollection,
+ count: owned.length,
+ loading: false,
+ error: null,
+ mustOwn: true,
+ missingWallet: false
+ });
+ continue;
+ }
+
const count = await countRecords({
publicClient: args.publicClient,
abi: args.abi,
@@ -336,7 +396,9 @@ export function useRequiredReferenceCreationGates(args: {
relatedCollection: entry.relatedCollection,
count: Number(count),
loading: false,
- error: null
+ error: null,
+ mustOwn: false,
+ missingWallet: false
});
} catch (cause: any) {
next.push({
@@ -344,7 +406,9 @@ export function useRequiredReferenceCreationGates(args: {
relatedCollection: entry.relatedCollection,
count: 0,
loading: false,
- error: String(cause?.message ?? cause)
+ error: String(cause?.message ?? cause),
+ mustOwn: entry.mustOwn,
+ missingWallet: false
});
}
}
@@ -357,7 +421,7 @@ export function useRequiredReferenceCreationGates(args: {
return () => {
cancelled = true;
};
- }, [args.abi, args.address, args.manifest, args.publicClient, requiredReferenceTargets]);
+ }, [account, args.abi, args.address, args.manifest, args.publicClient, requiredReferenceTargets]);
return {
gates,
diff --git a/packages/templates/next-export-ui/src/lib/ths.ts b/packages/templates/next-export-ui/src/lib/ths.ts
index c919953..3ff1562 100644
--- a/packages/templates/next-export-ui/src/lib/ths.ts
+++ b/packages/templates/next-export-ui/src/lib/ths.ts
@@ -43,6 +43,7 @@ export interface ThsRelation {
field: string;
to: string;
enforce?: boolean;
+ mustOwn?: boolean;
reverseIndex?: boolean;
}
diff --git a/schemas/tokenhost-ths.schema.json b/schemas/tokenhost-ths.schema.json
index ec9d4f9..533a28a 100644
--- a/schemas/tokenhost-ths.schema.json
+++ b/schemas/tokenhost-ths.schema.json
@@ -327,6 +327,7 @@
"field": { "type": "string" },
"to": { "type": "string" },
"enforce": { "type": "boolean" },
+ "mustOwn": { "type": "boolean" },
"reverseIndex": { "type": "boolean" }
}
},
diff --git a/test/testCrudGenerator.js b/test/testCrudGenerator.js
index 0a1ffd4..eeb39d5 100644
--- a/test/testCrudGenerator.js
+++ b/test/testCrudGenerator.js
@@ -60,4 +60,53 @@ describe('Spec-aligned CRUD generator', function () {
const { errors } = compileSolidity(appSol.path, appSol.contents, 'App');
expect(errors.map((e) => e.formattedMessage || e.message).join('\n')).to.equal('');
});
+
+ it('emits relation ownership enforcement for mustOwn references', function () {
+ const raw = {
+ thsVersion: '2025-12',
+ schemaVersion: '0.0.1',
+ app: {
+ name: 'Reference Auth',
+ slug: 'reference-auth',
+ features: { uploads: false, onChainIndexing: true }
+ },
+ collections: [
+ {
+ name: 'Profile',
+ fields: [{ name: 'handle', type: 'string', required: true }],
+ createRules: { required: ['handle'], access: 'public' },
+ visibilityRules: { gets: ['handle'], access: 'public' },
+ updateRules: { mutable: ['handle'], access: 'owner' },
+ deleteRules: { softDelete: true, access: 'owner' },
+ indexes: { unique: [], index: [] }
+ },
+ {
+ name: 'Post',
+ fields: [{ name: 'authorProfile', type: 'reference', required: true }],
+ createRules: { required: ['authorProfile'], access: 'public' },
+ visibilityRules: { gets: ['authorProfile'], access: 'public' },
+ updateRules: { mutable: ['authorProfile'], access: 'owner' },
+ deleteRules: { softDelete: true, access: 'owner' },
+ indexes: { unique: [], index: [] },
+ relations: [{ field: 'authorProfile', to: 'Profile', enforce: true, mustOwn: true }]
+ }
+ ]
+ };
+
+ const structural = validateThsStructural(raw);
+ expect(structural.ok).to.equal(true);
+ const schema = structural.data;
+ const lintIssues = lintThs(schema);
+ const lintErrors = lintIssues.filter((i) => i.severity === 'error');
+ expect(lintErrors).to.have.length(0);
+
+ const appSol = generateAppSolidity(schema);
+ expect(appSol.contents).to.include('error RelatedRecordNotOwned();');
+ expect(appSol.contents).to.include('function _requireOwnedProfile(uint256 id) internal view');
+ expect(appSol.contents).to.include('_requireOwnedProfile(input.authorProfile);');
+ expect(appSol.contents).to.include('_requireOwnedProfile(authorProfile);');
+
+ const { errors } = compileSolidity(appSol.path, appSol.contents, 'App');
+ expect(errors.map((e) => e.formattedMessage || e.message).join('\n')).to.equal('');
+ });
});
diff --git a/test/testThsSchema.js b/test/testThsSchema.js
index 2da72fd..c436eeb 100644
--- a/test/testThsSchema.js
+++ b/test/testThsSchema.js
@@ -199,6 +199,35 @@ describe('THS schema validation + lint', function () {
expect(res.ok).to.equal(true);
});
+ it('validateThsStructural accepts relation ownership requirements', function () {
+ const input = minimalSchema({
+ collections: [
+ {
+ name: 'Profile',
+ fields: [{ name: 'handle', type: 'string', required: true }],
+ createRules: { required: ['handle'], access: 'public' },
+ visibilityRules: { gets: ['handle'], access: 'public' },
+ updateRules: { mutable: ['handle'], access: 'owner' },
+ deleteRules: { softDelete: true, access: 'owner' },
+ indexes: { unique: [], index: [] }
+ },
+ {
+ name: 'Post',
+ fields: [{ name: 'authorProfile', type: 'reference', required: true }],
+ createRules: { required: ['authorProfile'], access: 'public' },
+ visibilityRules: { gets: ['authorProfile'], access: 'public' },
+ updateRules: { mutable: ['authorProfile'], access: 'owner' },
+ deleteRules: { softDelete: true, access: 'owner' },
+ indexes: { unique: [], index: [] },
+ relations: [{ field: 'authorProfile', to: 'Profile', enforce: true, mustOwn: true }]
+ }
+ ]
+ });
+
+ const res = validateThsStructural(input);
+ expect(res.ok).to.equal(true);
+ });
+
it('validateThsStructural rejects unknown app.theme.preset values', function () {
const input = minimalSchema({
app: {
From e5d9bcad00f71a0770ebc0c1c6947452f22a0610 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 14:09:57 -1000
Subject: [PATCH 14/24] Stabilize owned reference UX
---
.../src/solidity/generateAppSolidity.ts | 51 ++++++++++++
.../src/components/ConnectButton.tsx | 22 ++---
.../next-export-ui/src/lib/account.ts | 59 ++++++++++++++
.../templates/next-export-ui/src/lib/app.ts | 38 +++++++++
.../next-export-ui/src/lib/relations.ts | 81 ++++++++++---------
.../next-export-ui/src/lib/runtime.ts | 45 ++++++++++-
test/testCliGenerateUi.js | 10 +++
test/testCrudGenerator.js | 2 +
8 files changed, 253 insertions(+), 55 deletions(-)
create mode 100644 packages/templates/next-export-ui/src/lib/account.ts
diff --git a/packages/generator/src/solidity/generateAppSolidity.ts b/packages/generator/src/solidity/generateAppSolidity.ts
index 8650612..4da5b9e 100644
--- a/packages/generator/src/solidity/generateAppSolidity.ts
+++ b/packages/generator/src/solidity/generateAppSolidity.ts
@@ -419,6 +419,7 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
w.line();
w.line(`mapping(uint256 => ${record}) private ${cVar}Records;`);
+ w.line(`mapping(address => uint256[]) private ownerIndex_${C};`);
w.line(`uint256 public nextId${C} = 1;`);
w.line(`uint256 public activeCount${C} = 0;`);
w.line();
@@ -470,6 +471,54 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
});
w.line();
+ w.block(
+ `function listOwnedIds${C}(address owner, uint256 offset, uint256 limit, bool includeDeleted) external view returns (uint256[] memory)`,
+ () => {
+ w.line('if (limit > MAX_LIST_LIMIT) revert InvalidLimit();');
+ w.line(`uint256[] storage bucket = ownerIndex_${C}[owner];`);
+ w.line('if (bucket.length == 0) {');
+ w.line(' return new uint256[](0);');
+ w.line('}');
+ w.line('uint256[] memory tmp = new uint256[](limit);');
+ w.line('uint256[] memory seen = new uint256[](MAX_SCAN_STEPS);');
+ w.line('uint256 found = 0;');
+ w.line('uint256 matched = 0;');
+ w.line('uint256 steps = 0;');
+ w.line('uint256 bucketIndex = bucket.length;');
+ w.block('while (bucketIndex > 0 && found < limit && steps < MAX_SCAN_STEPS)', () => {
+ w.line('bucketIndex--;');
+ w.line('steps++;');
+ w.line('uint256 id = bucket[bucketIndex];');
+ w.line('bool duplicate = false;');
+ w.block('for (uint256 i = 0; i < matched; i++)', () => {
+ w.line('if (seen[i] == id) {');
+ w.line(' duplicate = true;');
+ w.line(' break;');
+ w.line('}');
+ });
+ w.line('if (duplicate) { continue; }');
+ w.line('seen[matched] = id;');
+ w.line('matched++;');
+ w.line(`${record} storage r = ${cVar}Records[id];`);
+ w.line('if (r.createdBy == address(0)) { continue; }');
+ w.line('if (r.owner != owner) { continue; }');
+ w.line('if (!includeDeleted && r.isDeleted) { continue; }');
+ w.line('if (offset > 0) {');
+ w.line(' offset--;');
+ w.line(' continue;');
+ w.line('}');
+ w.line('tmp[found] = id;');
+ w.line('found++;');
+ });
+ w.line('uint256[] memory out = new uint256[](found);');
+ w.block('for (uint256 i = 0; i < found; i++)', () => {
+ w.line('out[i] = tmp[i];');
+ });
+ w.line('return out;');
+ }
+ );
+ w.line();
+
// get
w.block(`function get${C}(uint256 id, bool includeDeleted) public view returns (${record} memory)`, () => {
w.line(`${record} storage r = ${cVar}Records[id];`);
@@ -631,6 +680,7 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
w.line(`${record} storage r = ${cVar}Records[id];`);
w.line(`_init${record}(r, id);`);
w.line(`_applyCreate${C}Fields(r, input);`);
+ w.line(`ownerIndex_${C}[r.owner].push(id);`);
// update unique maps after storage write
for (const u of c.indexes.unique) {
@@ -797,6 +847,7 @@ export function generateAppSolidity(schema: ThsSchema, options: GenerateAppSolid
}
w.line('address fromOwner = r.owner;');
w.line('r.owner = to;');
+ w.line(`ownerIndex_${C}[to].push(id);`);
w.line('r.updatedAt = block.timestamp;');
w.line('r.updatedBy = _msgSender();');
w.line('r.version += 1;');
diff --git a/packages/templates/next-export-ui/src/components/ConnectButton.tsx b/packages/templates/next-export-ui/src/components/ConnectButton.tsx
index fbf5a9e..fde89e9 100644
--- a/packages/templates/next-export-ui/src/components/ConnectButton.tsx
+++ b/packages/templates/next-export-ui/src/components/ConnectButton.tsx
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from 'react';
+import { getStoredAccount, setStoredAccount, subscribeStoredAccount } from '../lib/account';
import { chainFromId } from '../lib/chains';
import { chainWithRpcOverride, requestWalletAddress } from '../lib/clients';
import { shortAddress } from '../lib/format';
@@ -24,13 +25,8 @@ export default function ConnectButton() {
}, []);
useEffect(() => {
- // Best-effort: hydrate from localStorage.
- try {
- const cached = localStorage.getItem('TH_ACCOUNT');
- if (cached) setAccount(cached);
- } catch {
- // ignore
- }
+ setAccount(getStoredAccount());
+ return subscribeStoredAccount(setAccount);
}, []);
useEffect(() => {
@@ -68,11 +64,7 @@ export default function ConnectButton() {
const accountAddr = a ?? null;
setAccount(accountAddr);
setStatus(null);
- try {
- if (accountAddr) localStorage.setItem('TH_ACCOUNT', accountAddr);
- } catch {
- // ignore
- }
+ setStoredAccount(accountAddr);
} catch (e: any) {
setStatus(String(e?.message ?? e));
}
@@ -81,11 +73,7 @@ export default function ConnectButton() {
function disconnect() {
// Wallets don't support programmatic disconnect reliably; clear local state only.
setAccount(null);
- try {
- localStorage.removeItem('TH_ACCOUNT');
- } catch {
- // ignore
- }
+ setStoredAccount(null);
}
if (walletState === 'unknown') return null;
diff --git a/packages/templates/next-export-ui/src/lib/account.ts b/packages/templates/next-export-ui/src/lib/account.ts
new file mode 100644
index 0000000..eb9f1a4
--- /dev/null
+++ b/packages/templates/next-export-ui/src/lib/account.ts
@@ -0,0 +1,59 @@
+'use client';
+
+const ACCOUNT_STORAGE_KEY = 'TH_ACCOUNT';
+const ACCOUNT_EVENT_NAME = 'tokenhost:account-changed';
+
+function currentWindow(): Window | null {
+ return typeof window === 'undefined' ? null : window;
+}
+
+export function getStoredAccount(): string | null {
+ const win = currentWindow();
+ if (!win) return null;
+ try {
+ return win.localStorage.getItem(ACCOUNT_STORAGE_KEY);
+ } catch {
+ return null;
+ }
+}
+
+export function setStoredAccount(account: string | null): void {
+ const win = currentWindow();
+ if (!win) return;
+ try {
+ if (account) {
+ win.localStorage.setItem(ACCOUNT_STORAGE_KEY, account);
+ } else {
+ win.localStorage.removeItem(ACCOUNT_STORAGE_KEY);
+ }
+ } catch {
+ // ignore
+ }
+ try {
+ win.dispatchEvent(new CustomEvent(ACCOUNT_EVENT_NAME, { detail: { account } }));
+ } catch {
+ // ignore
+ }
+}
+
+export function subscribeStoredAccount(callback: (account: string | null) => void): () => void {
+ const win = currentWindow();
+ if (!win) return () => {};
+
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key && event.key !== ACCOUNT_STORAGE_KEY) return;
+ callback(getStoredAccount());
+ };
+
+ const handleCustom = (event: Event) => {
+ const detail = (event as CustomEvent<{ account?: string | null }>).detail;
+ callback(detail?.account ?? getStoredAccount());
+ };
+
+ win.addEventListener('storage', handleStorage);
+ win.addEventListener(ACCOUNT_EVENT_NAME, handleCustom as EventListener);
+ return () => {
+ win.removeEventListener('storage', handleStorage);
+ win.removeEventListener(ACCOUNT_EVENT_NAME, handleCustom as EventListener);
+ };
+}
diff --git a/packages/templates/next-export-ui/src/lib/app.ts b/packages/templates/next-export-ui/src/lib/app.ts
index 79800fb..3d09de3 100644
--- a/packages/templates/next-export-ui/src/lib/app.ts
+++ b/packages/templates/next-export-ui/src/lib/app.ts
@@ -27,6 +27,10 @@ export function fnListByIndex(collectionName: string, fieldName: string): string
return `listByIndex${collectionName}_${fieldName}`;
}
+export function fnListOwnedIds(collectionName: string): string {
+ return `listOwnedIds${collectionName}`;
+}
+
export function fnCreate(collectionName: string): string {
return `create${collectionName}`;
}
@@ -257,3 +261,37 @@ export async function listRecordsByIndex(args: {
return { ids, records };
}
+
+export async function listOwnedRecordsPage(args: {
+ publicClient: any;
+ abi: any[];
+ address: `0x${string}`;
+ collectionName: string;
+ owner: `0x${string}`;
+ offset: bigint;
+ limit: number;
+ includeDeleted?: boolean;
+}): Promise<{ ids: bigint[]; records: any[] }> {
+ assertAbiFunction(args.abi, fnListOwnedIds(args.collectionName), args.collectionName);
+ assertAbiFunction(args.abi, fnGet(args.collectionName), args.collectionName);
+
+ const ids = (await args.publicClient.readContract({
+ address: args.address,
+ abi: args.abi,
+ functionName: fnListOwnedIds(args.collectionName),
+ args: [args.owner, args.offset, BigInt(args.limit), Boolean(args.includeDeleted)]
+ })) as bigint[];
+
+ if (!ids || ids.length === 0) return { ids: [], records: [] };
+
+ const records = await readRecordsByIds({
+ publicClient: args.publicClient,
+ abi: args.abi,
+ address: args.address,
+ collectionName: args.collectionName,
+ ids,
+ includeDeleted: args.includeDeleted
+ });
+
+ return { ids, records };
+}
diff --git a/packages/templates/next-export-ui/src/lib/relations.ts b/packages/templates/next-export-ui/src/lib/relations.ts
index 3315008..63023aa 100644
--- a/packages/templates/next-export-ui/src/lib/relations.ts
+++ b/packages/templates/next-export-ui/src/lib/relations.ts
@@ -2,10 +2,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import { getStoredAccount, subscribeStoredAccount } from './account';
import { countRecords, readRecordsByIds } from './app';
import { displayField, getCollection } from './ths';
import { formatFieldValue } from './format';
-import { listAllRecords } from './runtime';
+import { listAllRecords, listOwnedRecords as listOwnedRuntimeRecords } from './runtime';
type RelationRuntime = {
manifest: any;
@@ -92,18 +93,18 @@ export function recordSummary(record: any): { title: string; subtitle: string |
}
export async function listOwnedRecords(runtime: RelationRuntime, collectionName: string, ownerAddress: string): Promise {
- const normalizedOwner = ownerAddress.trim().toLowerCase();
- const page = await listAllRecords({
+ const page = await listOwnedRuntimeRecords({
manifest: runtime.manifest,
publicClient: runtime.publicClient,
abi: runtime.abi,
address: runtime.appAddress,
- collectionName
+ collectionName,
+ owner: ownerAddress as `0x${string}`
});
return page.ids
.map((id, index) => ({ id, record: page.records[index] }))
- .filter((entry) => recordOwner(entry.record) === normalizedOwner);
+ .filter((entry) => Boolean(entry.record));
}
export async function loadRecordsByIds(runtime: RelationRuntime, collectionName: string, ids: bigint[]): Promise> {
@@ -177,11 +178,8 @@ export function useOwnedReferenceOptions(args: {
const mustOwn = Boolean(relation?.mustOwn);
useEffect(() => {
- try {
- setAccount(window.localStorage.getItem('TH_ACCOUNT'));
- } catch {
- setAccount(null);
- }
+ setAccount(getStoredAccount());
+ return subscribeStoredAccount(setAccount);
}, []);
useEffect(() => {
@@ -197,18 +195,31 @@ export function useOwnedReferenceOptions(args: {
setLoading(true);
setError(null);
try {
- const page = await listAllRecords({
- manifest: args.manifest,
- publicClient: args.publicClient,
- abi: args.abi,
- address: args.address,
- collectionName: relatedCollection.name
- });
-
const normalizedAccount = account?.trim().toLowerCase() ?? '';
- const nextOptions = page.ids
- .map((id, index) => {
- const record = page.records[index];
+ const page = mustOwn && normalizedAccount
+ ? await listOwnedRecords(
+ {
+ manifest: args.manifest,
+ publicClient: args.publicClient,
+ abi: args.abi,
+ appAddress: args.address
+ },
+ relatedCollection.name,
+ normalizedAccount
+ )
+ : await listAllRecords({
+ manifest: args.manifest,
+ publicClient: args.publicClient,
+ abi: args.abi,
+ address: args.address,
+ collectionName: relatedCollection.name
+ });
+ const pageItems = Array.isArray(page)
+ ? page
+ : page.ids.map((id, index) => ({ id, record: page.records[index] }));
+
+ const nextOptions = pageItems
+ .map(({ id, record }) => {
if (!record) return null;
return {
id,
@@ -317,11 +328,8 @@ export function useRequiredReferenceCreationGates(args: {
}, [args.collection, args.requiredFieldNames]);
useEffect(() => {
- try {
- setAccount(window.localStorage.getItem('TH_ACCOUNT'));
- } catch {
- setAccount(null);
- }
+ setAccount(getStoredAccount());
+ return subscribeStoredAccount(setAccount);
}, []);
useEffect(() => {
@@ -362,20 +370,19 @@ export function useRequiredReferenceCreationGates(args: {
continue;
}
- const owned = await listOwnedRecords(
- {
- manifest: args.manifest,
- publicClient: args.publicClient,
- abi: args.abi,
- appAddress: args.address
- },
- entry.relatedCollection.name,
- account
- );
+ const owned = await listOwnedRuntimeRecords({
+ manifest: args.manifest,
+ publicClient: args.publicClient,
+ abi: args.abi,
+ address: args.address,
+ collectionName: entry.relatedCollection.name,
+ owner: account as `0x${string}`,
+ maxRecords: 1
+ });
next.push({
fieldName: entry.fieldName,
relatedCollection: entry.relatedCollection,
- count: owned.length,
+ count: owned.ids.length,
loading: false,
error: null,
mustOwn: true,
diff --git a/packages/templates/next-export-ui/src/lib/runtime.ts b/packages/templates/next-export-ui/src/lib/runtime.ts
index 6575b14..d5cccf6 100644
--- a/packages/templates/next-export-ui/src/lib/runtime.ts
+++ b/packages/templates/next-export-ui/src/lib/runtime.ts
@@ -1,5 +1,5 @@
import { fetchAppAbi } from './abi';
-import { listRecords, listRecordsByIndex } from './app';
+import { listOwnedRecordsPage, listRecords, listRecordsByIndex } from './app';
import { extractHashtagTokens, hashtagIndexKey, normalizeHashtagToken } from './indexing';
import { chainFromId } from './chains';
import { chainWithRpcOverride, makePublicClient } from './clients';
@@ -87,6 +87,49 @@ export async function listAllRecords(args: {
return { ids, records };
}
+export async function listOwnedRecords(args: {
+ publicClient: any;
+ abi: any[];
+ address: `0x${string}`;
+ collectionName: string;
+ owner: `0x${string}`;
+ manifest?: any;
+ pageSize?: number;
+ maxRecords?: number;
+ includeDeleted?: boolean;
+}): Promise<{ ids: bigint[]; records: any[] }> {
+ const pageSize = clampListPageSize(args.manifest, args.pageSize);
+ const ids: bigint[] = [];
+ const records: any[] = [];
+ let offset = 0n;
+
+ for (;;) {
+ const remaining = typeof args.maxRecords === 'number' ? Math.max(0, args.maxRecords - ids.length) : pageSize;
+ if (remaining === 0) break;
+ const page = await listOwnedRecordsPage({
+ publicClient: args.publicClient,
+ abi: args.abi,
+ address: args.address,
+ collectionName: args.collectionName,
+ owner: args.owner,
+ offset,
+ limit: Math.min(pageSize, remaining),
+ includeDeleted: args.includeDeleted
+ });
+
+ if (!page.ids.length) break;
+
+ ids.push(...page.ids);
+ records.push(...page.records);
+
+ if (typeof args.maxRecords === 'number' && ids.length >= args.maxRecords) break;
+ if (page.ids.length < pageSize) break;
+ offset += BigInt(page.ids.length);
+ }
+
+ return { ids, records };
+}
+
export async function listRecordsByFieldValue(args: {
publicClient: any;
abi: any[];
diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js
index da326db..427242c 100644
--- a/test/testCliGenerateUi.js
+++ b/test/testCliGenerateUi.js
@@ -286,6 +286,10 @@ describe('th generate (UI template)', function () {
expect(generatedReferenceField).to.include('Owned records appear first');
expect(generatedReferenceField).to.include("href={`/${relatedCollection.name}/?mode=new`}");
+ const generatedAccount = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'account.ts'), 'utf-8');
+ expect(generatedAccount).to.include("const ACCOUNT_EVENT_NAME = 'tokenhost:account-changed'");
+ expect(generatedAccount).to.include('export function subscribeStoredAccount');
+
const generatedResolvedReference = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ResolvedReferenceValue.tsx'), 'utf-8');
expect(generatedResolvedReference).to.include('getRelatedCollection');
expect(generatedResolvedReference).to.include("href={`/${relatedCollection.name}/?mode=view&id=${String(id)}`}");
@@ -296,6 +300,8 @@ describe('th generate (UI template)', function () {
expect(generatedRelations).to.include('useOwnedReferenceOptions');
expect(generatedRelations).to.include('useRequiredReferenceCreationGates');
expect(generatedRelations).to.include('TH_REFERENCE_SELECTION');
+ expect(generatedRelations).to.include('subscribeStoredAccount');
+ expect(generatedRelations).to.include('maxRecords: 1');
const generatedNewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'new', 'ClientPage.tsx'), 'utf-8');
expect(generatedNewPage).to.include('ReferenceFieldInput');
@@ -313,6 +319,10 @@ describe('th generate (UI template)', function () {
const generatedRecordCard = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'RecordCard.tsx'), 'utf-8');
expect(generatedRecordCard).to.include('ResolvedReferenceValue');
+
+ const generatedApp = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'app.ts'), 'utf-8');
+ expect(generatedApp).to.include('function fnListOwnedIds');
+ expect(generatedApp).to.include('function listOwnedRecordsPage');
});
it('generated UI builds (next export)', function () {
diff --git a/test/testCrudGenerator.js b/test/testCrudGenerator.js
index eeb39d5..c3044f9 100644
--- a/test/testCrudGenerator.js
+++ b/test/testCrudGenerator.js
@@ -102,6 +102,8 @@ describe('Spec-aligned CRUD generator', function () {
const appSol = generateAppSolidity(schema);
expect(appSol.contents).to.include('error RelatedRecordNotOwned();');
+ expect(appSol.contents).to.include('mapping(address => uint256[]) private ownerIndex_Profile;');
+ expect(appSol.contents).to.include('function listOwnedIdsProfile(address owner, uint256 offset, uint256 limit, bool includeDeleted) external view returns (uint256[] memory)');
expect(appSol.contents).to.include('function _requireOwnedProfile(uint256 id) internal view');
expect(appSol.contents).to.include('_requireOwnedProfile(input.authorProfile);');
expect(appSol.contents).to.include('_requireOwnedProfile(authorProfile);');
From d2a738044524554f01ac3a57b363588743f10740 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 15:34:12 -1000
Subject: [PATCH 15/24] Polish generated app shell and create flows
---
apps/example/microblog.schema.json | 5 ++
.../schema/schemas/tokenhost-ths.schema.json | 9 ++
packages/schema/src/lint.ts | 11 +++
packages/schema/src/types.ts | 7 ++
.../app/[collection]/edit/ClientPage.tsx | 30 ++++++-
.../app/[collection]/new/ClientPage.tsx | 30 ++++++-
.../app/[collection]/view/ClientPage.tsx | 85 +++++++++++++------
.../templates/next-export-ui/app/globals.css | 66 +++++++++++++-
.../templates/next-export-ui/app/layout.tsx | 16 ++--
.../src/collection-route/CollectionLayout.tsx | 8 +-
.../src/components/GeneratedFeedStream.tsx | 11 ++-
.../src/components/ReferenceFieldInput.tsx | 8 +-
.../templates/next-export-ui/src/lib/ths.ts | 11 +++
schemas/tokenhost-ths.schema.json | 9 ++
test/testCliGenerateUi.js | 9 ++
test/testThsSchema.js | 18 ++++
16 files changed, 282 insertions(+), 51 deletions(-)
diff --git a/apps/example/microblog.schema.json b/apps/example/microblog.schema.json
index 56f5c42..3e37a02 100644
--- a/apps/example/microblog.schema.json
+++ b/apps/example/microblog.schema.json
@@ -4,6 +4,11 @@
"app": {
"name": "Microblog",
"slug": "microblog",
+ "brand": {
+ "primaryText": "micro",
+ "accentText": "blog"
+ },
+ "primaryCollection": "Post",
"theme": {
"preset": "cyber-grid"
},
diff --git a/packages/schema/schemas/tokenhost-ths.schema.json b/packages/schema/schemas/tokenhost-ths.schema.json
index 533a28a..ba9a062 100644
--- a/packages/schema/schemas/tokenhost-ths.schema.json
+++ b/packages/schema/schemas/tokenhost-ths.schema.json
@@ -29,6 +29,15 @@
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"description": { "type": "string" },
+ "brand": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "primaryText": { "type": "string", "minLength": 1 },
+ "accentText": { "type": "string", "minLength": 1 }
+ }
+ },
+ "primaryCollection": { "type": "string", "minLength": 1 },
"theme": {
"type": "object",
"description": "Theme preset selection and future theme options.",
diff --git a/packages/schema/src/lint.ts b/packages/schema/src/lint.ts
index ad76ebe..e34bb33 100644
--- a/packages/schema/src/lint.ts
+++ b/packages/schema/src/lint.ts
@@ -89,6 +89,17 @@ export function lintThs(schema: ThsSchema): Issue[] {
);
}
+ const primaryCollection = String(schema.app.primaryCollection ?? '').trim();
+ if (primaryCollection && !schema.collections.some((collection) => collection.name === primaryCollection)) {
+ issues.push(
+ err(
+ '/app/primaryCollection',
+ 'lint.app.primary_collection_unknown',
+ `app.primaryCollection references unknown collection "${primaryCollection}".`
+ )
+ );
+ }
+
const generatedFeedIds = new Set();
const generatedFeeds = Array.isArray(generatedUi?.feeds) ? generatedUi.feeds : [];
for (const [index, feed] of generatedFeeds.entries()) {
diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts
index b167110..4014c6c 100644
--- a/packages/schema/src/types.ts
+++ b/packages/schema/src/types.ts
@@ -87,6 +87,11 @@ export interface ThsAppUi {
generated?: ThsAppUiGenerated;
}
+export interface ThsAppBrand {
+ primaryText?: string;
+ accentText?: string;
+}
+
export type ThsThemePreset = 'cyber-grid';
export interface ThsAppTheme {
@@ -98,6 +103,8 @@ export interface ThsApp {
name: string;
slug: string;
description?: string;
+ brand?: ThsAppBrand;
+ primaryCollection?: string;
theme?: ThsAppTheme;
features?: ThsAppFeatures;
ui?: ThsAppUi;
diff --git a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
index 8a4950f..1317801 100644
--- a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
@@ -20,6 +20,25 @@ function inputType(field: ThsField): 'text' | 'number' {
return 'text';
}
+function isLongTextField(field: ThsField): boolean {
+ if (field.type !== 'string') return false;
+ return ['body', 'description', 'content', 'bio', 'summary'].includes(field.name);
+}
+
+function fieldGroupClass(field: ThsField): string {
+ if (field.type === 'reference') return 'fieldGroup fieldGroupMinor';
+ if (field.type === 'image') return 'fieldGroup fieldGroupMinor';
+ if (isLongTextField(field)) return 'fieldGroup fieldGroupFeature';
+ return 'fieldGroup';
+}
+
+function fieldPlaceholder(field: ThsField): string {
+ if (field.type !== 'string') return field.type;
+ if (field.name === 'body') return 'Update the post…';
+ if (field.name === 'bio') return 'Tell people about this profile…';
+ return fieldDisplayName(field);
+}
+
function getValue(record: any, key: string, fallbackIndex?: number): any {
if (record && typeof record === 'object' && key in record) {
return (record as any)[key];
@@ -304,7 +323,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
{fields.map((f) => (
-
+
{fieldDisplayName(f)}
{f.type === 'bool' ? (
setForm((prev) => ({ ...prev, [f.name]: next }))}
/>
+ ) : isLongTextField(f) ? (
+
diff --git a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
index 1f3bd5f..a4e3564 100644
--- a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
@@ -21,6 +21,25 @@ function inputType(field: ThsField): 'text' | 'number' {
return 'text';
}
+function isLongTextField(field: ThsField): boolean {
+ if (field.type !== 'string') return false;
+ return ['body', 'description', 'content', 'bio', 'summary'].includes(field.name);
+}
+
+function fieldGroupClass(field: ThsField): string {
+ if (field.type === 'reference') return 'fieldGroup fieldGroupMinor';
+ if (field.type === 'image') return 'fieldGroup fieldGroupMinor';
+ if (isLongTextField(field)) return 'fieldGroup fieldGroupFeature';
+ return 'fieldGroup';
+}
+
+function fieldPlaceholder(field: ThsField): string {
+ if (field.type !== 'string') return field.type;
+ if (field.name === 'body') return 'Write your post…';
+ if (field.name === 'bio') return 'Tell people about this profile…';
+ return fieldDisplayName(field);
+}
+
export default function CreateRecordPage(props: { params: { collection: string } }) {
const collectionName = props.params.collection;
const collection = useMemo(() => getCollection(collectionName), [collectionName]);
@@ -247,7 +266,7 @@ export default function CreateRecordPage(props: { params: { collection: string }
{fields.map((f) => (
-
+
{fieldDisplayName(f)} {required.has(f.name) ? required : null}
@@ -279,13 +298,20 @@ export default function CreateRecordPage(props: { params: { collection: string }
disabled={txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'}
onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))}
/>
+ ) : isLongTextField(f) ? (
+
diff --git a/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
index c530d7f..67c032e 100644
--- a/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
@@ -9,7 +9,7 @@ import { chainFromId } from '../../../src/lib/chains';
import { chainWithRpcOverride, makePublicClient } from '../../../src/lib/clients';
import { formatDateTime, formatFieldValue, shortAddress } from '../../../src/lib/format';
import { fetchManifest, getPrimaryDeployment, getReadRpcUrl } from '../../../src/lib/manifest';
-import { fieldDisplayName, fieldLinkUi, getCollection, transferEnabled, type ThsCollection, type ThsField } from '../../../src/lib/ths';
+import { displayField, fieldDisplayName, fieldLinkUi, getCollection, transferEnabled, type ThsCollection, type ThsField } from '../../../src/lib/ths';
import { submitWriteTx } from '../../../src/lib/tx';
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
import ResolvedReferenceValue from '../../../src/components/ResolvedReferenceValue';
@@ -29,6 +29,14 @@ function fieldIndex(collection: ThsCollection, field: ThsField): number {
return 9 + Math.max(0, idx);
}
+function prefersLongText(field: ThsField): boolean {
+ return field.type === 'string' && ['body', 'description', 'content', 'bio', 'summary'].includes(field.name);
+}
+
+function findMediaField(collection: ThsCollection): ThsField | null {
+ return collection.fields.find((field) => field.type === 'image') ?? null;
+}
+
function renderFieldValue(args: {
collection: ThsCollection;
field: ThsField;
@@ -313,27 +321,62 @@ export default function ViewRecordPage(props: { params: { collection: string } }
const owner = getValue(record, 'owner', 3);
const createdBy = getValue(record, 'createdBy', 2);
const createdAt = getValue(record, 'createdAt', 1);
+ const updatedAt = getValue(record, 'updatedAt', 4);
const version = getValue(record, 'version', 8);
const canEdit = Array.isArray((collection as any).updateRules?.mutable) && (collection as any).updateRules.mutable.length > 0;
const canDelete = Boolean((collection as any).deleteRules?.softDelete);
+ const display = displayField(collection);
+ const titleField =
+ collection.fields.find((field) => field.type === 'string' && ['displayName', 'title', 'name', 'handle'].includes(field.name)) ??
+ (display?.type === 'string' ? display : null);
+ const titleRaw = titleField ? getValue(record, titleField.name, fieldIndex(collection, titleField)) : null;
+ const title = titleField ? formatFieldValue(titleRaw, titleField.type, (titleField as any).decimals, titleField.name) : `${collection.name} #${String(id)}`;
+ const longTextField = collection.fields.find(prefersLongText) ?? null;
+ const longTextRaw = longTextField ? getValue(record, longTextField.name, fieldIndex(collection, longTextField)) : null;
+ const longText = longTextField ? formatFieldValue(longTextRaw, longTextField.type, (longTextField as any).decimals, longTextField.name) : '';
+ const mediaField = findMediaField(collection);
+ const mediaRaw = mediaField ? getValue(record, mediaField.name, fieldIndex(collection, mediaField)) : null;
+ const mediaUrl = mediaField ? formatFieldValue(mediaRaw, mediaField.type, (mediaField as any).decimals, mediaField.name) : '';
+ const detailFields = collection.fields.filter((field) => ![titleField?.name, longTextField?.name, mediaField?.name].includes(field.name));
return (
<>
-
-
-
- {collection.name} #{String(getValue(record, 'id', 0))}
-
-
-
void fetchRecord()}>Refresh
- {canEdit ? (
-
router.push(`/${collectionName}/?mode=edit&id=${String(id)}`)}>Edit
- ) : null}
- {canDelete ? (
-
router.push(`/${collectionName}/?mode=delete&id=${String(id)}`)}>Delete
- ) : null}
+
+
+
+
/{collection.name.toLowerCase()}/{String(getValue(record, 'id', 0))}
+
{title || `${collection.name} #${String(id)}`}
+
+ {titleField && titleField.name !== longTextField?.name ? fieldDisplayName(titleField) : 'On-chain record'}
+
+
+
+ id {String(getValue(record, 'id', 0))}
+ {transferEnabled(collection) ? transferable : null}
+ {canEdit ? router.push(`/${collectionName}/?mode=edit&id=${String(id)}`)}>Edit : null}
+ {canDelete ? router.push(`/${collectionName}/?mode=delete&id=${String(id)}`)}>Delete : null}
+
+ {longText ? {longText}
: null}
+
+ {mediaUrl ? (
+
+
+
+ ) : null}
+
+
+ owner {owner ? shortAddress(String(owner)) : '—'}
+ created {createdAt ? formatDateTime(createdAt, 'compact') : '—'}
+ {updatedAt ? updated {formatDateTime(updatedAt, 'compact')} : null}
+ version {version ? String(version) : '0'}
+ void fetchRecord()}>Refresh
+
+
+
+
+ Details
owner
{owner ? shortAddress(String(owner)) : '—'}
@@ -341,15 +384,9 @@ export default function ViewRecordPage(props: { params: { collection: string } }
{createdBy ? shortAddress(String(createdBy)) : '—'}
createdAt
{createdAt ? formatDateTime(createdAt) : '—'}
-
version
-
{version ? String(version) : '—'}
-
-
-
-
-
Fields
-
- {collection.fields.map((f) => {
+
updatedAt
+
{updatedAt ? formatDateTime(updatedAt) : '—'}
+ {detailFields.map((f) => {
const v = getValue(record, f.name, fieldIndex(collection, f));
const rendered = formatFieldValue(v, f.type, (f as any).decimals, f.name);
return (
@@ -360,7 +397,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
);
})}
-
+
{transferEnabled(collection) ? (
diff --git a/packages/templates/next-export-ui/app/globals.css b/packages/templates/next-export-ui/app/globals.css
index 73f5af6..af4eef6 100644
--- a/packages/templates/next-export-ui/app/globals.css
+++ b/packages/templates/next-export-ui/app/globals.css
@@ -456,10 +456,10 @@ html[data-theme='dark'] .themeToggle::after {
.brandWordText {
display: inline-flex;
- font-size: 16px;
+ font-size: 20px;
font-weight: 800;
gap: 6px;
- letter-spacing: 0.18em;
+ letter-spacing: 0.16em;
line-height: 1.08;
text-transform: uppercase;
}
@@ -708,6 +708,15 @@ html[data-theme='dark'] .themeToggle::after {
justify-content: space-between;
}
+.actionGroup {
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.fieldPillRow {
+ margin-top: 14px;
+}
+
.displayTitle {
font-family: var(--th-font-display);
font-size: clamp(2.4rem, 6vw, 4.7rem);
@@ -722,6 +731,12 @@ html[data-theme='dark'] .themeToggle::after {
color: var(--th-primary);
}
+.displayTitleCompact {
+ font-size: clamp(2.05rem, 4vw, 3.05rem);
+ line-height: 0.98;
+ max-width: none;
+}
+
.lead {
color: var(--th-muted);
font-size: 0.98rem;
@@ -878,6 +893,10 @@ html[data-theme='dark'] .themeToggle::after {
padding: 14px;
}
+.recordPreviewCellCompact {
+ padding: 12px;
+}
+
.recordPreviewLabel {
color: var(--th-muted);
font-family: var(--th-font-mono);
@@ -995,6 +1014,16 @@ html[data-theme='dark'] .btn.primary {
transform: none;
}
+.cardTitleLink {
+ transition: color var(--th-motion-fast) ease;
+}
+
+.cardTitleLink:hover,
+.cardTitleLink:focus-visible {
+ color: var(--th-primary);
+ outline: none;
+}
+
.input,
.select {
background: var(--th-input-bg);
@@ -1033,6 +1062,7 @@ html[data-theme='dark'] .btn.primary {
.formGrid {
display: grid;
gap: 16px;
+ grid-template-columns: minmax(260px, 0.86fr) minmax(340px, 1.14fr);
margin-top: 16px;
}
@@ -1040,6 +1070,34 @@ html[data-theme='dark'] .btn.primary {
min-width: 0;
}
+.fieldGroupMinor {
+ align-self: start;
+}
+
+.textarea {
+ min-height: 140px;
+ resize: vertical;
+}
+
+.textareaFeature {
+ min-height: 220px;
+}
+
+.recordHeroBody {
+ font-size: 1.18rem;
+ line-height: 1.7;
+ margin: 0;
+ max-width: 70ch;
+ white-space: pre-wrap;
+}
+
+.recordHeroMedia {
+ background: color-mix(in srgb, var(--th-panel-muted) 92%, transparent);
+ border: 1px solid var(--th-border);
+ overflow: hidden;
+ padding: 14px;
+}
+
.kv {
border: 1px solid var(--th-border);
display: grid;
@@ -1319,6 +1377,10 @@ html[data-theme='dark'] .btn.primary {
grid-template-columns: 1fr;
}
+ .formGrid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
.kv > div:nth-child(odd) {
border-bottom: 0;
border-right: 0;
diff --git a/packages/templates/next-export-ui/app/layout.tsx b/packages/templates/next-export-ui/app/layout.tsx
index d5ce31c..cad9b29 100644
--- a/packages/templates/next-export-ui/app/layout.tsx
+++ b/packages/templates/next-export-ui/app/layout.tsx
@@ -9,7 +9,7 @@ import FooterDeploymentMeta from '../src/components/FooterDeploymentMeta';
import LivingGrid from '../src/components/LivingGrid';
import NetworkStatus from '../src/components/NetworkStatus';
import ThemeToggle from '../src/components/ThemeToggle';
-import { ths } from '../src/lib/ths';
+import { primaryCollection, ths } from '../src/lib/ths';
export const metadata = {
title: `${ths.app.name} - Token Host`,
@@ -34,7 +34,9 @@ const themeBootScript = `
`;
export default function RootLayout(props: { children: React.ReactNode }) {
- const primaryCollection = ths.collections[0] ?? null;
+ const brandPrimary = String(ths.app.brand?.primaryText ?? 'token').trim() || 'token';
+ const brandAccent = String(ths.app.brand?.accentText ?? 'host').trim() || 'host';
+ const primaryModel = primaryCollection();
const navCollections = ths.collections.slice(0, 2);
return (
@@ -52,14 +54,13 @@ export default function RootLayout(props: { children: React.ReactNode }) {
- token
- host
+ {brandPrimary}
+ {brandAccent}
- Overview
{navCollections.map((collection) => (
{collection.name}
@@ -71,7 +72,7 @@ export default function RootLayout(props: { children: React.ReactNode }) {
- {primaryCollection ? Create record : null}
+ {primaryModel ? Create {primaryModel.name} : null}
@@ -97,10 +98,9 @@ export default function RootLayout(props: { children: React.ReactNode }) {
/runtime
-
Overview
Manifest
Compiled ABI
- {primaryCollection ?
Create record : null}
+ {primaryModel ?
Create {primaryModel.name} : null}
diff --git a/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx b/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
index 3fdd90d..2c6a07d 100644
--- a/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
+++ b/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
@@ -22,12 +22,6 @@ export default function CollectionLayout(props: { children: ReactNode; collectio
/collection/{collection.name}
-
- {collection.fields.length} fields
- create {collection.createRules.access}
- update {collection.updateRules.access}
- delete {collection.deleteRules.access}
-
@@ -38,7 +32,7 @@ export default function CollectionLayout(props: { children: ReactNode; collectio
List records
- Create record
+ Create {collection.name}
{collection.fields.slice(0, 6).map((field) => (
diff --git a/packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx b/packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx
index eb8d151..96f2757 100644
--- a/packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx
+++ b/packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx
@@ -34,6 +34,7 @@ export default function GeneratedFeedStream(props: {
const summary = feedCardSummary(item, props.feed);
const tags = extractHashtagTokens(summary.body);
const timestamp = item.record?.updatedAt ?? item.record?.createdAt ?? null;
+ const viewHref = `/${props.feed.collection}/?mode=view&id=${String(item.id)}`;
return (
@@ -49,7 +50,11 @@ export default function GeneratedFeedStream(props: {
/>
) : null}
-
{summary.title}
+
+
+ {summary.title}
+
+
{summary.subtitle ?
{summary.subtitle}
: null}
@@ -66,7 +71,9 @@ export default function GeneratedFeedStream(props: {
{summary.mediaUrl ? (
-
+
+
+
) : null}
diff --git a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
index 5dd8898..0f9af62 100644
--- a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
+++ b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
@@ -64,14 +64,14 @@ export default function ReferenceFieldInput(props: {
))}
{selectedSummary ? (
-
+
{relatedCollection.name} #{String(selectedOption?.id ?? '')}
{selectedSummary.subtitle ? {selectedSummary.subtitle} : null}
- {selectedSummary.imageUrl ? (
+ {selectedSummary.imageUrl && !mustOwn ? (
// eslint-disable-next-line @next/next/no-img-element
{selectedSummary.title}
- {selectedSummary.body ?
{selectedSummary.body} : null}
+ {!mustOwn && selectedSummary.body ?
{selectedSummary.body} : null}
@@ -103,7 +103,7 @@ export default function ReferenceFieldInput(props: {
: options.length > 0
? ownedOptions.length > 0
? mustOwn
- ? `This relation requires a ${relatedCollection.name} owned by the connected wallet. Your owned records are shown here${account ? ` for ${account.slice(0, 6)}…` : ''}.`
+ ? `This relation requires a ${relatedCollection.name} owned by the connected wallet.${account ? ` Showing records for ${account.slice(0, 6)}…` : ''}.`
: `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first and your last choice is remembered${account ? ` for ${account.slice(0, 6)}…` : ''}.`
: `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry.`
: mustOwn
diff --git a/packages/templates/next-export-ui/src/lib/ths.ts b/packages/templates/next-export-ui/src/lib/ths.ts
index 3ff1562..ccd0852 100644
--- a/packages/templates/next-export-ui/src/lib/ths.ts
+++ b/packages/templates/next-export-ui/src/lib/ths.ts
@@ -79,6 +79,11 @@ export interface ThsSchema {
app: {
name: string;
slug: string;
+ brand?: {
+ primaryText?: string;
+ accentText?: string;
+ };
+ primaryCollection?: string;
features?: Record
;
ui?: {
homePage?: {
@@ -146,6 +151,12 @@ export function getCollection(name: string): ThsCollection | null {
return (ths.collections as any[]).find((c) => c && c.name === name) ?? null;
}
+export function primaryCollection(): ThsCollection | null {
+ const configured = typeof ths.app.primaryCollection === 'string' ? ths.app.primaryCollection.trim() : '';
+ if (configured) return getCollection(configured);
+ return (ths.collections as any[])[0] ?? null;
+}
+
export function getField(collection: ThsCollection, fieldName: string): ThsField | null {
return (collection.fields as any[]).find((f) => f && f.name === fieldName) ?? null;
}
diff --git a/schemas/tokenhost-ths.schema.json b/schemas/tokenhost-ths.schema.json
index 533a28a..ba9a062 100644
--- a/schemas/tokenhost-ths.schema.json
+++ b/schemas/tokenhost-ths.schema.json
@@ -29,6 +29,15 @@
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"description": { "type": "string" },
+ "brand": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "primaryText": { "type": "string", "minLength": 1 },
+ "accentText": { "type": "string", "minLength": 1 }
+ }
+ },
+ "primaryCollection": { "type": "string", "minLength": 1 },
"theme": {
"type": "object",
"description": "Theme preset selection and future theme options.",
diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js
index 427242c..20ac363 100644
--- a/test/testCliGenerateUi.js
+++ b/test/testCliGenerateUi.js
@@ -102,6 +102,8 @@ function minimalSchema() {
function minimalSchemaWithThemePreset() {
const schema = minimalSchema();
schema.app.theme = { preset: 'cyber-grid' };
+ schema.app.brand = { primaryText: 'micro', accentText: 'blog' };
+ schema.app.primaryCollection = 'Item';
return schema;
}
@@ -210,6 +212,9 @@ describe('th generate (UI template)', function () {
expect(layoutSource).to.include('NetworkStatus');
expect(layoutSource).to.include('themeBootScript');
expect(layoutSource).to.not.include('/tokenhost/ops');
+ expect(layoutSource).to.not.include('href=\"/\">Overview');
+ expect(layoutSource).to.include('Create {primaryModel.name}');
+ expect(layoutSource).to.include('brandPrimary');
const generatedTokens = fs.readFileSync(path.join(outDir, 'ui', 'src', 'theme', 'tokens.json'), 'utf-8');
expect(generatedTokens).to.equal(readTemplateThemeTokens());
@@ -262,6 +267,8 @@ describe('th generate (UI template)', function () {
const generatedThs = fs.readFileSync(path.join(outDir, 'ui', 'src', 'generated', 'ths.ts'), 'utf-8');
expect(generatedThs).to.include('"preset": "cyber-grid"');
+ expect(generatedThs).to.include('"primaryText": "micro"');
+ expect(generatedThs).to.include('"primaryCollection": "Item"');
const generatedTokens = fs.readFileSync(path.join(outDir, 'ui', 'src', 'theme', 'tokens.json'), 'utf-8');
expect(generatedTokens).to.equal(readTemplateThemeTokens());
@@ -309,10 +316,12 @@ describe('th generate (UI template)', function () {
expect(generatedNewPage).to.include('You must create a');
expect(generatedNewPage).to.include('Checking required linked records before showing the form.');
expect(generatedNewPage).to.include('Waiting for media upload…');
+ expect(generatedNewPage).to.include('textareaFeature');
const generatedEditPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'edit', 'ClientPage.tsx'), 'utf-8');
expect(generatedEditPage).to.include('ReferenceFieldInput');
expect(generatedEditPage).to.include('Waiting for media upload…');
+ expect(generatedEditPage).to.include('textareaFeature');
const generatedViewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'view', 'ClientPage.tsx'), 'utf-8');
expect(generatedViewPage).to.include('ResolvedReferenceValue');
diff --git a/test/testThsSchema.js b/test/testThsSchema.js
index c436eeb..2b2662b 100644
--- a/test/testThsSchema.js
+++ b/test/testThsSchema.js
@@ -138,6 +138,8 @@ describe('THS schema validation + lint', function () {
app: {
name: 'Test App',
slug: 'test-app',
+ brand: { primaryText: 'test', accentText: 'app' },
+ primaryCollection: 'Item',
theme: { preset: 'cyber-grid' },
features: { uploads: false, onChainIndexing: true }
}
@@ -147,6 +149,22 @@ describe('THS schema validation + lint', function () {
expect(res.ok).to.equal(true);
});
+ it('lintThs rejects unknown app.primaryCollection values', function () {
+ const input = minimalSchema({
+ app: {
+ name: 'Test App',
+ slug: 'test-app',
+ primaryCollection: 'Missing',
+ features: { uploads: false, onChainIndexing: true }
+ }
+ });
+
+ const res = validateThsStructural(input);
+ expect(res.ok).to.equal(true);
+ const issues = lintThs(res.data);
+ expect(issues.some((i) => i.code === 'lint.app.primary_collection_unknown')).to.equal(true);
+ });
+
it('validateThsStructural accepts generated feed/token/home UI primitives', function () {
const input = minimalSchema({
app: {
From 4a5d92832fbdc74ced562ba5fc11e38f81477b23 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 15:55:09 -1000
Subject: [PATCH 16/24] Simplify generated reference create flow
---
.../app/[collection]/edit/ClientPage.tsx | 12 ++++-
.../app/[collection]/new/ClientPage.tsx | 12 ++++-
.../templates/next-export-ui/app/globals.css | 38 +++++++++++++--
.../src/components/ReferenceFieldInput.tsx | 47 +++++++++++++------
4 files changed, 85 insertions(+), 24 deletions(-)
diff --git a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
index 1317801..1817c40 100644
--- a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
@@ -32,6 +32,10 @@ function fieldGroupClass(field: ThsField): string {
return 'fieldGroup';
}
+function isMinorField(field: ThsField): boolean {
+ return field.type === 'reference' || field.type === 'image';
+}
+
function fieldPlaceholder(field: ThsField): string {
if (field.type !== 'string') return field.type;
if (field.name === 'body') return 'Update the post…';
@@ -123,6 +127,10 @@ export default function EditRecordPage(props: { params: { collection: string } }
const appAddress = deployment?.deploymentEntrypointAddress as `0x${string}` | undefined;
const fields = collection ? mutableFields(collection) : [];
+ const orderedFields = useMemo(
+ () => [...fields.filter((field) => !isMinorField(field)), ...fields.filter((field) => isMinorField(field))],
+ [fields]
+ );
const optimistic = Boolean((collection as any)?.updateRules?.optimisticConcurrency);
const uploadBusy = Object.values(busyUploads).some(Boolean);
@@ -322,8 +330,8 @@ export default function EditRecordPage(props: { params: { collection: string } }
- {fields.map((f) => (
-
+ {orderedFields.map((f) => (
+
{fieldDisplayName(f)}
{f.type === 'bool' ? (
createFields(collection), [collection]);
+ const orderedFields = useMemo(
+ () => [...fields.filter((field) => !isMinorField(field)), ...fields.filter((field) => isMinorField(field))],
+ [fields]
+ );
const required = useMemo(() => requiredFieldNames(collection), [collection]);
const requiredReferenceFieldNames = useMemo(
() => fields.filter((field) => field.type === 'reference' && required.has(field.name)).map((field) => field.name),
@@ -265,8 +273,8 @@ export default function CreateRecordPage(props: { params: { collection: string }
)}
- {fields.map((f) => (
-
+ {orderedFields.map((f) => (
+
{fieldDisplayName(f)} {required.has(f.name) ? required : null}
diff --git a/packages/templates/next-export-ui/app/globals.css b/packages/templates/next-export-ui/app/globals.css
index af4eef6..89ef9e4 100644
--- a/packages/templates/next-export-ui/app/globals.css
+++ b/packages/templates/next-export-ui/app/globals.css
@@ -894,7 +894,7 @@ html[data-theme='dark'] .themeToggle::after {
}
.recordPreviewCellCompact {
- padding: 12px;
+ padding: 12px 14px;
}
.recordPreviewLabel {
@@ -1062,7 +1062,7 @@ html[data-theme='dark'] .btn.primary {
.formGrid {
display: grid;
gap: 16px;
- grid-template-columns: minmax(260px, 0.86fr) minmax(340px, 1.14fr);
+ grid-template-columns: minmax(0, 1fr);
margin-top: 16px;
}
@@ -1072,6 +1072,7 @@ html[data-theme='dark'] .btn.primary {
.fieldGroupMinor {
align-self: start;
+ max-width: 520px;
}
.textarea {
@@ -1083,6 +1084,36 @@ html[data-theme='dark'] .btn.primary {
min-height: 220px;
}
+.selectMinor {
+ font-size: 0.92rem;
+ min-height: 50px;
+ padding: 12px 14px;
+}
+
+.referenceIdentityShell {
+ background: color-mix(in srgb, var(--th-panel-muted) 96%, transparent);
+ border: 1px solid var(--th-border);
+ display: grid;
+ gap: 8px;
+ padding: 14px 16px;
+}
+
+.referenceIdentityMeta {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.referenceIdentityTitle {
+ font-size: 1.02rem;
+ font-weight: 700;
+}
+
+.referenceIdentitySubtitle {
+ color: var(--th-muted);
+}
+
.recordHeroBody {
font-size: 1.18rem;
line-height: 1.7;
@@ -1282,9 +1313,6 @@ html[data-theme='dark'] .btn.primary {
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
}
- .formGrid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
}
@media (min-width: 980px) {
diff --git a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
index 0f9af62..df3aeec 100644
--- a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
+++ b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
@@ -32,6 +32,10 @@ export default function ReferenceFieldInput(props: {
const resolvedValue = value.trim();
const hasResolvedValue = resolvedValue !== '' && options.some((option) => String(option.id) === resolvedValue);
const selectedSummary = selectedOption ? recordSummary(selectedOption.record) : null;
+ const singleOwnedOption = mustOwn && ownedOptions.length === 1 ? ownedOptions[0] : null;
+ const compactLockedSelection = Boolean(
+ singleOwnedOption && selectedOption && String(selectedOption.id) === String(singleOwnedOption.id)
+ );
if (!relatedCollection) {
return (
@@ -48,21 +52,32 @@ export default function ReferenceFieldInput(props: {
return (
<>
-
onChange(event.target.value)}
- >
-
- {loading ? `Loading ${relatedCollection.name}…` : options.length > 0 ? `Select ${relatedCollection.name}` : `No ${relatedCollection.name} records found`}
-
- {options.map((option) => (
-
- {option.label}{option.owned ? ' · owned by connected wallet' : ''}
+ {compactLockedSelection ? (
+
+
+ {relatedCollection.name} #{String(selectedOption?.id ?? '')}
+ wallet-owned
+
+
{selectedSummary?.title ?? `${relatedCollection.name} #${String(selectedOption?.id ?? '')}`}
+ {selectedSummary?.subtitle ?
{selectedSummary.subtitle}
: null}
+
+ ) : (
+ onChange(event.target.value)}
+ >
+
+ {loading ? `Loading ${relatedCollection.name}…` : options.length > 0 ? `Select ${relatedCollection.name}` : `No ${relatedCollection.name} records found`}
- ))}
-
+ {options.map((option) => (
+
+ {option.label}{option.owned ? ' · owned by connected wallet' : ''}
+
+ ))}
+
+ )}
{selectedSummary ? (
@@ -103,7 +118,9 @@ export default function ReferenceFieldInput(props: {
: options.length > 0
? ownedOptions.length > 0
? mustOwn
- ? `This relation requires a ${relatedCollection.name} owned by the connected wallet.${account ? ` Showing records for ${account.slice(0, 6)}…` : ''}.`
+ ? compactLockedSelection
+ ? `Using your wallet-owned ${relatedCollection.name} for this record.`
+ : `Choose a ${relatedCollection.name} owned by the connected wallet.`
: `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first and your last choice is remembered${account ? ` for ${account.slice(0, 6)}…` : ''}.`
: `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry.`
: mustOwn
From d93f0de7436135fc4a9d5c4ee2f511ff72d19160 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 15:56:36 -1000
Subject: [PATCH 17/24] Refine microblog hero copy
---
apps/example/microblog.schema.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/example/microblog.schema.json b/apps/example/microblog.schema.json
index 3e37a02..70399a0 100644
--- a/apps/example/microblog.schema.json
+++ b/apps/example/microblog.schema.json
@@ -48,7 +48,7 @@
"eyebrow": "/tokenhost/microblog",
"title": "Microblog posts",
"accent": "with on-chain discovery and first-class media.",
- "description": "This example app uses Token Host's native hashtag index on Post.body and the native upload field flow for Post.image. Posts reference Profile records instead of copying handles into each post.",
+ "description": "Live on Filecoin Calibration, this microblog combines native on-chain hashtag discovery with Filecoin Onchain Cloud image uploads. Posts stay tied to wallet-owned Profile identities instead of copying usernames into each record.",
"badges": ["native hashtags", "native image uploads", "filecoin-ready"],
"actions": [
{ "label": "Compose post", "href": "/Post/?mode=new", "variant": "primary" },
From 2a097dbb004abfddd281d7c5b1cfafd5e0653051 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 16:00:54 -1000
Subject: [PATCH 18/24] Tone down cyber grid background
---
.../templates/next-export-ui/app/globals.css | 27 +++++++++++--------
.../templates/next-export-ui/app/layout.tsx | 4 +--
.../src/components/HomeOnlyLivingGrid.tsx | 16 +++++++++++
3 files changed, 34 insertions(+), 13 deletions(-)
create mode 100644 packages/templates/next-export-ui/src/components/HomeOnlyLivingGrid.tsx
diff --git a/packages/templates/next-export-ui/app/globals.css b/packages/templates/next-export-ui/app/globals.css
index 89ef9e4..53702c4 100644
--- a/packages/templates/next-export-ui/app/globals.css
+++ b/packages/templates/next-export-ui/app/globals.css
@@ -52,9 +52,9 @@
--th-success: hsl(142 70% 35%);
--th-danger: hsl(0 84% 60%);
--th-grid: hsl(190 100% 50% / 0.05);
- --th-grid-strong: hsl(190 100% 50% / 0.75);
- --th-glow-left: hsl(190 100% 50% / 0.18);
- --th-glow-right: hsl(300 100% 75% / 0.18);
+ --th-grid-strong: hsl(190 100% 50% / 0.34);
+ --th-glow-left: hsl(190 100% 50% / 0.1);
+ --th-glow-right: hsl(300 100% 75% / 0.08);
--th-shadow: 4px 4px 0 rgba(0, 0, 0, 0.03);
--th-shadow-soft: 4px 4px 0 rgba(0, 0, 0, 0.03);
--th-badge-bg: hsl(0 0% 100%);
@@ -96,9 +96,9 @@ html[data-theme='dark'] {
--th-success: hsl(142 70% 45%);
--th-danger: hsl(0 100% 60%);
--th-grid: hsl(300 100% 75% / 0.04);
- --th-grid-strong: hsl(300 100% 75% / 0.75);
- --th-glow-left: hsl(190 100% 50% / 0.14);
- --th-glow-right: hsl(300 100% 60% / 0.12);
+ --th-grid-strong: hsl(300 100% 75% / 0.32);
+ --th-glow-left: hsl(190 100% 50% / 0.08);
+ --th-glow-right: hsl(300 100% 60% / 0.08);
--th-shadow: none;
--th-shadow-soft: none;
--th-badge-bg: hsl(240 10% 8%);
@@ -166,10 +166,10 @@ svg {
inset: 0;
position: absolute;
background-image:
- linear-gradient(to right, color-mix(in srgb, var(--th-primary) 16%, transparent) 29px, transparent 29px),
- linear-gradient(to bottom, color-mix(in srgb, var(--th-primary) 16%, transparent) 29px, transparent 29px),
- linear-gradient(to right, color-mix(in srgb, var(--th-primary) 75%, transparent) 2px, transparent 2px),
- linear-gradient(to bottom, color-mix(in srgb, var(--th-primary) 75%, transparent) 2px, transparent 2px);
+ linear-gradient(to right, color-mix(in srgb, var(--th-primary) 10%, transparent) 29px, transparent 29px),
+ linear-gradient(to bottom, color-mix(in srgb, var(--th-primary) 10%, transparent) 29px, transparent 29px),
+ linear-gradient(to right, color-mix(in srgb, var(--th-primary) 34%, transparent) 2px, transparent 2px),
+ linear-gradient(to bottom, color-mix(in srgb, var(--th-primary) 34%, transparent) 2px, transparent 2px);
background-repeat: repeat;
background-size: 30px 30px, 30px 30px, 30px 30px, 30px 30px;
-webkit-mask-image:
@@ -184,6 +184,7 @@ svg {
mask-composite: add;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
+ opacity: 0.52;
}
.siteLivingGridCanvas {
@@ -194,7 +195,11 @@ svg {
}
html[data-theme='dark'] .siteLivingGridCanvas {
- opacity: 0.9;
+ opacity: 0.72;
+}
+
+html[data-theme='dark'] .siteGridLayer {
+ opacity: 0.66;
}
.container {
diff --git a/packages/templates/next-export-ui/app/layout.tsx b/packages/templates/next-export-ui/app/layout.tsx
index cad9b29..4640974 100644
--- a/packages/templates/next-export-ui/app/layout.tsx
+++ b/packages/templates/next-export-ui/app/layout.tsx
@@ -6,7 +6,7 @@ import Link from 'next/link';
import ConnectButton from '../src/components/ConnectButton';
import FaucetButton from '../src/components/FaucetButton';
import FooterDeploymentMeta from '../src/components/FooterDeploymentMeta';
-import LivingGrid from '../src/components/LivingGrid';
+import HomeOnlyLivingGrid from '../src/components/HomeOnlyLivingGrid';
import NetworkStatus from '../src/components/NetworkStatus';
import ThemeToggle from '../src/components/ThemeToggle';
import { primaryCollection, ths } from '../src/lib/ths';
@@ -45,7 +45,7 @@ export default function RootLayout(props: { children: React.ReactNode }) {
diff --git a/packages/templates/next-export-ui/src/components/HomeOnlyLivingGrid.tsx b/packages/templates/next-export-ui/src/components/HomeOnlyLivingGrid.tsx
new file mode 100644
index 0000000..6db8e85
--- /dev/null
+++ b/packages/templates/next-export-ui/src/components/HomeOnlyLivingGrid.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import React from 'react';
+import { usePathname } from 'next/navigation';
+
+import LivingGrid from './LivingGrid';
+
+export default function HomeOnlyLivingGrid() {
+ const pathname = usePathname();
+
+ if (pathname && pathname !== '/') {
+ return null;
+ }
+
+ return ;
+}
From f3e82d2e1c08136fc9978e7f3fe34b30dec7f07b Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 16:07:06 -1000
Subject: [PATCH 19/24] Pluralize generated nav labels
---
packages/templates/next-export-ui/app/layout.tsx | 5 ++---
packages/templates/next-export-ui/src/lib/ths.ts | 11 +++++++++++
test/testCliGenerateUi.js | 7 +++++++
3 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/packages/templates/next-export-ui/app/layout.tsx b/packages/templates/next-export-ui/app/layout.tsx
index 4640974..eafb3b3 100644
--- a/packages/templates/next-export-ui/app/layout.tsx
+++ b/packages/templates/next-export-ui/app/layout.tsx
@@ -9,7 +9,7 @@ import FooterDeploymentMeta from '../src/components/FooterDeploymentMeta';
import HomeOnlyLivingGrid from '../src/components/HomeOnlyLivingGrid';
import NetworkStatus from '../src/components/NetworkStatus';
import ThemeToggle from '../src/components/ThemeToggle';
-import { primaryCollection, ths } from '../src/lib/ths';
+import { collectionNavLabel, primaryCollection, ths } from '../src/lib/ths';
export const metadata = {
title: `${ths.app.name} - Token Host`,
@@ -63,10 +63,9 @@ export default function RootLayout(props: { children: React.ReactNode }) {
{navCollections.map((collection) => (
- {collection.name}
+ {collectionNavLabel(collection)}
))}
- Manifest
diff --git a/packages/templates/next-export-ui/src/lib/ths.ts b/packages/templates/next-export-ui/src/lib/ths.ts
index ccd0852..af602b8 100644
--- a/packages/templates/next-export-ui/src/lib/ths.ts
+++ b/packages/templates/next-export-ui/src/lib/ths.ts
@@ -220,3 +220,14 @@ export function fieldDisplayName(field: ThsField): string {
if (typeof field.ui?.label === 'string' && field.ui.label.trim()) return field.ui.label.trim();
return field.name;
}
+
+export function collectionNavLabel(collection: ThsCollection): string {
+ const explicitPlural = typeof collection.plural === 'string' ? collection.plural.trim() : '';
+ if (explicitPlural) return explicitPlural;
+
+ const name = String(collection.name ?? '').trim();
+ if (!name) return 'Records';
+ if (/(s|x|z|sh|ch)$/i.test(name)) return `${name}es`;
+ if (/[^aeiou]y$/i.test(name)) return `${name.slice(0, -1)}ies`;
+ return `${name}s`;
+}
diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js
index 20ac363..da85ea8 100644
--- a/test/testCliGenerateUi.js
+++ b/test/testCliGenerateUi.js
@@ -272,6 +272,13 @@ describe('th generate (UI template)', function () {
const generatedTokens = fs.readFileSync(path.join(outDir, 'ui', 'src', 'theme', 'tokens.json'), 'utf-8');
expect(generatedTokens).to.equal(readTemplateThemeTokens());
+
+ const generatedThsLib = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'ths.ts'), 'utf-8');
+ expect(generatedThsLib).to.include('export function collectionNavLabel');
+
+ const generatedLayout = fs.readFileSync(path.join(outDir, 'ui', 'app', 'layout.tsx'), 'utf-8');
+ expect(generatedLayout).to.include('collectionNavLabel(collection)');
+ expect(generatedLayout).to.not.include('
Manifest ');
});
it('upstreams relation metadata into reference-aware generated CRUD UI', function () {
From 7529a40865df4dfed37e046d8b41bdc4eab80c7a Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 16:11:09 -1000
Subject: [PATCH 20/24] Simplify owned profile field display
---
.../templates/next-export-ui/app/globals.css | 22 +++++++++++++++++--
.../src/components/ReferenceFieldInput.tsx | 10 +++++----
2 files changed, 26 insertions(+), 6 deletions(-)
diff --git a/packages/templates/next-export-ui/app/globals.css b/packages/templates/next-export-ui/app/globals.css
index 53702c4..47a6c8d 100644
--- a/packages/templates/next-export-ui/app/globals.css
+++ b/packages/templates/next-export-ui/app/globals.css
@@ -1100,7 +1100,8 @@ html[data-theme='dark'] .btn.primary {
border: 1px solid var(--th-border);
display: grid;
gap: 8px;
- padding: 14px 16px;
+ max-width: 420px;
+ padding: 12px 14px;
}
.referenceIdentityMeta {
@@ -1110,13 +1111,30 @@ html[data-theme='dark'] .btn.primary {
gap: 8px;
}
+.referenceIdentityLabel {
+ color: var(--th-muted);
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.referenceIdentityRow {
+ align-items: baseline;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
.referenceIdentityTitle {
- font-size: 1.02rem;
+ font-size: 1rem;
font-weight: 700;
}
.referenceIdentitySubtitle {
color: var(--th-muted);
+ font-size: 0.95rem;
}
.recordHeroBody {
diff --git a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
index df3aeec..e24edaf 100644
--- a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
+++ b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx
@@ -55,11 +55,13 @@ export default function ReferenceFieldInput(props: {
{compactLockedSelection ? (
- {relatedCollection.name} #{String(selectedOption?.id ?? '')}
+ Posting as
wallet-owned
-
{selectedSummary?.title ?? `${relatedCollection.name} #${String(selectedOption?.id ?? '')}`}
- {selectedSummary?.subtitle ?
{selectedSummary.subtitle}
: null}
+
+ {selectedSummary?.title ?? `${relatedCollection.name} #${String(selectedOption?.id ?? '')}`}
+ {selectedSummary?.subtitle ? {selectedSummary.subtitle} : null}
+
) : (
)}
- {selectedSummary ? (
+ {selectedSummary && !compactLockedSelection ? (
From 8329bb2cdae2a18134ff556909c0019df77ddcac Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 16:15:21 -1000
Subject: [PATCH 21/24] Move feed spacing to shared grid layout
---
packages/templates/next-export-ui/app/globals.css | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/templates/next-export-ui/app/globals.css b/packages/templates/next-export-ui/app/globals.css
index 47a6c8d..5c451ea 100644
--- a/packages/templates/next-export-ui/app/globals.css
+++ b/packages/templates/next-export-ui/app/globals.css
@@ -672,6 +672,7 @@ html[data-theme='dark'] .themeToggle::after {
display: grid;
gap: 18px;
grid-template-columns: repeat(12, minmax(0, 1fr));
+ margin-bottom: 18px;
}
.card {
From 256d4984bcd28848ecddcf6e560fc7488f937d6b Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 16:16:30 -1000
Subject: [PATCH 22/24] Trim microblog hero copy
---
apps/example/microblog.schema.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/example/microblog.schema.json b/apps/example/microblog.schema.json
index 70399a0..c066550 100644
--- a/apps/example/microblog.schema.json
+++ b/apps/example/microblog.schema.json
@@ -48,7 +48,7 @@
"eyebrow": "/tokenhost/microblog",
"title": "Microblog posts",
"accent": "with on-chain discovery and first-class media.",
- "description": "Live on Filecoin Calibration, this microblog combines native on-chain hashtag discovery with Filecoin Onchain Cloud image uploads. Posts stay tied to wallet-owned Profile identities instead of copying usernames into each record.",
+ "description": "Live on Filecoin Calibration, this microblog combines native on-chain hashtag discovery with Filecoin Onchain Cloud image uploads.",
"badges": ["native hashtags", "native image uploads", "filecoin-ready"],
"actions": [
{ "label": "Compose post", "href": "/Post/?mode=new", "variant": "primary" },
From a6b014e5c061d0583dde9401143c6dc2f698beaa Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 16:20:51 -1000
Subject: [PATCH 23/24] Widen generated hero titles
---
packages/templates/next-export-ui/app/globals.css | 4 ++++
.../next-export-ui/src/components/GeneratedHomePageClient.tsx | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/packages/templates/next-export-ui/app/globals.css b/packages/templates/next-export-ui/app/globals.css
index 5c451ea..685a664 100644
--- a/packages/templates/next-export-ui/app/globals.css
+++ b/packages/templates/next-export-ui/app/globals.css
@@ -737,6 +737,10 @@ html[data-theme='dark'] .themeToggle::after {
color: var(--th-primary);
}
+.displayTitleHero {
+ max-width: none;
+}
+
.displayTitleCompact {
font-size: clamp(2.05rem, 4vw, 3.05rem);
line-height: 0.98;
diff --git a/packages/templates/next-export-ui/src/components/GeneratedHomePageClient.tsx b/packages/templates/next-export-ui/src/components/GeneratedHomePageClient.tsx
index dfe0705..9ac8283 100644
--- a/packages/templates/next-export-ui/src/components/GeneratedHomePageClient.tsx
+++ b/packages/templates/next-export-ui/src/components/GeneratedHomePageClient.tsx
@@ -70,7 +70,7 @@ export default function GeneratedHomePageClient() {
))}
-
+
{section.title}
{section.accent ? (
<>
From e640c06e2cc04ed770ca586af191758a14dff0d0 Mon Sep 17 00:00:00 2001
From: Mikers
Date: Tue, 24 Mar 2026 16:23:12 -1000
Subject: [PATCH 24/24] Rename microblog records CTA
---
apps/example/microblog.schema.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/example/microblog.schema.json b/apps/example/microblog.schema.json
index c066550..00959c1 100644
--- a/apps/example/microblog.schema.json
+++ b/apps/example/microblog.schema.json
@@ -53,7 +53,7 @@
"actions": [
{ "label": "Compose post", "href": "/Post/?mode=new", "variant": "primary" },
{ "label": "Create profile", "href": "/Profile/?mode=new" },
- { "label": "Browse raw records", "href": "/Post/" }
+ { "label": "View Posts", "href": "/Post/" }
]
},
{