feat: add Phase 3 provision UI and dashboard pages

Wallet-connected provisioning wizard at /provision with SIWE auth,
subdomain picker, and multi-step deploy flow. Dashboard at /dashboard
shows instance list with status and management controls. Adds wagmi +
viem + react-query for wallet integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 21:42:16 -08:00
parent dca3140065
commit 908e2257e4
13 changed files with 1333 additions and 13 deletions

396
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
@ -21,6 +22,8 @@
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"viem": "^2.46.3",
"wagmi": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
@ -35,6 +38,12 @@
"typescript": "^5"
}
},
"node_modules/@adraffy/ens-normalize": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
"integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
"license": "MIT"
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -1236,6 +1245,45 @@
"node": ">= 10"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
"integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2789,6 +2837,42 @@
"dev": true,
"license": "MIT"
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.9.0",
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -3069,6 +3153,32 @@
"tailwindcss": "4.2.0"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -3682,6 +3792,58 @@
"win32"
]
},
"node_modules/@wagmi/core": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@wagmi/core/-/core-3.4.0.tgz",
"integrity": "sha512-EU5gDsUp5t7+cuLv12/L8hfyWfCIKsBNiiBqpOqxZJxvAcAiQk4xFe2jMgaQPqApc3Omvxrk032M8AQ4N0cQeg==",
"license": "MIT",
"dependencies": {
"eventemitter3": "5.0.1",
"mipd": "0.0.7",
"zustand": "5.0.0"
},
"funding": {
"url": "https://github.com/sponsors/wevm"
},
"peerDependencies": {
"@tanstack/query-core": ">=5.0.0",
"ox": ">=0.11.1",
"typescript": ">=5.7.3",
"viem": "2.x"
},
"peerDependenciesMeta": {
"@tanstack/query-core": {
"optional": true
},
"ox": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/abitype": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
"integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/wevm"
},
"peerDependencies": {
"typescript": ">=5.0.4",
"zod": "^3.22.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@ -5060,6 +5222,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5996,6 +6164,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/isows": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
"integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@ -6534,6 +6717,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mipd": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/mipd/-/mipd-0.0.7.tgz",
"integrity": "sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wagmi-dev"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": ">=5.0.4"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -6876,6 +7079,36 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ox": {
"version": "0.12.4",
"resolved": "https://registry.npmjs.org/ox/-/ox-0.12.4.tgz",
"integrity": "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "^1.11.0",
"@noble/ciphers": "^1.3.0",
"@noble/curves": "1.9.1",
"@noble/hashes": "^1.8.0",
"@scure/bip32": "^1.7.0",
"@scure/bip39": "^1.6.0",
"abitype": "^1.2.3",
"eventemitter3": "5.0.1"
},
"peerDependencies": {
"typescript": ">=5.4.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -8114,7 +8347,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -8302,6 +8535,117 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/viem": {
"version": "2.46.3",
"resolved": "https://registry.npmjs.org/viem/-/viem-2.46.3.tgz",
"integrity": "sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"dependencies": {
"@noble/curves": "1.9.1",
"@noble/hashes": "1.8.0",
"@scure/bip32": "1.7.0",
"@scure/bip39": "1.6.0",
"abitype": "1.2.3",
"isows": "1.0.7",
"ox": "0.12.4",
"ws": "8.18.3"
},
"peerDependencies": {
"typescript": ">=5.0.4"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/wagmi": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/wagmi/-/wagmi-3.5.0.tgz",
"integrity": "sha512-39uiY6Vkc28NiAHrxJzVTodoRgSVGG97EewwUxRf+jcFMTe8toAnaM8pJZA3Zw/6snMg4tSgWLJAtMnOacLe7w==",
"license": "MIT",
"dependencies": {
"@wagmi/connectors": "7.2.1",
"@wagmi/core": "3.4.0",
"use-sync-external-store": "1.4.0"
},
"funding": {
"url": "https://github.com/sponsors/wevm"
},
"peerDependencies": {
"@tanstack/react-query": ">=5.0.0",
"react": ">=18",
"typescript": ">=5.7.3",
"viem": "2.x"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/wagmi/node_modules/@wagmi/connectors": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@wagmi/connectors/-/connectors-7.2.1.tgz",
"integrity": "sha512-/tyDepUMDM8eNzNX3ofjqHNRFZ6XcZ3u0+cQp5x0/LHCpMA8tRh7A1/e7dTrYiIJeL7iLgHzfHUXCsU02OKMLQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/wevm"
},
"peerDependencies": {
"@base-org/account": "^2.5.1",
"@coinbase/wallet-sdk": "^4.3.6",
"@metamask/sdk": "~0.33.1",
"@safe-global/safe-apps-provider": "~0.18.6",
"@safe-global/safe-apps-sdk": "^9.1.0",
"@wagmi/core": "3.4.0",
"@walletconnect/ethereum-provider": "^2.21.1",
"porto": "~0.2.35",
"typescript": ">=5.7.3",
"viem": "2.x"
},
"peerDependenciesMeta": {
"@base-org/account": {
"optional": true
},
"@coinbase/wallet-sdk": {
"optional": true
},
"@metamask/sdk": {
"optional": true
},
"@safe-global/safe-apps-provider": {
"optional": true
},
"@safe-global/safe-apps-sdk": {
"optional": true
},
"@walletconnect/ethereum-provider": {
"optional": true
},
"porto": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/wagmi/node_modules/use-sync-external-store": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
"integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -8417,6 +8761,27 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -8458,6 +8823,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz",
"integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
@ -22,6 +23,8 @@
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"viem": "^2.46.3",
"wagmi": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {

200
src/app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,200 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAccount, useConnect } from "wagmi";
import { injected } from "wagmi/connectors";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { InstanceCard, type Instance } from "@/components/InstanceCard";
import {
Wallet,
Plus,
Loader2,
ServerOff,
} from "lucide-react";
import { apiFetch, apiHeaders } from "@/lib/api";
import { getNonce, createSiweMessage } from "@/lib/siwe";
import { useSignMessage } from "wagmi";
import { toast } from "sonner";
export default function DashboardPage() {
const { address, isConnected, chain } = useAccount();
const { connect } = useConnect();
const { signMessageAsync } = useSignMessage();
const [siweToken, setSiweToken] = useState("");
const [instances, setInstances] = useState<Instance[]>([]);
const [loading, setLoading] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const signIn = useCallback(async () => {
if (!address || !chain) return;
setSigningIn(true);
try {
const nonce = await getNonce();
const message = createSiweMessage(address, nonce, chain.id);
const signature = await signMessageAsync({ message });
const token = btoa(JSON.stringify({ message, signature }));
setSiweToken(token);
} catch (e) {
toast.error(
`Sign-in failed: ${e instanceof Error ? e.message : "Unknown error"}`
);
} finally {
setSigningIn(false);
}
}, [address, chain, signMessageAsync]);
const fetchInstances = useCallback(async () => {
if (!siweToken) return;
setLoading(true);
try {
const data = await apiFetch<{ instances: Instance[] }>("/v1/spaces", {
headers: apiHeaders(siweToken),
});
setInstances(data.instances);
} catch (e) {
toast.error(
`Failed to load spaces: ${e instanceof Error ? e.message : "Unknown"}`
);
} finally {
setLoading(false);
}
}, [siweToken]);
useEffect(() => {
if (siweToken) fetchInstances();
}, [siweToken, fetchInstances]);
const handleDelete = useCallback(
async (slug: string) => {
if (!confirm(`Remove space "${slug}"? This will destroy all data.`))
return;
try {
await apiFetch(`/v1/spaces/${slug}`, {
method: "DELETE",
headers: apiHeaders(siweToken),
});
toast.success(`Space "${slug}" is being removed`);
fetchInstances();
} catch (e) {
toast.error(
`Delete failed: ${e instanceof Error ? e.message : "Unknown"}`
);
}
},
[siweToken, fetchInstances]
);
// Not connected
if (!isConnected) {
return (
<div className="max-w-2xl mx-auto py-16 text-center space-y-6">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
<Wallet className="h-8 w-8 text-primary" />
</div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">
Connect your wallet to view and manage your Postiz spaces.
</p>
<Button onClick={() => connect({ connector: injected() })} size="lg">
<Wallet className="mr-2 h-5 w-5" />
Connect Wallet
</Button>
</div>
);
}
// Connected but not signed in
if (!siweToken) {
return (
<div className="max-w-2xl mx-auto py-16 text-center space-y-6">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
<Wallet className="h-8 w-8 text-primary" />
</div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">
Sign in with your wallet to access your spaces.
</p>
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">
{address?.slice(0, 6)}...{address?.slice(-4)}
</span>
<Badge variant="secondary">{chain?.name}</Badge>
</div>
<Button onClick={signIn} size="lg" disabled={signingIn}>
{signingIn ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Wallet className="mr-2 h-5 w-5" />
)}
Sign Message to Continue
</Button>
</div>
);
}
// Signed in
return (
<div className="max-w-4xl mx-auto py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Your Spaces</h1>
<p className="text-sm text-muted-foreground">
Manage your deployed Postiz instances
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="secondary" className="font-mono">
{address?.slice(0, 6)}...{address?.slice(-4)}
</Badge>
<Button asChild>
<a href="/provision">
<Plus className="mr-2 h-4 w-4" />
New Space
</a>
</Button>
</div>
</div>
{loading ? (
<div className="text-center py-12">
<Loader2 className="h-8 w-8 text-primary animate-spin mx-auto" />
<p className="text-sm text-muted-foreground mt-2">
Loading your spaces...
</p>
</div>
) : instances.length === 0 ? (
<Card className="border-dashed border-2">
<CardContent className="py-12 text-center space-y-4">
<ServerOff className="h-12 w-12 text-muted-foreground/40 mx-auto" />
<div>
<h3 className="font-semibold">No spaces yet</h3>
<p className="text-sm text-muted-foreground">
Deploy your first Postiz instance to get started with social
media scheduling.
</p>
</div>
<Button asChild>
<a href="/provision">
<Plus className="mr-2 h-4 w-4" />
Deploy Your First Space
</a>
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{instances.map((instance) => (
<InstanceCard
key={instance.id}
instance={instance}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
);
}

View File

@ -45,7 +45,7 @@ export default function HomePage() {
</a>
</Button>
<Button asChild variant="outline" size="lg" className="text-lg px-8 border-primary/30 hover:bg-primary/5">
<a href="#deploy">Deploy Your Own</a>
<a href="/provision">Deploy Your Own</a>
</Button>
</div>
</section>
@ -315,7 +315,7 @@ export default function HomePage() {
Postiz handles scheduling, Temporal handles workflows, Traefik handles routing.
</p>
<div className="bg-card/80 backdrop-blur border rounded-lg p-4 max-w-lg mx-auto text-left font-mono text-sm">
<p className="text-muted-foreground"># Clone and deploy</p>
<p className="text-muted-foreground"># Or self-host manually</p>
<p>git clone https://gitea.jeffemmett.com/jeffemmett/rsocials-online</p>
<p>cd rsocials-online/postiz</p>
<p>cp .env.example .env</p>
@ -324,14 +324,14 @@ export default function HomePage() {
</div>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Button asChild size="lg" className="text-lg px-8 bg-gradient-to-r from-primary to-accent hover:opacity-90">
<a href="https://socials.crypto-commons.org">
Try the Live Instance
<a href="/provision">
Deploy with 1 Click
<ArrowRight className="ml-2 h-5 w-5" />
</a>
</Button>
<Button asChild variant="outline" size="lg" className="text-lg px-8 border-primary/30 hover:bg-primary/5">
<a href="https://github.com/gitroomhq/postiz-app" target="_blank" rel="noopener noreferrer">
Postiz on GitHub
<a href="https://socials.crypto-commons.org" target="_blank" rel="noopener noreferrer">
Try the Live Instance
</a>
</Button>
</div>

View File

@ -0,0 +1,44 @@
import { Badge } from "@/components/ui/badge";
import { ProvisionForm } from "@/components/ProvisionForm";
export const metadata = {
title: "Provision a Space - rSocials",
description:
"Deploy your own Postiz social media scheduling instance for your community.",
};
export default function ProvisionPage() {
return (
<div className="max-w-2xl mx-auto space-y-8 py-8">
<div className="text-center space-y-3">
<Badge
variant="secondary"
className="bg-primary/10 text-primary border-primary/20"
>
Self-Service Provisioning
</Badge>
<h1 className="text-3xl font-bold">Deploy Your Postiz Space</h1>
<p className="text-muted-foreground max-w-md mx-auto">
Connect your wallet, choose a subdomain, and get a fully managed
Postiz instance in under 2 minutes.
</p>
</div>
<ProvisionForm />
<div className="text-center text-xs text-muted-foreground space-y-1">
<p>
Each space includes Postiz, PostgreSQL, Redis, and Temporal all
managed for you.
</p>
<p>
Your data stays on our EU-based server. Cancel anytime from the{" "}
<a href="/dashboard" className="text-primary hover:underline">
dashboard
</a>
.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Globe,
ExternalLink,
Trash2,
Clock,
CheckCircle2,
AlertCircle,
Loader2,
} from "lucide-react";
export interface Instance {
id: string;
slug: string;
displayName: string;
primaryDomain: string;
fallbackDomain: string;
owner: string;
status: string;
createdAt: string;
updatedAt: string;
}
const statusConfig: Record<
string,
{ label: string; color: string; icon: React.ReactNode }
> = {
active: {
label: "Active",
color: "bg-green-500/10 text-green-600",
icon: <CheckCircle2 className="h-3 w-3" />,
},
provisioning: {
label: "Deploying",
color: "bg-yellow-500/10 text-yellow-600",
icon: <Loader2 className="h-3 w-3 animate-spin" />,
},
failed: {
label: "Failed",
color: "bg-destructive/10 text-destructive",
icon: <AlertCircle className="h-3 w-3" />,
},
destroying: {
label: "Removing",
color: "bg-orange-500/10 text-orange-600",
icon: <Loader2 className="h-3 w-3 animate-spin" />,
},
destroyed: {
label: "Removed",
color: "bg-muted text-muted-foreground",
icon: <Clock className="h-3 w-3" />,
},
};
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function InstanceCard({
instance,
onDelete,
}: {
instance: Instance;
onDelete?: (slug: string) => void;
}) {
const status = statusConfig[instance.status] ?? statusConfig.active;
return (
<Card className="border-primary/20 hover:border-primary/40 transition-colors">
<CardContent className="pt-5 pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Globe className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-semibold">{instance.displayName}</h3>
<p className="text-xs text-muted-foreground font-mono">
{instance.fallbackDomain}
</p>
</div>
</div>
<Badge className={`${status.color} flex items-center gap-1`}>
{status.icon}
{status.label}
</Badge>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div>
<span className="block font-medium text-foreground/80">
Created
</span>
{formatDate(instance.createdAt)}
</div>
<div>
<span className="block font-medium text-foreground/80">
Primary Domain
</span>
<span className="font-mono">{instance.primaryDomain}</span>
</div>
</div>
<div className="mt-4 flex gap-2">
<Button asChild variant="outline" size="sm" className="flex-1">
<a
href={`https://${instance.fallbackDomain}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-1 h-3 w-3" />
Open
</a>
</Button>
{onDelete && instance.status === "active" && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(instance.slug)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -28,11 +28,17 @@ export function Navbar() {
Platforms
</Link>
<Link
href="#deploy"
href="/provision"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Deploy
</Link>
<Link
href="/dashboard"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Dashboard
</Link>
<Link
href="/zine"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"

View File

@ -1,12 +1,19 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider } from "wagmi";
import { Toaster } from "sonner";
import { wagmiConfig } from "@/lib/wagmi-config";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<Toaster position="bottom-right" />
</>
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="bottom-right" />
</QueryClientProvider>
</WagmiProvider>
);
}

View File

@ -0,0 +1,459 @@
"use client";
import { useState, useCallback } from "react";
import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi";
import { injected } from "wagmi/connectors";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Wallet,
CheckCircle2,
Loader2,
AlertCircle,
Globe,
ArrowRight,
ArrowLeft,
} from "lucide-react";
import { apiFetch, apiHeaders } from "@/lib/api";
import { getNonce, createSiweMessage } from "@/lib/siwe";
import { toast } from "sonner";
type Step = "connect" | "configure" | "confirm" | "provisioning" | "done";
interface ProvisionResult {
instance: {
slug: string;
primaryDomain: string;
fallbackDomain: string;
status: string;
};
}
const RESERVED_SLUGS = new Set([
"www",
"api",
"admin",
"mail",
"app",
"staging",
"test",
"dev",
"socials",
"cc",
"crypto-commons",
"votc",
"p2pf",
"bcrg",
]);
const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/;
export function ProvisionForm() {
const { address, isConnected, chain } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const { signMessageAsync } = useSignMessage();
const [step, setStep] = useState<Step>("connect");
const [slug, setSlug] = useState("");
const [displayName, setDisplayName] = useState("");
const [slugError, setSlugError] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ProvisionResult | null>(null);
const [provisionError, setProvisionError] = useState("");
const [siweToken, setSiweToken] = useState("");
const validateSlug = useCallback((value: string) => {
const v = value.toLowerCase();
if (!v) {
setSlugError("");
return false;
}
if (v.length < 3) {
setSlugError("Must be at least 3 characters");
return false;
}
if (!SLUG_RE.test(v)) {
setSlugError(
"Only lowercase letters, numbers, and hyphens. Must start/end with alphanumeric."
);
return false;
}
if (RESERVED_SLUGS.has(v)) {
setSlugError("This subdomain is reserved");
return false;
}
setSlugError("");
return true;
}, []);
const handleConnect = useCallback(() => {
connect({ connector: injected() });
}, [connect]);
const handleSignIn = useCallback(async () => {
if (!address || !chain) return;
setLoading(true);
try {
const nonce = await getNonce();
const message = createSiweMessage(address, nonce, chain.id);
const signature = await signMessageAsync({ message });
// The signature + message together serve as the bearer token
const token = btoa(JSON.stringify({ message, signature }));
setSiweToken(token);
setStep("configure");
toast.success("Wallet connected and signed in");
} catch (e) {
toast.error(
`Sign-in failed: ${e instanceof Error ? e.message : "Unknown error"}`
);
} finally {
setLoading(false);
}
}, [address, chain, signMessageAsync]);
const handleProvision = useCallback(async () => {
if (!validateSlug(slug) || !displayName.trim()) return;
setLoading(true);
setProvisionError("");
setStep("provisioning");
try {
const res = await apiFetch<ProvisionResult>("/v1/spaces", {
method: "POST",
headers: apiHeaders(siweToken),
body: JSON.stringify({
slug: slug.toLowerCase(),
displayName: displayName.trim(),
}),
});
setResult(res);
setStep("done");
toast.success("Your space is live!");
} catch (e) {
setProvisionError(
e instanceof Error ? e.message : "Provisioning failed"
);
setStep("confirm");
toast.error("Provisioning failed");
} finally {
setLoading(false);
}
}, [slug, displayName, siweToken, validateSlug]);
const progress =
step === "connect"
? 0
: step === "configure"
? 33
: step === "confirm"
? 66
: step === "provisioning"
? 80
: 100;
return (
<div className="max-w-lg mx-auto space-y-6">
<Progress value={progress} className="h-2" />
{/* Step 1: Connect Wallet */}
{step === "connect" && (
<Card className="border-2 border-primary/30">
<CardContent className="pt-6 space-y-4">
<div className="text-center space-y-2">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
<Wallet className="h-8 w-8 text-primary" />
</div>
<h2 className="text-xl font-bold">Connect Your Wallet</h2>
<p className="text-sm text-muted-foreground">
Sign in with your Ethereum wallet to provision a Postiz instance
for your community.
</p>
</div>
{!isConnected ? (
<Button
onClick={handleConnect}
className="w-full"
size="lg"
>
<Wallet className="mr-2 h-5 w-5" />
Connect Wallet
</Button>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-xs text-muted-foreground">Connected</p>
<p className="font-mono text-sm">
{address?.slice(0, 6)}...{address?.slice(-4)}
</p>
</div>
<Badge variant="secondary" className="bg-green-500/10 text-green-600">
{chain?.name ?? "Unknown"}
</Badge>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => disconnect()}
className="flex-1"
>
Disconnect
</Button>
<Button
onClick={handleSignIn}
disabled={loading}
className="flex-1"
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ArrowRight className="mr-2 h-4 w-4" />
)}
Sign In
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Step 2: Configure Space */}
{step === "configure" && (
<Card className="border-2 border-primary/30">
<CardContent className="pt-6 space-y-4">
<div className="text-center space-y-2">
<div className="h-16 w-16 rounded-full bg-accent/10 flex items-center justify-center mx-auto">
<Globe className="h-8 w-8 text-accent" />
</div>
<h2 className="text-xl font-bold">Configure Your Space</h2>
<p className="text-sm text-muted-foreground">
Choose a name and subdomain for your Postiz instance.
</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">
Community Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Community"
className="w-full px-3 py-2 rounded-md border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
maxLength={50}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Subdomain
</label>
<div className="flex items-center gap-0">
<input
type="text"
value={slug}
onChange={(e) => {
const v = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "");
setSlug(v);
validateSlug(v);
}}
placeholder="my-community"
className="flex-1 px-3 py-2 rounded-l-md border border-r-0 bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
maxLength={30}
/>
<span className="px-3 py-2 bg-muted border rounded-r-md text-sm text-muted-foreground whitespace-nowrap">
.rsocials.online
</span>
</div>
{slugError && (
<p className="text-xs text-destructive mt-1">{slugError}</p>
)}
{slug && !slugError && (
<p className="text-xs text-muted-foreground mt-1">
Your space will be at{" "}
<span className="font-mono text-foreground">
{slug}.rsocials.online
</span>
</p>
)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setStep("connect")}
className="flex-1"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={() => setStep("confirm")}
disabled={!slug || !!slugError || !displayName.trim()}
className="flex-1"
>
Review
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Confirm */}
{step === "confirm" && (
<Card className="border-2 border-primary/30">
<CardContent className="pt-6 space-y-4">
<div className="text-center space-y-2">
<div className="h-16 w-16 rounded-full bg-green-500/10 flex items-center justify-center mx-auto">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h2 className="text-xl font-bold">Confirm Provisioning</h2>
</div>
<div className="space-y-2 p-4 bg-muted rounded-lg text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Name</span>
<span className="font-medium">{displayName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Subdomain</span>
<span className="font-mono">{slug}.rsocials.online</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Owner</span>
<span className="font-mono text-xs">
{address?.slice(0, 6)}...{address?.slice(-4)}
</span>
</div>
</div>
<p className="text-xs text-muted-foreground text-center">
This will deploy a new Postiz instance with its own database,
scheduler, and domain routing. Takes about 2 minutes.
</p>
{provisionError && (
<div className="flex items-center gap-2 p-3 bg-destructive/10 rounded-lg text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{provisionError}
</div>
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setStep("configure")}
className="flex-1"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={handleProvision}
disabled={loading}
className="flex-1 bg-gradient-to-r from-primary to-accent hover:opacity-90"
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Deploy Space
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Provisioning */}
{step === "provisioning" && (
<Card className="border-2 border-primary/30">
<CardContent className="pt-6 space-y-4">
<div className="text-center space-y-3">
<Loader2 className="h-12 w-12 text-primary animate-spin mx-auto" />
<h2 className="text-xl font-bold">Deploying Your Space</h2>
<p className="text-sm text-muted-foreground">
Setting up containers, database, and routing...
</p>
</div>
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
Generating compose configuration...
</div>
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
Starting Postiz containers...
</div>
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
Configuring domain routing...
</div>
</div>
</CardContent>
</Card>
)}
{/* Step 5: Done */}
{step === "done" && result && (
<Card className="border-2 border-green-500/30 bg-gradient-to-br from-green-500/5 to-transparent">
<CardContent className="pt-6 space-y-4">
<div className="text-center space-y-2">
<div className="h-16 w-16 rounded-full bg-green-500/10 flex items-center justify-center mx-auto">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h2 className="text-xl font-bold">Your Space is Live!</h2>
<p className="text-sm text-muted-foreground">
{displayName} is now deployed and ready to use.
</p>
</div>
<div className="space-y-2 p-4 bg-muted rounded-lg text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">URL</span>
<a
href={`https://${result.instance.fallbackDomain}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-mono"
>
{result.instance.fallbackDomain}
</a>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<Badge className="bg-green-500/10 text-green-600">
{result.instance.status}
</Badge>
</div>
</div>
<div className="flex gap-2">
<Button asChild className="flex-1">
<a
href={`https://${result.instance.fallbackDomain}`}
target="_blank"
rel="noopener noreferrer"
>
Open Postiz
<ArrowRight className="ml-2 h-4 w-4" />
</a>
</Button>
<Button variant="outline" asChild className="flex-1">
<a href="/dashboard">View Dashboard</a>
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

24
src/lib/api.ts Normal file
View File

@ -0,0 +1,24 @@
const API_BASE =
process.env.NEXT_PUBLIC_API_URL ?? "https://api.rsocials.online";
export async function apiFetch<T>(
path: string,
opts?: RequestInit
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: {
"Content-Type": "application/json",
...opts?.headers,
},
...opts,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error ?? `API error ${res.status}`);
}
return res.json();
}
export function apiHeaders(token: string) {
return { Authorization: `Bearer ${token}` };
}

29
src/lib/siwe.ts Normal file
View File

@ -0,0 +1,29 @@
import { apiFetch } from "./api";
export async function getNonce(): Promise<string> {
const data = await apiFetch<{ nonce: string }>("/v1/auth/nonce");
return data.nonce;
}
export function createSiweMessage(
address: string,
nonce: string,
chainId: number
): string {
const domain = window.location.host;
const origin = window.location.origin;
const issuedAt = new Date().toISOString();
return [
`${domain} wants you to sign in with your Ethereum account:`,
address,
"",
"Sign in to rSocials to provision your social media instance.",
"",
`URI: ${origin}`,
`Version: 1`,
`Chain ID: ${chainId}`,
`Nonce: ${nonce}`,
`Issued At: ${issuedAt}`,
].join("\n");
}

17
src/lib/wagmi-config.ts Normal file
View File

@ -0,0 +1,17 @@
import { http, createConfig } from "wagmi";
import { base, baseSepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
const projectId = process.env.NEXT_PUBLIC_WC_PROJECT_ID ?? "";
export const wagmiConfig = createConfig({
chains: [base, baseSepolia],
connectors: [
injected(),
...(projectId ? [walletConnect({ projectId })] : []),
],
transports: {
[base.id]: http(),
[baseSepolia.id]: http(),
},
});

View File

@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "api"]
}