diff --git a/package-lock.json b/package-lock.json index baf63c9..c44e073 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index 97fbb01..bdb8b9c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..306d61d --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -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([]); + 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 ( +
+
+ +
+

Dashboard

+

+ Connect your wallet to view and manage your Postiz spaces. +

+ +
+ ); + } + + // Connected but not signed in + if (!siweToken) { + return ( +
+
+ +
+

Dashboard

+

+ Sign in with your wallet to access your spaces. +

+
+ + {address?.slice(0, 6)}...{address?.slice(-4)} + + {chain?.name} +
+ +
+ ); + } + + // Signed in + return ( +
+
+
+

Your Spaces

+

+ Manage your deployed Postiz instances +

+
+
+ + {address?.slice(0, 6)}...{address?.slice(-4)} + + +
+
+ + {loading ? ( +
+ +

+ Loading your spaces... +

+
+ ) : instances.length === 0 ? ( + + + +
+

No spaces yet

+

+ Deploy your first Postiz instance to get started with social + media scheduling. +

+
+ +
+
+ ) : ( +
+ {instances.map((instance) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 272996c..2188c1e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -45,7 +45,7 @@ export default function HomePage() { @@ -315,7 +315,7 @@ export default function HomePage() { Postiz handles scheduling, Temporal handles workflows, Traefik handles routing.

-

# Clone and deploy

+

# Or self-host manually

git clone https://gitea.jeffemmett.com/jeffemmett/rsocials-online

cd rsocials-online/postiz

cp .env.example .env

@@ -324,14 +324,14 @@ export default function HomePage() {
diff --git a/src/app/provision/page.tsx b/src/app/provision/page.tsx new file mode 100644 index 0000000..e1e1a5c --- /dev/null +++ b/src/app/provision/page.tsx @@ -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 ( +
+
+ + Self-Service Provisioning + +

Deploy Your Postiz Space

+

+ Connect your wallet, choose a subdomain, and get a fully managed + Postiz instance in under 2 minutes. +

+
+ + + +
+

+ Each space includes Postiz, PostgreSQL, Redis, and Temporal — all + managed for you. +

+

+ Your data stays on our EU-based server. Cancel anytime from the{" "} + + dashboard + + . +

+
+
+ ); +} diff --git a/src/components/InstanceCard.tsx b/src/components/InstanceCard.tsx new file mode 100644 index 0000000..feb8cef --- /dev/null +++ b/src/components/InstanceCard.tsx @@ -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: , + }, + provisioning: { + label: "Deploying", + color: "bg-yellow-500/10 text-yellow-600", + icon: , + }, + failed: { + label: "Failed", + color: "bg-destructive/10 text-destructive", + icon: , + }, + destroying: { + label: "Removing", + color: "bg-orange-500/10 text-orange-600", + icon: , + }, + destroyed: { + label: "Removed", + color: "bg-muted text-muted-foreground", + icon: , + }, +}; + +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 ( + + +
+
+
+ +
+
+

{instance.displayName}

+

+ {instance.fallbackDomain} +

+
+
+ + {status.icon} + {status.label} + +
+ +
+
+ + Created + + {formatDate(instance.createdAt)} +
+
+ + Primary Domain + + {instance.primaryDomain} +
+
+ +
+ + {onDelete && instance.status === "active" && ( + + )} +
+
+
+ ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index ce544f7..deca085 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -28,11 +28,17 @@ export function Navbar() { Platforms Deploy + + Dashboard + - {children} - - + + + {children} + + + ); } diff --git a/src/components/ProvisionForm.tsx b/src/components/ProvisionForm.tsx new file mode 100644 index 0000000..aedc94f --- /dev/null +++ b/src/components/ProvisionForm.tsx @@ -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("connect"); + const [slug, setSlug] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [slugError, setSlugError] = useState(""); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(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("/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 ( +
+ + + {/* Step 1: Connect Wallet */} + {step === "connect" && ( + + +
+
+ +
+

Connect Your Wallet

+

+ Sign in with your Ethereum wallet to provision a Postiz instance + for your community. +

+
+ + {!isConnected ? ( + + ) : ( +
+
+
+

Connected

+

+ {address?.slice(0, 6)}...{address?.slice(-4)} +

+
+ + {chain?.name ?? "Unknown"} + +
+
+ + +
+
+ )} +
+
+ )} + + {/* Step 2: Configure Space */} + {step === "configure" && ( + + +
+
+ +
+

Configure Your Space

+

+ Choose a name and subdomain for your Postiz instance. +

+
+ +
+
+ + 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} + /> +
+ +
+ +
+ { + 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} + /> + + .rsocials.online + +
+ {slugError && ( +

{slugError}

+ )} + {slug && !slugError && ( +

+ Your space will be at{" "} + + {slug}.rsocials.online + +

+ )} +
+
+ +
+ + +
+
+
+ )} + + {/* Step 3: Confirm */} + {step === "confirm" && ( + + +
+
+ +
+

Confirm Provisioning

+
+ +
+
+ Name + {displayName} +
+
+ Subdomain + {slug}.rsocials.online +
+
+ Owner + + {address?.slice(0, 6)}...{address?.slice(-4)} + +
+
+ +

+ This will deploy a new Postiz instance with its own database, + scheduler, and domain routing. Takes about 2 minutes. +

+ + {provisionError && ( +
+ + {provisionError} +
+ )} + +
+ + +
+
+
+ )} + + {/* Step 4: Provisioning */} + {step === "provisioning" && ( + + +
+ +

Deploying Your Space

+

+ Setting up containers, database, and routing... +

+
+ +
+
+ + Generating compose configuration... +
+
+ + Starting Postiz containers... +
+
+ + Configuring domain routing... +
+
+
+
+ )} + + {/* Step 5: Done */} + {step === "done" && result && ( + + +
+
+ +
+

Your Space is Live!

+

+ {displayName} is now deployed and ready to use. +

+
+ +
+ +
+ Status + + {result.instance.status} + +
+
+ + +
+
+ )} +
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..724e3a4 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,24 @@ +const API_BASE = + process.env.NEXT_PUBLIC_API_URL ?? "https://api.rsocials.online"; + +export async function apiFetch( + path: string, + opts?: RequestInit +): Promise { + 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}` }; +} diff --git a/src/lib/siwe.ts b/src/lib/siwe.ts new file mode 100644 index 0000000..211c67d --- /dev/null +++ b/src/lib/siwe.ts @@ -0,0 +1,29 @@ +import { apiFetch } from "./api"; + +export async function getNonce(): Promise { + 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"); +} diff --git a/src/lib/wagmi-config.ts b/src/lib/wagmi-config.ts new file mode 100644 index 0000000..715f892 --- /dev/null +++ b/src/lib/wagmi-config.ts @@ -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(), + }, +}); diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..863936a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "api"] }