@@ -190,10 +273,10 @@ export default function CreateRecordPage(props: { params: { collection: string }
)}
- {fields.map((f) => (
-
+ {orderedFields.map((f) => (
+
- {f.name} {required.has(f.name) ? required : null}
+ {fieldDisplayName(f)} {required.has(f.name) ? required : null}
{f.type === 'bool' ? (
setForm((prev) => ({ ...prev, [f.name]: next }))}
+ onBusyChange={(busy) => setBusyUploads((prev) => ({ ...prev, [f.name]: busy }))}
+ />
+ ) : f.type === 'reference' ? (
+ setForm((prev) => ({ ...prev, [f.name]: next }))}
+ />
+ ) : isLongTextField(f) ? (
+
@@ -227,9 +330,9 @@ export default function CreateRecordPage(props: { params: { collection: string }
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/[collection]/view/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
index c28c60f..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,9 +9,10 @@ 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 { 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';
function getValue(record: any, key: string, fallbackIndex?: number): any {
if (record && typeof record === 'object' && key in record) {
@@ -28,7 +29,24 @@ function fieldIndex(collection: ThsCollection, field: ThsField): number {
return 9 + Math.max(0, idx);
}
-function renderFieldValue(field: ThsField, rendered: string) {
+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;
+ 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 +63,20 @@ function renderFieldValue(field: ThsField, rendered: string) {
);
}
+ if (field.type === 'reference') {
+ return (
+
+ );
+ }
+
return
{rendered} ;
}
@@ -289,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)) : '—'}
@@ -317,26 +384,20 @@ 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 (
- {f.name}
- {renderFieldValue(f, rendered)}
+ {fieldDisplayName(f)}
+ {renderFieldValue({ collection, field: f, rendered, raw: v, abi, publicClient, address: appAddress })}
);
})}
-
+
{transferEnabled(collection) ? (
diff --git a/packages/templates/next-export-ui/app/globals.css b/packages/templates/next-export-ui/app/globals.css
index ae96912..685a664 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,11 +184,7 @@ 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;
+ opacity: 0.52;
}
.siteLivingGridCanvas {
@@ -199,7 +195,11 @@ svg {
}
html[data-theme='dark'] .siteLivingGridCanvas {
- opacity: 0.9;
+ opacity: 0.72;
+}
+
+html[data-theme='dark'] .siteGridLayer {
+ opacity: 0.66;
}
.container {
@@ -461,10 +461,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;
}
@@ -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 {
@@ -713,6 +714,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);
@@ -727,6 +737,16 @@ 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;
+ max-width: none;
+}
+
.lead {
color: var(--th-muted);
font-size: 0.98rem;
@@ -883,6 +903,10 @@ html[data-theme='dark'] .themeToggle::after {
padding: 14px;
}
+.recordPreviewCellCompact {
+ padding: 12px 14px;
+}
+
.recordPreviewLabel {
color: var(--th-muted);
font-family: var(--th-font-mono);
@@ -1000,6 +1024,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);
@@ -1038,6 +1072,7 @@ html[data-theme='dark'] .btn.primary {
.formGrid {
display: grid;
gap: 16px;
+ grid-template-columns: minmax(0, 1fr);
margin-top: 16px;
}
@@ -1045,6 +1080,83 @@ html[data-theme='dark'] .btn.primary {
min-width: 0;
}
+.fieldGroupMinor {
+ align-self: start;
+ max-width: 520px;
+}
+
+.textarea {
+ min-height: 140px;
+ resize: vertical;
+}
+
+.textareaFeature {
+ 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;
+ max-width: 420px;
+ padding: 12px 14px;
+}
+
+.referenceIdentityMeta {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ 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: 1rem;
+ font-weight: 700;
+}
+
+.referenceIdentitySubtitle {
+ color: var(--th-muted);
+ font-size: 0.95rem;
+}
+
+.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;
@@ -1229,9 +1341,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) {
@@ -1324,6 +1433,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;
@@ -1368,53 +1481,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;
diff --git a/packages/templates/next-export-ui/app/layout.tsx b/packages/templates/next-export-ui/app/layout.tsx
index d5ce31c..eafb3b3 100644
--- a/packages/templates/next-export-ui/app/layout.tsx
+++ b/packages/templates/next-export-ui/app/layout.tsx
@@ -6,10 +6,10 @@ 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 { ths } from '../src/lib/ths';
+import { collectionNavLabel, 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 (
@@ -43,7 +45,7 @@ export default function RootLayout(props: { children: React.ReactNode }) {
@@ -97,10 +97,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/app/page.tsx b/packages/templates/next-export-ui/app/page.tsx
index ce0f4ec..ba2b731 100644
--- a/packages/templates/next-export-ui/app/page.tsx
+++ b/packages/templates/next-export-ui/app/page.tsx
@@ -1,13 +1,21 @@
import Link from 'next/link';
-import { displayField, hasCreatePayment, mutableFields, ths, transferEnabled } from '../src/lib/ths';
+import GeneratedHomePageClient from '../src/components/GeneratedHomePageClient';
+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) {
+ 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;
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 +65,9 @@ export default function HomePage() {
paid creates: {paidCollections}
+ relations: {totalRelations}
+ indexed collections: {indexedCollections}
+ media collections: {imageCollections}
schema {ths.schemaVersion}
@@ -71,6 +82,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 +96,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
@@ -111,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}
@@ -122,17 +147,40 @@ 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}
+ {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')}
+
+ ))}
+
Browse
Create
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/collection-route/CollectionLayout.tsx b/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
index 08d6913..2c6a07d 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);
@@ -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,12 +32,12 @@ export default function CollectionLayout(props: { children: ReactNode; collectio
List records
- Create record
+ Create {collection.name}
{collection.fields.slice(0, 6).map((field) => (
- {field.name}
+ {fieldDisplayName(field)}
))}
@@ -63,7 +57,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/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/components/GeneratedFeedStream.tsx b/packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx
new file mode 100644
index 0000000..96f2757
--- /dev/null
+++ b/packages/templates/next-export-ui/src/components/GeneratedFeedStream.tsx
@@ -0,0 +1,94 @@
+'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;
+ const viewHref = `/${props.feed.collection}/?mode=view&id=${String(item.id)}`;
+ 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..9ac8283
--- /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/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 ;
+}
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
+
+ {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}.`}
+