diff --git a/examples/white-label-minimal/.env.example b/examples/white-label-minimal/.env.example index 50cf776..ead90e2 100644 --- a/examples/white-label-minimal/.env.example +++ b/examples/white-label-minimal/.env.example @@ -3,17 +3,16 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= DATABASE_URL= DIRECT_URL= -# Workspace key; it also needs the apps:watch scope for live builder updates. +# Personal access token of the integration account, created in your enterprise +# workspace with access to all apps and full permission. +BASE44_ACCESS_TOKEN= +BASE44_WORKSPACE_ID= +# Workspace API key for live updates (apps:watch) and for signing builders into +# their latest build (Provision app users, Mint embed sign-in tokens). BASE44_SVC_KEY= -BASE44_ORG_ID= # Set to your Base44 HTTPS origin. BASE44_PLATFORM_HOST= # Set NEXTAUTH_URL explicitly, including in local development. NEXTAUTH_URL=http://127.0.0.1:3001 -# Exact HTTPS origin in production; leave empty for localhost. -BUILDER_ORIGIN= # Optional: use Sunny's existing OAuth callback with a shared NEXTAUTH_SECRET. AUTH_REDIRECT_PROXY_URL= - -# Static preview hosting domain; confirm for your Base44 environment. -BASE44_STATIC_PREVIEW_DOMAIN= diff --git a/examples/white-label-minimal/AGENTS.md b/examples/white-label-minimal/AGENTS.md index 643577d..fc7ad3c 100644 --- a/examples/white-label-minimal/AGENTS.md +++ b/examples/white-label-minimal/AGENTS.md @@ -1,3 +1,18 @@ +# Tiny Sunny + +This example is read by developers who just read the +[white label docs](https://docs.base44.com/developers/white-label/overview), and its snippets go +into integration guides. Write it the way a careful developer would write a guide. + +- **One Base44 call, one function.** Each function shows its request in the open: path, method, + body. A reader should be able to paste it into a guide as it is. +- **Name things after the docs.** Use the step and field names the docs use. +- **Plain control flow.** `if` statements on one clear state, not chained ternaries or clever helpers. + Prefer a few obvious lines over one dense line. +- **Guard only what matters.** Keep the token on the server, check app ownership, filter what goes to + the browser, keep `X-Request-ID` stable. Skip checks for states that cannot happen. +- **Comments say why, briefly.** No comment that repeats the code. + # This is NOT the Next.js you know diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md index 474ca27..3d4f65e 100644 --- a/examples/white-label-minimal/README.md +++ b/examples/white-label-minimal/README.md @@ -1,12 +1,31 @@ # Tiny Sunny -A minimal Base44 integration: sign in, connect a workspace, create an app, -chat with the builder, answer its questions, preview, and publish. +A minimal Base44 integration: sign in, create an app, chat with the builder, +answer its questions, preview, and publish. -This example uses the [service-user tenancy model](https://base44-docs-white-label-rewrite.mintlify.site/white-label/tenancy-and-credentials#service-users). -Each builder gets a Base44 service user that owns their apps. Your backend provisions -that identity using a workspace API key, then uses the service user's access token -to create and manage apps. Both credentials stay on the server. +It follows Base44's [tenancy model](https://docs.base44.com/developers/white-label/tenancy-and-credentials): +one Base44 account owns every app, and your server calls Base44 with that account's +personal access token. Base44 cannot tell your builders apart, so Tiny keeps them +apart itself: it records which builder owns each app and checks that on every call. +The token never reaches the browser. + +## From the docs to the code + +If you have read the [white label docs](https://docs.base44.com/developers/white-label/overview), +each part of them lives in one place here: + +| Docs | Code | +| --- | --- | +| [Tenancy and credentials](https://docs.base44.com/developers/white-label/tenancy-and-credentials) | [server/base44/config.ts](server/base44/config.ts), [server/base44/request.ts](server/base44/request.ts) | +| Keeping builders apart | [server/actions.ts](server/actions.ts), [server/ownership.ts](server/ownership.ts) | +| [The build turn](https://docs.base44.com/developers/white-label/the-build-turn), steps 1–6 | [server/base44/build-turn.ts](server/base44/build-turn.ts), one function per call, numbered by step | +| Watch it build (live, instead of polling) | [server/base44/live-updates.ts](server/base44/live-updates.ts), [client/useBuildTurn.ts](client/useBuildTurn.ts) | +| Answer the agent's questions | [components/Question.tsx](client/components/Question.tsx), one UI per `waiting_on.kind` | +| Publish the app | `publish()` in [components/Builder.tsx](client/components/Builder.tsx) | +| [Embed the app](https://docs.base44.com/developers/white-label/embed-the-app) | [server/base44/embed.ts](server/base44/embed.ts), used for every preview | +| [Custom instructions](https://docs.base44.com/developers/white-label/custom-instructions) | [server/base44/custom-instructions.ts](server/base44/custom-instructions.ts) | + +Everything else is Tiny's own product: sign-in, the app list, and the chat UI. ## Run locally @@ -18,65 +37,62 @@ cp examples/white-label-minimal/.env.example examples/white-label-minimal/.env.l ``` Fill in `.env.local` with Sunny's Google OAuth credentials, Auth.js secret, -migrated Prisma database, and Base44 workspace service key. Keep `NEXTAUTH_URL` -set to `http://127.0.0.1:3001`, then run: +migrated Prisma database, and the integration account's +[personal access token](https://docs.base44.com/Workspaces/Personal-access-tokens) and workspace ID. +Create the token in your enterprise workspace, with access to all apps and full permission. +Live chat updates also need a workspace API key with the `apps:watch` scope (`BASE44_SVC_KEY`). +Keep `NEXTAUTH_URL` set to `http://127.0.0.1:3001`, then run: ```sh npm run minimal:dev ``` -Open [Tiny Sunny](http://127.0.0.1:3001), sign in, and select **Connect workspace**. -Apps are saved in Sunny's ownership table, so existing apps in the same database -and workspace are available here too. +Open [Tiny Sunny](http://127.0.0.1:3001) and sign in. ## Copy the integration -| Folder | Responsibility | +| Folder | What runs there | | --- | --- | -| `lib/base44/` | Base44 API calls, identity lifecycle, and custom instructions | -| `lib/server/` | Authentication and request handling | -| `lib/storage/` | Database access and app ownership | -| `lib/chat/` | Browser API calls and conversation helpers | -| `lib/types.ts` | Shared types | - -Start with [lib/base44/client.ts](lib/base44/client.ts). It contains the Base44 -endpoints and request payloads for creation, conversation, tool answers, preview, -and publishing. Copy it with `lib/base44/config.ts`, `lib/base44/error.ts`, -`lib/base44/custom-instructions.ts`, and `lib/types.ts`. It uses `fetch` and `server-only`, with +| `server/` | The server: Base44 calls ([server/base44/](server/base44)), server actions, sign-in, and app ownership | +| `client/` | The browser: the build turn's state, live updates, and the UI components | +| `app/` | Next.js routes only: the page, its layout, and the sign-in callback | +| `types.ts` | Types both sides share | + +Start with [server/base44/build-turn.ts](server/base44/build-turn.ts). It has one function per Base44 call, +numbered by the step of the build turn: create, send a prompt, watch, answer, and publish. +[embed.ts](server/base44/embed.ts) and [live-updates.ts](server/base44/live-updates.ts) cover the other two docs +pages, and [request.ts](server/base44/request.ts) is the `fetch` they share. Copy them with +`server/base44/config.ts`, `server/base44/error.ts`, `server/base44/custom-instructions.ts`, and `types.ts`. Every endpoint it calls is described in +Base44's [Apps API reference](https://docs.base44.com/developers/references/apps-api/get-started/overview), which also links the OpenAPI spec if you prefer to generate a typed client. It uses `fetch` and `server-only`, with no Sunny imports. Set `BASE44_PLATFORM_HOST` to your Base44 HTTPS origin and -adapt `lib/base44/custom-instructions.ts` to your product. New apps use the first 80 +adapt `server/base44/custom-instructions.ts` to your product. New apps use the first 80 characters of the prompt as their initial name. For a complete browser integration, follow this path: ```text -components/Builder.tsx → lib/chat/builder-api.ts → app/api/base44/route.ts - → lib/server/api-handler.ts → lib/server/app-service.ts → lib/base44/client.ts +client/components/Builder.tsx → server/actions.ts → server/base44/build-turn.ts ``` -[lib/server/app-service.ts](lib/server/app-service.ts) is where your application plugs in: +[server/actions.ts](server/actions.ts) holds Tiny's server actions, and is where your application plugs in: -- [server/auth.ts](lib/server/auth.ts) supplies the verified user from your session. -- [base44/identity.ts](lib/base44/identity.ts) provisions a service user, mints its token, - and refreshes it near expiry. -- [storage/app-repository.ts](lib/storage/app-repository.ts) saves ownership and scopes app access to that user. +- [server/auth.ts](server/auth.ts) supplies the verified user from your session. +- [server/ownership.ts](server/ownership.ts) records which builder created each app, and checks it. -[prisma/schema.prisma](prisma/schema.prisma) defines identity links and app ownership. -[lib/storage/db.ts](lib/storage/db.ts) connects through `DATABASE_URL`. The Prisma client is generated +[prisma/schema.prisma](prisma/schema.prisma) defines app ownership. +[server/db.ts](server/db.ts) connects through `DATABASE_URL`. The Prisma client is generated on install and build; run `npm run db:generate` from the example after schema edits. -Google login and Base44 connection are separate. `/api/base44/connection` -provisions a service principal using the workspace key. Builder requests use its -stored token; they do not provision identities or send credentials to the browser. -The API handler validates requests and checks app ownership before app operations. +Every Base44 call sends `Authorization: Bearer ` and `X-Active-Workspace-Id`, and +new apps are created with `organization_id`, so they use your workspace's design system, +skills, and plan. Each server action checks who is signed in and, for an app, that they own it, +before it calls Base44. -For the chat UI, copy `components/`, `lib/chat/`, and `lib/base44/socket-session.ts`. -The chat uses assistant-ui's external-store runtime with Base44's live updates as its -source of truth (see below). `Question.tsx` handles +In the browser, [client/useBuildTurn.ts](client/useBuildTurn.ts) is the whole build turn: it opens +live updates, sends prompts and answers questions. The components only show it. The chat uses +assistant-ui's external-store runtime with Base44's live updates as its source of truth (see below). `Question.tsx` handles approvals, choices, and secrets; retries preserve the original answer and request ID. -Preview URLs stay in page memory and remain stable during normal use. A timed-out -creation may still succeed, so the UI asks users to check before creating again. -An app can only be resumed here if its ownership was saved successfully. +A timed-out creation may still succeed, and the error says so. ## Live builder updates @@ -84,28 +100,27 @@ The chat updates live over the Base44 platform socket, through the Platform SDK, [`@base44/platform`](https://github.com/base44/javascript-sdk/tree/main/packages/platform). ```text -components/useBuilderSocket.ts → lib/chat/live-updates.ts → @base44/platform -lib/chat/builder-api.ts → app/api/base44/route.ts → lib/base44/socket-session.ts +client/useBuildTurn.ts → server/actions.ts → server/base44/live-updates.ts +client/useBuildTurn.ts → @base44/platform ``` -1. **Server** ([socket-session.ts](lib/base44/socket-session.ts)): after the usual +1. **Server** ([server/base44/live-updates.ts](server/base44/live-updates.ts)): after the usual sign-in and app-ownership checks, opens a read-only session for one app with the workspace key. `BASE44_SVC_KEY` needs the `apps:watch` scope. Only the session token reaches the browser. -2. **Browser** ([live-updates.ts](lib/chat/live-updates.ts)): connects with that - token and subscribes to the app. A snapshot (status and the last 50 messages) - arrives on every connect; then `message.updated`, `message.removed` and - `app.status_changed` keep the chat current. -3. **React** ([useBuilderSocket.ts](components/useBuilderSocket.ts)): holds the - state and shows one error with **Reconnect live updates** if the connection stops. +2. **Browser** ([useBuildTurn.ts](client/useBuildTurn.ts), step 2): creates the platform + client with that token and subscribes to the app. A snapshot (status and the last 50 + messages) arrives on every connect; then `message.updated`, `message.removed` and + `app.status_changed` keep the chat current. If the connection stops, the builder sees + one error with **Reconnect live updates**. Kept out to stay minimal; add them in production: - The session is not closed on leave; it expires after an hour. Workspace keys have a limit on open sessions, so close it (`DELETE /api/service/socket-sessions/{id}`) when the builder unmounts. -- The chat shows the last 50 messages. Load older ones over HTTP - (`getConversation`) if you need full history. +- The chat shows the last 50 messages. Load older ones with + [Read conversation messages](https://docs.base44.com/api-reference/read-conversation-messages) if you need full history. - Generated images stay as placeholders until the next reconnect (`image.resolved`). ## Deploy to Netlify @@ -115,7 +130,7 @@ as the package directory. Its `netlify.toml` builds the example without running migrations; use an already migrated Sunny database. Set the variables from `.env.example` for builds and production functions. -Set `NEXTAUTH_URL` and `BUILDER_ORIGIN` to your deployment's exact HTTPS origin. +Set `NEXTAUTH_URL` to your deployment's exact HTTPS origin. Register `/api/auth/callback/google` with your Google OAuth client, or set `AUTH_REDIRECT_PROXY_URL=https://sunny44.com/api/auth` and use Sunny's Google client and `NEXTAUTH_SECRET` for its existing redirect proxy. @@ -132,33 +147,19 @@ npm run minimal:build API tests cover session checks, ownership, validation, and upstream failures. Browser tests use a separate fixture app with mocked Base44 responses. -### Static and live previews - -Browsing uses `https://preview--{slug}.{BASE44_STATIC_PREVIEW_DOMAIN}`. Set -`BASE44_STATIC_PREVIEW_DOMAIN` to the preview hosting domain confirmed for your -Base44 environment. Without it, browsing uses screenshots or placeholders. This URL is derived by Tiny, not returned by Base44. -Apps without a slug show a screenshot or placeholder; browsing never starts a sandbox. -Editing requests a live preview and keeps static visible until the live iframe loads. -A load event only controls the visual transition; it does not verify app health. -Edited widgets remain live for the page session instead of silently switching to a -potentially stale static build. Recovery checks the source window and live origin of `preview:requestRefresh` messages, with at most three recovery attempts per preview session and no retries on authorization failures. This observed Base44 message is not a confirmed public contract, so manual refresh remains available. Recovery reloads the iframe using its last parent-supplied path and parameters; cross-origin navigation inside the app cannot be preserved without a supported bridge. No private -build-status, runtime-auth, or heartbeat endpoints are used. - -### Reusing the preview component - -`components/Base44Preview.tsx` owns static/live rendering, loading, recovery, and manual refresh. It depends only on React. `PreviewFrame` adapts Tiny's app model and backend client to its props: - -```tsx - -``` +### Latest build and live previews + +Every preview is the app signed in as the builder, through +[Embed the app](https://docs.base44.com/developers/white-label/embed-the-app) +([server/base44/embed.ts](server/base44/embed.ts)). The builder is not a Base44 user, so +without it a private app would show its own sign-in page inside the frame. -`loadPreview(appId)` calls your authenticated backend and returns `{ url }`. Reject with an error carrying `status: 401` or `403` to stop automatic recovery on authorization failures. Keep platform credentials on the backend. Changing the app or live mode starts a new preview session; changing the callback does not reload the iframe. +- **Latest build** (`target: "latest_preview"`): app cards, and the stage until the live preview + loads. It needs no sandbox. Before the first build there is no URL, and the card shows the + screenshot or a placeholder. +- **Live preview** (`target: "live_preview"`): while editing. It runs the app's sandbox and shows + each change as the agent makes it. -When copying the component, include the `preview-frame`, `preview-loading-frame`, `preview-fallback`, `preview-controls`, `preview-spinner`, `widget-placeholder`, and `secondary` styles from `app/globals.css`, or provide equivalent styles and a sized parent container. +Each URL works once and expires in 60 seconds, so [AppPreview](client/components/AppPreview.tsx) +asks for a new one whenever it loads a frame, and again on **Refresh preview**. While editing, the +latest build stays visible until the live preview has loaded. diff --git a/examples/white-label-minimal/app/api/auth/[...nextauth]/route.ts b/examples/white-label-minimal/app/api/auth/[...nextauth]/route.ts index 2163731..ea67a23 100644 --- a/examples/white-label-minimal/app/api/auth/[...nextauth]/route.ts +++ b/examples/white-label-minimal/app/api/auth/[...nextauth]/route.ts @@ -1,2 +1,2 @@ -import { handlers } from "../../../../lib/server/auth"; +import { handlers } from "../../../../server/auth"; export const { GET, POST } = handlers; diff --git a/examples/white-label-minimal/app/api/base44/connection/route.ts b/examples/white-label-minimal/app/api/base44/connection/route.ts deleted file mode 100644 index afe3caa..0000000 --- a/examples/white-label-minimal/app/api/base44/connection/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { requireUser } from "../../../../lib/server/auth"; -import { connect, disconnect, getLink, linkStatus } from "../../../../lib/base44/identity"; -import { Base44Error } from "../../../../lib/base44/error"; - -const headers = { "Cache-Control": "no-store, private" }; - -export async function POST(request: Request) { - const origin = request.headers.get("origin"); - const allowed = process.env.BUILDER_ORIGIN || new URL(request.url).origin; - if (origin !== allowed) - return Response.json( - { error: "This request origin is not allowed." }, - { status: 403, headers }, - ); - try { - const actor = await requireUser(); - const payload = await request.json().catch(() => null); - let result; - switch (payload?.action) { - case "status": - result = linkStatus(await getLink(actor.email)); - break; - case "connect": - result = await connect(actor.email); - break; - case "disconnect": - result = await disconnect(actor.email); - break; - default: - return Response.json({ error: "Invalid connection action." }, { status: 400, headers }); - } - return Response.json(result, { headers }); - } catch (error) { - if (error instanceof Base44Error) - return Response.json({ error: error.message }, { status: error.status, headers }); - return Response.json( - { error: "Could not update your Base44 connection." }, - { status: 500, headers }, - ); - } -} diff --git a/examples/white-label-minimal/app/api/base44/route.ts b/examples/white-label-minimal/app/api/base44/route.ts deleted file mode 100644 index efcc9c3..0000000 --- a/examples/white-label-minimal/app/api/base44/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createHandler } from "../../../lib/server/api-handler"; -import { getAppClient } from "../../../lib/server/app-service"; - -export const runtime = "nodejs"; -export const maxDuration = 180; -export const POST = createHandler(getAppClient); diff --git a/examples/white-label-minimal/app/globals.css b/examples/white-label-minimal/app/globals.css index ada2421..e20c719 100644 --- a/examples/white-label-minimal/app/globals.css +++ b/examples/white-label-minimal/app/globals.css @@ -43,8 +43,10 @@ textarea, a { font: inherit; } -button { +button, +a.button { display: inline-flex; + text-decoration: none; align-items: center; justify-content: center; gap: 8px; @@ -147,23 +149,6 @@ h1 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 20px; } -.app-thumbnail { - display: flex; - width: 100%; - aspect-ratio: 4 / 3; - border: 0; - border-radius: 0; - padding: 0; - background: #edf0f4; - color: #c0c7d2; - font-size: 54px; - font-weight: 400; -} -.app-thumbnail img { - width: 100%; - height: 100%; - object-fit: cover; -} .empty-state { min-height: 330px; display: flex; @@ -183,10 +168,6 @@ h1 { font-size: 13px; margin-bottom: 24px; } -.load-more { - display: flex; - margin: 30px auto; -} .error, .builder aside { background: #fff2e7; @@ -426,7 +407,7 @@ iframe { .composer > small { width: 100%; } .delivery { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; margin: 16px 0 8px 34px; padding: 14px 16px; border: 1px solid var(--border); background: white; border-radius: 8px; } .delivery strong { font-size: 13px; font-weight: 600; } -.delivery button { align-self: stretch; font-size: 12px; padding: 9px 12px; border-radius: 4px; } +.delivery button, .delivery .button { align-self: stretch; font-size: 12px; padding: 9px 12px; border-radius: 4px; } .ready-badge { display: inline-flex; align-items: center; gap: 5px; margin: 0; padding: 3px 8px 3px 6px; border-radius: 999px; background: var(--ready-surface); color: var(--ready-ink); font-size: 11px; font-weight: 500; line-height: 1; } .build-progress { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; margin: 12px 0 12px 34px; } .builder > details, .builder > aside { max-height: 35%; overflow: auto; flex-shrink: 0; margin: 12px; } @@ -451,9 +432,6 @@ iframe { @media (max-width: 600px) { .widget-actions button { min-width: 40px; min-height: 40px; } .widget-heading { padding: 8px; } .widget-preview { height: 420px; } } .preview-frame { position: relative; height: 100%; min-height: 240px; display: flex; flex-direction: column; } -/* Base44Preview depends only on React, so its spinner is a styled element - rather than an icon import. */ -.preview-spinner { width: 18px; height: 18px; border: 2px solid var(--border); border-top-color: var(--muted); border-radius: 50%; animation: spin 0.8s linear infinite; } .preview-frame iframe { flex: 1; min-height: 0; width: 100%; margin: 0; border: 0; } .preview-frame .preview-loading-frame { position: absolute; inset: 0; visibility: hidden; width: 100%; height: 100%; } .preview-fallback { flex: 1; min-height: 0; width: 100%; object-fit: contain; } diff --git a/examples/white-label-minimal/app/page.tsx b/examples/white-label-minimal/app/page.tsx index eb7d0cd..50578eb 100644 --- a/examples/white-label-minimal/app/page.tsx +++ b/examples/white-label-minimal/app/page.tsx @@ -1,7 +1,7 @@ -import { getSessionUser } from "../lib/server/auth"; -import GoogleSignInButton from "../components/GoogleSignInButton"; -import TinySunnyLogo from "../components/TinySunnyLogo"; -import Workspace from "../components/Workspace"; +import { getSessionUser } from "../server/auth"; +import GoogleSignInButton from "../client/components/GoogleSignInButton"; +import TinySunnyLogo from "../client/components/TinySunnyLogo"; +import Workspace from "../client/components/Workspace"; export default async function Page() { const user = await getSessionUser(); diff --git a/examples/white-label-minimal/client/chat-state.ts b/examples/white-label-minimal/client/chat-state.ts new file mode 100644 index 0000000..eaa6c30 --- /dev/null +++ b/examples/white-label-minimal/client/chat-state.ts @@ -0,0 +1,42 @@ +import type { App, Message } from "../types"; + +// Where the build turn stands, from the builder's point of view. +export type ChatState = + | "loading" // the chat has not arrived yet + | "paused" // live updates stopped + | "question" // the agent waits for the builder's answer + | "building" // the agent is working + | "failed" // the last build failed + | "ready" // the app is built and can be published + | "idle"; // waiting for a prompt + +export function getChatState(app: App | null, messages: Message[], loading: boolean, liveError: string): ChatState { + if (loading) return "loading"; + if (liveError) return "paused"; + if (hasOpenQuestion(messages)) return "question"; + if (app?.status?.state === "processing") return "building"; + if (app?.status?.state === "error") return "failed"; + if (app?.status?.state === "ready" && hasBuiltCode(messages)) return "ready"; + return "idle"; +} + +function hasOpenQuestion(messages: Message[]) { + return messages.some((message) => + message.tool_calls?.some((tool) => tool.status === "waiting_for_user_input")); +} + +// "ready" alone also follows a greeting-only reply or an answer, so the app +// counts as built only once the latest turn has written code and finished. +function hasBuiltCode(messages: Message[]) { + if (messages.at(-1)?.role !== "assistant") return false; + + const lastPrompt = messages.findLastIndex((message) => message.role === "user"); + const latestTurn = messages.slice(lastPrompt + 1); + const stillWorking = latestTurn.some((message) => + message.tool_calls?.some((tool) => tool.status === "pending" || tool.status === "running")); + if (stillWorking) return false; + + return latestTurn.some((message) => + message.tool_calls?.some((tool) => + (tool.name === "write_file" || tool.name === "find_replace") && tool.status === "success")); +} diff --git a/examples/white-label-minimal/client/components/AppPreview.tsx b/examples/white-label-minimal/client/components/AppPreview.tsx new file mode 100644 index 0000000..8144f4f --- /dev/null +++ b/examples/white-label-minimal/client/components/AppPreview.tsx @@ -0,0 +1,98 @@ +"use client"; +import { useEffect, useState } from "react"; +import * as actions from "../../server/actions"; +import type { App } from "../../types"; +import { unwrap } from "../unwrap"; + +const frameProps = { + referrerPolicy: "no-referrer", + sandbox: "allow-scripts allow-same-origin allow-forms allow-popups", +} as const; + +// An app shown inside Tiny, signed in as the builder ("Embed the app"). +// It shows the latest build. While editing (live), the running sandbox loads +// behind it and takes over once loaded. Each URL works once, so every load and +// every refresh asks the server for a new one. +export default function AppPreview({ app, title, live = false, showControls = true }: { + app: App; + title: string; + live?: boolean; + showControls?: boolean; +}) { + const building = app.status?.state === "processing"; + const [latestUrl, setLatestUrl] = useState(null); + const [liveUrl, setLiveUrl] = useState(null); + const [liveLoaded, setLiveLoaded] = useState(false); + const [error, setError] = useState(""); + const [attempt, setAttempt] = useState(0); + + // The latest build. It needs no sandbox; before the first build there is none. + useEffect(() => { + if (building) return; + let current = true; + unwrap(actions.getLatestBuildUrl(app.id)).then( + (result) => current && setLatestUrl(result.url), + () => {}, + ); + return () => { + current = false; + }; + }, [app.id, building, attempt]); + + // The live sandbox, only while editing. + useEffect(() => { + if (!live) return; + let current = true; + unwrap(actions.getPreviewUrl(app.id)).then( + (result) => current && setLiveUrl(result.url), + (err) => current && setError(err.message), + ); + return () => { + current = false; + }; + }, [app.id, live, attempt]); + + function refresh() { + setLiveUrl(null); + setLiveLoaded(false); + setError(""); + setAttempt((n) => n + 1); + } + + let underneath; + if (latestUrl) { + underneath =