@pylonsync/create-pylon 0.3.53 → 0.3.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/bin/create-pylon.js +98 -42
  2. package/package.json +1 -1
  3. package/templates/backend/b2b/apps/api/functions/archiveProject.ts +15 -0
  4. package/templates/backend/b2b/apps/api/functions/createOrg.ts +43 -0
  5. package/templates/backend/b2b/apps/api/functions/createProject.ts +25 -0
  6. package/templates/backend/b2b/apps/api/functions/inviteMember.ts +49 -0
  7. package/templates/backend/b2b/apps/api/functions/myOrgs.ts +37 -0
  8. package/templates/backend/b2b/apps/api/functions/orgMembers.ts +13 -0
  9. package/templates/backend/b2b/apps/api/functions/orgProjects.ts +18 -0
  10. package/templates/backend/b2b/apps/api/functions/removeMember.ts +29 -0
  11. package/templates/backend/b2b/apps/api/functions/setMemberRole.ts +38 -0
  12. package/templates/backend/b2b/apps/api/package.json +20 -0
  13. package/templates/backend/b2b/apps/api/schema.ts +171 -0
  14. package/templates/backend/b2b/apps/api/tsconfig.json +13 -0
  15. package/templates/backend/chat/apps/api/functions/createRoom.ts +32 -0
  16. package/templates/backend/chat/apps/api/functions/listRooms.ts +15 -0
  17. package/templates/backend/chat/apps/api/functions/roomMessages.ts +20 -0
  18. package/templates/backend/chat/apps/api/functions/sendMessage.ts +37 -0
  19. package/templates/backend/chat/apps/api/package.json +20 -0
  20. package/templates/backend/chat/apps/api/schema.ts +93 -0
  21. package/templates/backend/chat/apps/api/tsconfig.json +13 -0
  22. package/templates/backend/consumer/apps/api/functions/createPost.ts +48 -0
  23. package/templates/backend/consumer/apps/api/functions/deletePost.ts +21 -0
  24. package/templates/backend/consumer/apps/api/functions/feed.ts +57 -0
  25. package/templates/backend/consumer/apps/api/functions/myProfile.ts +17 -0
  26. package/templates/backend/consumer/apps/api/functions/profilePosts.ts +17 -0
  27. package/templates/backend/consumer/apps/api/functions/toggleLike.ts +48 -0
  28. package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +70 -0
  29. package/templates/backend/consumer/apps/api/package.json +20 -0
  30. package/templates/backend/consumer/apps/api/schema.ts +130 -0
  31. package/templates/backend/consumer/apps/api/tsconfig.json +13 -0
  32. package/templates/expo/chat/apps/expo/App.tsx +384 -0
  33. package/templates/expo/chat/apps/expo/app.json +25 -0
  34. package/templates/expo/chat/apps/expo/babel.config.js +6 -0
  35. package/templates/expo/chat/apps/expo/package.json +30 -0
  36. package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
  37. package/templates/expo/consumer/apps/expo/App.tsx +392 -0
  38. package/templates/expo/consumer/apps/expo/app.json +25 -0
  39. package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
  40. package/templates/expo/consumer/apps/expo/package.json +30 -0
  41. package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
  42. package/templates/ios/chat/apps/ios/Package.swift +24 -0
  43. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +116 -0
  44. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  45. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  46. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  47. package/templates/ios/chat/apps/ios/project.yml +44 -0
  48. package/templates/ios/consumer/apps/ios/Package.swift +24 -0
  49. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  50. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  51. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  52. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  53. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +57 -0
  54. package/templates/ios/consumer/apps/ios/project.yml +44 -0
  55. package/templates/mac/b2b/apps/mac/Package.swift +22 -0
  56. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
  57. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
  58. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  59. package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  60. package/templates/mac/b2b/apps/mac/project.yml +34 -0
  61. package/templates/mac/barebones/apps/mac/Package.swift +33 -0
  62. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
  63. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
  64. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  65. package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  66. package/templates/mac/barebones/apps/mac/project.yml +34 -0
  67. package/templates/mac/chat/apps/mac/Package.swift +34 -0
  68. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +143 -0
  69. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  70. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  71. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  72. package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  73. package/templates/mac/chat/apps/mac/project.yml +36 -0
  74. package/templates/mac/consumer/apps/mac/Package.swift +34 -0
  75. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  76. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  77. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  78. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  79. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +59 -0
  80. package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  81. package/templates/mac/consumer/apps/mac/project.yml +36 -0
  82. package/templates/mac/todo/apps/mac/Package.swift +33 -0
  83. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
  84. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
  85. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  86. package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  87. package/templates/mac/todo/apps/mac/project.yml +34 -0
  88. package/templates/web/b2b/apps/web/next-env.d.ts +2 -0
  89. package/templates/web/b2b/apps/web/next.config.ts +24 -0
  90. package/templates/web/b2b/apps/web/package.json +29 -0
  91. package/templates/web/b2b/apps/web/postcss.config.mjs +3 -0
  92. package/templates/web/b2b/apps/web/src/app/components/OrgPicker.tsx +171 -0
  93. package/templates/web/b2b/apps/web/src/app/globals.css +6 -0
  94. package/templates/web/b2b/apps/web/src/app/layout.tsx +21 -0
  95. package/templates/web/b2b/apps/web/src/app/page.tsx +39 -0
  96. package/templates/web/b2b/apps/web/src/lib/pylon.ts +5 -0
  97. package/templates/web/b2b/apps/web/tsconfig.json +26 -0
  98. package/templates/web/chat/apps/web/next-env.d.ts +2 -0
  99. package/templates/web/chat/apps/web/next.config.ts +24 -0
  100. package/templates/web/chat/apps/web/package.json +29 -0
  101. package/templates/web/chat/apps/web/postcss.config.mjs +3 -0
  102. package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +231 -0
  103. package/templates/web/chat/apps/web/src/app/globals.css +6 -0
  104. package/templates/web/chat/apps/web/src/app/layout.tsx +22 -0
  105. package/templates/web/chat/apps/web/src/app/page.tsx +12 -0
  106. package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
  107. package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
  108. package/templates/web/chat/apps/web/tsconfig.json +26 -0
  109. package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
  110. package/templates/web/consumer/apps/web/next.config.ts +24 -0
  111. package/templates/web/consumer/apps/web/package.json +29 -0
  112. package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
  113. package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +315 -0
  114. package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
  115. package/templates/web/consumer/apps/web/src/app/layout.tsx +22 -0
  116. package/templates/web/consumer/apps/web/src/app/page.tsx +21 -0
  117. package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
  118. package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
  119. package/templates/web/consumer/apps/web/tsconfig.json +26 -0
  120. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Package.swift +0 -0
  121. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/ContentView.swift +0 -0
  122. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  123. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  124. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/project.yml +0 -0
  125. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Package.swift +0 -0
  126. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  127. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/TodoListView.swift +0 -0
  128. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  129. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/project.yml +0 -0
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import { useState, useTransition } from "react";
4
+ import { Button, Input, Card, CardHeader, CardContent } from "@__APP_NAME_KEBAB__/ui";
5
+
6
+ type Org = {
7
+ id: string;
8
+ slug: string;
9
+ name: string;
10
+ role: string;
11
+ createdAt: string;
12
+ };
13
+
14
+ export function OrgPicker({ initialOrgs }: { initialOrgs: Org[] }) {
15
+ const [orgs, setOrgs] = useState(initialOrgs);
16
+ const [activeId, setActiveId] = useState<string | null>(
17
+ initialOrgs[0]?.id ?? null,
18
+ );
19
+ const [creating, setCreating] = useState(false);
20
+ const [name, setName] = useState("");
21
+ const [slug, setSlug] = useState("");
22
+ const [pending, startTransition] = useTransition();
23
+ const [error, setError] = useState<string | null>(null);
24
+
25
+ async function create(e: React.FormEvent) {
26
+ e.preventDefault();
27
+ setError(null);
28
+ startTransition(async () => {
29
+ const res = await fetch("/api/fn/createOrg", {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ slug, name }),
33
+ });
34
+ if (res.ok) {
35
+ const org = (await res.json()) as Org;
36
+ setOrgs((prev) => [
37
+ { ...org, role: "owner" },
38
+ ...prev,
39
+ ]);
40
+ setActiveId(org.id);
41
+ setName("");
42
+ setSlug("");
43
+ setCreating(false);
44
+ } else {
45
+ const body = await res.json().catch(() => ({}));
46
+ setError(body?.message ?? "create failed");
47
+ }
48
+ });
49
+ }
50
+
51
+ const active = orgs.find((o) => o.id === activeId) ?? null;
52
+
53
+ return (
54
+ <div className="space-y-6">
55
+ <Card>
56
+ <CardHeader>
57
+ <div className="flex items-center justify-between">
58
+ <h2 className="text-sm font-medium">Your organizations</h2>
59
+ <Button
60
+ size="sm"
61
+ variant={creating ? "ghost" : "default"}
62
+ onClick={() => setCreating((v) => !v)}
63
+ >
64
+ {creating ? "Cancel" : "New org"}
65
+ </Button>
66
+ </div>
67
+ </CardHeader>
68
+ <CardContent>
69
+ {creating && (
70
+ <form onSubmit={create} className="grid grid-cols-2 gap-2 mb-4">
71
+ <Input
72
+ placeholder="Org name"
73
+ value={name}
74
+ onChange={(e) => setName(e.target.value)}
75
+ disabled={pending}
76
+ required
77
+ />
78
+ <Input
79
+ placeholder="acme-corp"
80
+ value={slug}
81
+ onChange={(e) => setSlug(e.target.value.toLowerCase())}
82
+ disabled={pending}
83
+ pattern="[a-z0-9][a-z0-9-]{1,30}[a-z0-9]"
84
+ required
85
+ />
86
+ <Button
87
+ type="submit"
88
+ variant="primary"
89
+ disabled={pending || !name.trim() || !slug.trim()}
90
+ className="col-span-2"
91
+ >
92
+ {pending ? "Creating…" : "Create org"}
93
+ </Button>
94
+ {error && (
95
+ <p className="text-xs text-red-500 col-span-2">{error}</p>
96
+ )}
97
+ </form>
98
+ )}
99
+
100
+ {orgs.length === 0 ? (
101
+ <p className="text-sm text-neutral-500 py-2">
102
+ You&apos;re not in any orgs yet. Create one above to get started.
103
+ </p>
104
+ ) : (
105
+ <ul className="divide-y divide-neutral-200 dark:divide-neutral-800 -mx-5">
106
+ {orgs.map((o) => (
107
+ <li
108
+ key={o.id}
109
+ className="flex items-center justify-between px-5 py-2.5 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-900"
110
+ data-active={o.id === activeId || undefined}
111
+ onClick={() => setActiveId(o.id)}
112
+ >
113
+ <div>
114
+ <div className="text-sm font-medium">{o.name}</div>
115
+ <div className="text-xs text-neutral-400 font-mono">
116
+ {o.slug}
117
+ </div>
118
+ </div>
119
+ <span
120
+ className={`text-xs font-mono uppercase tracking-wide px-2 py-0.5 rounded ${
121
+ o.role === "owner"
122
+ ? "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
123
+ : o.role === "admin"
124
+ ? "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300"
125
+ : "bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"
126
+ }`}
127
+ >
128
+ {o.role}
129
+ </span>
130
+ </li>
131
+ ))}
132
+ </ul>
133
+ )}
134
+ </CardContent>
135
+ </Card>
136
+
137
+ {active && <OrgDetail org={active} />}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ function OrgDetail({ org }: { org: Org }) {
143
+ return (
144
+ <Card>
145
+ <CardHeader>
146
+ <div className="flex items-baseline justify-between">
147
+ <h2 className="text-sm font-medium">
148
+ {org.name}{" "}
149
+ <span className="text-xs text-neutral-400 font-mono ml-1">
150
+ /{org.slug}
151
+ </span>
152
+ </h2>
153
+ <span className="text-xs text-neutral-500">your role: {org.role}</span>
154
+ </div>
155
+ </CardHeader>
156
+ <CardContent className="space-y-4">
157
+ <p className="text-sm text-neutral-500">
158
+ Wire member management and project lists into this panel by calling
159
+ <code className="font-mono text-xs mx-1">/api/fn/orgMembers</code>
160
+ and
161
+ <code className="font-mono text-xs mx-1">/api/fn/orgProjects</code>
162
+ with this org&apos;s id. Both queries enforce membership; you only
163
+ see what your role allows.
164
+ </p>
165
+ <div className="text-xs font-mono text-neutral-400">
166
+ orgId: {org.id}
167
+ </div>
168
+ </CardContent>
169
+ </Card>
170
+ );
171
+ }
@@ -0,0 +1,6 @@
1
+ @import "tailwindcss";
2
+ @source "../../../../packages/ui/src/**/*.{ts,tsx}";
3
+
4
+ :root { color-scheme: light dark; }
5
+ html, body { height: 100%; }
6
+ body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
@@ -0,0 +1,21 @@
1
+ import type { Metadata } from "next";
2
+ import "./globals.css";
3
+
4
+ export const metadata: Metadata = {
5
+ title: "__APP_NAME__",
6
+ description: "Multi-tenant SaaS scaffold powered by Pylon",
7
+ };
8
+
9
+ export default function RootLayout({
10
+ children,
11
+ }: {
12
+ children: React.ReactNode;
13
+ }) {
14
+ return (
15
+ <html lang="en">
16
+ <body className="antialiased min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
17
+ {children}
18
+ </body>
19
+ </html>
20
+ );
21
+ }
@@ -0,0 +1,39 @@
1
+ import { pylon } from "@/lib/pylon";
2
+ import { OrgPicker } from "./components/OrgPicker";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ type Org = {
7
+ id: string;
8
+ slug: string;
9
+ name: string;
10
+ role: string;
11
+ createdAt: string;
12
+ };
13
+
14
+ export default async function HomePage() {
15
+ const orgs = await pylon
16
+ .json<Org[]>("/api/fn/myOrgs", {
17
+ method: "POST",
18
+ body: "{}",
19
+ headers: { "Content-Type": "application/json" },
20
+ })
21
+ .catch(() => [] as Org[]);
22
+
23
+ return (
24
+ <main className="mx-auto max-w-3xl px-6 py-12 space-y-8">
25
+ <header className="space-y-2">
26
+ <h1 className="text-3xl font-semibold tracking-tight">__APP_NAME__</h1>
27
+ <p className="text-sm text-neutral-500 dark:text-neutral-400">
28
+ Multi-tenant SaaS scaffold. Pick an org to manage its members and
29
+ projects, or create a new one. Tenant isolation is enforced
30
+ server-side by Pylon row-level policies — clients can&apos;t leak
31
+ data across orgs even if they fabricate <code className="font-mono text-xs">orgId</code>{" "}
32
+ in a request.
33
+ </p>
34
+ </header>
35
+
36
+ <OrgPicker initialOrgs={orgs} />
37
+ </main>
38
+ );
39
+ }
@@ -0,0 +1,5 @@
1
+ import { createPylonServer } from "@pylonsync/next/server";
2
+
3
+ export const pylon = createPylonServer({
4
+ cookieName: "__APP_NAME_SNAKE___session",
5
+ });
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": { "@/*": ["./src/*"] }
18
+ },
19
+ "include": [
20
+ "next-env.d.ts",
21
+ "src/**/*.ts",
22
+ "src/**/*.tsx",
23
+ ".next/types/**/*.ts"
24
+ ],
25
+ "exclude": ["node_modules"]
26
+ }
@@ -0,0 +1,2 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
@@ -0,0 +1,24 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const PYLON_API_URL = process.env.PYLON_API_URL ?? "http://localhost:4321";
4
+
5
+ const config: NextConfig = {
6
+ transpilePackages: [
7
+ "@__APP_NAME_KEBAB__/ui",
8
+ "@pylonsync/sdk",
9
+ "@pylonsync/react",
10
+ "@pylonsync/next",
11
+ "@pylonsync/functions",
12
+ "@pylonsync/sync",
13
+ ],
14
+ async rewrites() {
15
+ return [
16
+ { source: "/api/fn/:path*", destination: `${PYLON_API_URL}/api/fn/:path*` },
17
+ { source: "/api/auth/:path*", destination: `${PYLON_API_URL}/api/auth/:path*` },
18
+ { source: "/api/sync/:path*", destination: `${PYLON_API_URL}/api/sync/:path*` },
19
+ { source: "/api/:path*", destination: `${PYLON_API_URL}/api/:path*` },
20
+ ];
21
+ },
22
+ };
23
+
24
+ export default config;
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@__APP_NAME_KEBAB__/web",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "next dev --port 3000",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "lint": "next lint"
11
+ },
12
+ "dependencies": {
13
+ "@__APP_NAME_KEBAB__/ui": "__WORKSPACE_DEP__",
14
+ "@pylonsync/sdk": "^__PYLON_VERSION__",
15
+ "@pylonsync/react": "^__PYLON_VERSION__",
16
+ "@pylonsync/next": "^__PYLON_VERSION__",
17
+ "next": "^16.0.0",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.0.0",
23
+ "@types/react": "^19.0.0",
24
+ "@types/react-dom": "^19.0.0",
25
+ "@tailwindcss/postcss": "^4.0.0",
26
+ "tailwindcss": "^4.0.0",
27
+ "typescript": "^5.5.0"
28
+ }
29
+ }
@@ -0,0 +1,3 @@
1
+ export default {
2
+ plugins: { "@tailwindcss/postcss": {} },
3
+ };
@@ -0,0 +1,231 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
5
+ import { Button, Input } from "@__APP_NAME_KEBAB__/ui";
6
+
7
+ type Room = {
8
+ id: string;
9
+ slug: string;
10
+ name: string;
11
+ createdAt: string;
12
+ };
13
+
14
+ type Message = {
15
+ id: string;
16
+ roomId: string;
17
+ authorId: string;
18
+ authorName: string;
19
+ body: string;
20
+ createdAt: string;
21
+ };
22
+
23
+ const NAME_KEY = "__APP_NAME_SNAKE___author_name";
24
+
25
+ export function ChatRoom() {
26
+ // Live subscriptions. The sync engine pushes diffs over WebSocket
27
+ // — every Room insert + every Message append from any other client
28
+ // re-renders these components without a fetch / poll / refresh.
29
+ const { data: rooms = [] } = db.useQuery<Room>("Room", {
30
+ orderBy: { createdAt: "asc" },
31
+ });
32
+ const [activeId, setActiveId] = useState<string | null>(null);
33
+ useEffect(() => {
34
+ if (!activeId && rooms.length > 0) setActiveId(rooms[0].id);
35
+ }, [rooms, activeId]);
36
+
37
+ const { data: messages = [] } = db.useQuery<Message>("Message", {
38
+ where: activeId ? { roomId: activeId } : undefined,
39
+ orderBy: { createdAt: "asc" },
40
+ limit: 200,
41
+ });
42
+
43
+ const [body, setBody] = useState("");
44
+ const [authorName, setAuthorName] = useState("anonymous");
45
+ const [sending, setSending] = useState(false);
46
+ const scrollerRef = useRef<HTMLDivElement>(null);
47
+
48
+ useEffect(() => {
49
+ const stored =
50
+ typeof window !== "undefined"
51
+ ? window.localStorage.getItem(NAME_KEY)
52
+ : null;
53
+ if (stored) setAuthorName(stored);
54
+ }, []);
55
+
56
+ useEffect(() => {
57
+ // Auto-scroll to bottom when message count changes.
58
+ scrollerRef.current?.scrollTo({
59
+ top: scrollerRef.current.scrollHeight,
60
+ behavior: "smooth",
61
+ });
62
+ }, [messages.length, activeId]);
63
+
64
+ function persistName(next: string) {
65
+ setAuthorName(next);
66
+ window.localStorage.setItem(NAME_KEY, next);
67
+ }
68
+
69
+ const active = rooms.find((r) => r.id === activeId) ?? null;
70
+
71
+ async function send() {
72
+ const trimmed = body.trim();
73
+ if (!trimmed || !active) return;
74
+ setBody("");
75
+ setSending(true);
76
+ try {
77
+ // Server validates body length / auth, runs ctx.db.insert,
78
+ // the resulting change_event hits every subscriber's local
79
+ // store. We don't append to local state ourselves — the
80
+ // useQuery above re-renders when the new row lands.
81
+ await callFn("sendMessage", {
82
+ roomId: active.id,
83
+ body: trimmed,
84
+ authorName,
85
+ });
86
+ } finally {
87
+ setSending(false);
88
+ }
89
+ }
90
+
91
+ async function createRoom() {
92
+ const name = window.prompt("Room name?");
93
+ if (!name) return;
94
+ const slug = name
95
+ .toLowerCase()
96
+ .replace(/[^a-z0-9]+/g, "-")
97
+ .replace(/^-|-$/g, "");
98
+ try {
99
+ const room = (await callFn("createRoom", { slug, name })) as Room;
100
+ setActiveId(room.id);
101
+ } catch (e) {
102
+ window.alert(`Create failed: ${String(e)}`);
103
+ }
104
+ }
105
+
106
+ return (
107
+ <>
108
+ <aside className="w-64 border-r border-neutral-200 dark:border-neutral-800 flex flex-col">
109
+ <div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
110
+ <div className="flex items-center justify-between mb-2">
111
+ <h2 className="text-sm font-medium">Rooms</h2>
112
+ <button
113
+ onClick={createRoom}
114
+ className="text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
115
+ >
116
+ + New
117
+ </button>
118
+ </div>
119
+ <Input
120
+ value={authorName}
121
+ onChange={(e) => persistName(e.target.value)}
122
+ placeholder="Your name"
123
+ className="text-xs"
124
+ />
125
+ </div>
126
+ <ul className="flex-1 overflow-auto divide-y divide-neutral-200 dark:divide-neutral-800">
127
+ {rooms.map((r) => (
128
+ <li key={r.id}>
129
+ <button
130
+ onClick={() => setActiveId(r.id)}
131
+ className={`w-full text-left px-4 py-2.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-900 ${
132
+ r.id === activeId
133
+ ? "bg-neutral-100 dark:bg-neutral-800 font-medium"
134
+ : ""
135
+ }`}
136
+ >
137
+ <div>{r.name}</div>
138
+ <div className="text-xs text-neutral-400 font-mono">
139
+ #{r.slug}
140
+ </div>
141
+ </button>
142
+ </li>
143
+ ))}
144
+ {rooms.length === 0 && (
145
+ <li className="px-4 py-3 text-xs text-neutral-500">
146
+ No rooms yet. Create one above.
147
+ </li>
148
+ )}
149
+ </ul>
150
+ </aside>
151
+
152
+ <section className="flex-1 flex flex-col min-w-0">
153
+ {active ? (
154
+ <>
155
+ <header className="px-6 py-3 border-b border-neutral-200 dark:border-neutral-800">
156
+ <h1 className="text-sm font-medium">{active.name}</h1>
157
+ <p className="text-xs text-neutral-400 font-mono">
158
+ #{active.slug}
159
+ </p>
160
+ </header>
161
+
162
+ <div
163
+ ref={scrollerRef}
164
+ className="flex-1 overflow-auto px-6 py-4 space-y-3"
165
+ >
166
+ {messages.length === 0 ? (
167
+ <p className="text-sm text-neutral-500 text-center py-12">
168
+ No messages yet. Say hi.
169
+ </p>
170
+ ) : (
171
+ messages.map((m) => (
172
+ <div key={m.id} className="space-y-0.5">
173
+ <div className="flex items-baseline gap-2">
174
+ <span className="text-sm font-medium">
175
+ {m.authorName}
176
+ </span>
177
+ <span className="text-xs text-neutral-400">
178
+ {new Date(m.createdAt).toLocaleTimeString(undefined, {
179
+ hour: "numeric",
180
+ minute: "2-digit",
181
+ })}
182
+ </span>
183
+ </div>
184
+ <p className="text-sm whitespace-pre-wrap break-words">
185
+ {m.body}
186
+ </p>
187
+ </div>
188
+ ))
189
+ )}
190
+ </div>
191
+
192
+ <form
193
+ onSubmit={(e) => {
194
+ e.preventDefault();
195
+ send();
196
+ }}
197
+ className="px-6 py-3 border-t border-neutral-200 dark:border-neutral-800 flex gap-2"
198
+ >
199
+ <Input
200
+ value={body}
201
+ onChange={(e) => setBody(e.target.value)}
202
+ placeholder={`Message ${active.name}…`}
203
+ disabled={sending}
204
+ className="flex-1"
205
+ />
206
+ <Button
207
+ type="submit"
208
+ variant="primary"
209
+ disabled={sending || !body.trim()}
210
+ >
211
+ Send
212
+ </Button>
213
+ </form>
214
+ </>
215
+ ) : (
216
+ <div className="flex-1 flex items-center justify-center">
217
+ <div className="text-center space-y-2">
218
+ <p className="text-sm text-neutral-500">No room selected.</p>
219
+ <button
220
+ onClick={createRoom}
221
+ className="text-sm text-blue-600 hover:underline"
222
+ >
223
+ Create the first one
224
+ </button>
225
+ </div>
226
+ </div>
227
+ )}
228
+ </section>
229
+ </>
230
+ );
231
+ }
@@ -0,0 +1,6 @@
1
+ @import "tailwindcss";
2
+ @source "../../../../packages/ui/src/**/*.{ts,tsx}";
3
+
4
+ :root { color-scheme: light dark; }
5
+ html, body { height: 100%; }
6
+ body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
@@ -0,0 +1,22 @@
1
+ import type { Metadata } from "next";
2
+ import "./globals.css";
3
+ import { Providers } from "./providers";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "__APP_NAME__",
7
+ description: "Realtime chat powered by Pylon's sync engine",
8
+ };
9
+
10
+ export default function RootLayout({
11
+ children,
12
+ }: {
13
+ children: React.ReactNode;
14
+ }) {
15
+ return (
16
+ <html lang="en">
17
+ <body className="antialiased min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
18
+ <Providers>{children}</Providers>
19
+ </body>
20
+ </html>
21
+ );
22
+ }
@@ -0,0 +1,12 @@
1
+ import { ChatRoom } from "./components/ChatRoom";
2
+
3
+ // All data flows through `db.useQuery` in the client component below —
4
+ // no server-side initial fetch needed. The sync engine handles initial
5
+ // hydration + every subsequent change over the same WebSocket.
6
+ export default function HomePage() {
7
+ return (
8
+ <main className="h-screen flex">
9
+ <ChatRoom />
10
+ </main>
11
+ );
12
+ }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { init } from "@pylonsync/react";
5
+
6
+ /**
7
+ * Boots the Pylon sync engine on first mount. Every `db.useQuery`
8
+ * downstream renders against the same engine instance, so a single
9
+ * top-level Providers wrapper is enough — no need to thread a client
10
+ * through context.
11
+ *
12
+ * `baseUrl` is the same-origin Next path because next.config.ts
13
+ * proxies /api/* to the Pylon binary on PYLON_API_URL. Browser →
14
+ * same-origin → Next server → Pylon: no CORS, no extra DNS, the
15
+ * cookie travels naturally.
16
+ */
17
+ export function Providers({ children }: { children: React.ReactNode }) {
18
+ const [ready, setReady] = useState(false);
19
+ useEffect(() => {
20
+ init({ baseUrl: "", appName: "__APP_NAME_SNAKE__" });
21
+ setReady(true);
22
+ }, []);
23
+ if (!ready) return null;
24
+ return <>{children}</>;
25
+ }
@@ -0,0 +1,5 @@
1
+ import { createPylonServer } from "@pylonsync/next/server";
2
+
3
+ export const pylon = createPylonServer({
4
+ cookieName: "__APP_NAME_SNAKE___session",
5
+ });
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": { "@/*": ["./src/*"] }
18
+ },
19
+ "include": [
20
+ "next-env.d.ts",
21
+ "src/**/*.ts",
22
+ "src/**/*.tsx",
23
+ ".next/types/**/*.ts"
24
+ ],
25
+ "exclude": ["node_modules"]
26
+ }
@@ -0,0 +1,2 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />