@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.
- package/bin/create-pylon.js +98 -42
- package/package.json +1 -1
- package/templates/backend/b2b/apps/api/functions/archiveProject.ts +15 -0
- package/templates/backend/b2b/apps/api/functions/createOrg.ts +43 -0
- package/templates/backend/b2b/apps/api/functions/createProject.ts +25 -0
- package/templates/backend/b2b/apps/api/functions/inviteMember.ts +49 -0
- package/templates/backend/b2b/apps/api/functions/myOrgs.ts +37 -0
- package/templates/backend/b2b/apps/api/functions/orgMembers.ts +13 -0
- package/templates/backend/b2b/apps/api/functions/orgProjects.ts +18 -0
- package/templates/backend/b2b/apps/api/functions/removeMember.ts +29 -0
- package/templates/backend/b2b/apps/api/functions/setMemberRole.ts +38 -0
- package/templates/backend/b2b/apps/api/package.json +20 -0
- package/templates/backend/b2b/apps/api/schema.ts +171 -0
- package/templates/backend/b2b/apps/api/tsconfig.json +13 -0
- package/templates/backend/chat/apps/api/functions/createRoom.ts +32 -0
- package/templates/backend/chat/apps/api/functions/listRooms.ts +15 -0
- package/templates/backend/chat/apps/api/functions/roomMessages.ts +20 -0
- package/templates/backend/chat/apps/api/functions/sendMessage.ts +37 -0
- package/templates/backend/chat/apps/api/package.json +20 -0
- package/templates/backend/chat/apps/api/schema.ts +93 -0
- package/templates/backend/chat/apps/api/tsconfig.json +13 -0
- package/templates/backend/consumer/apps/api/functions/createPost.ts +48 -0
- package/templates/backend/consumer/apps/api/functions/deletePost.ts +21 -0
- package/templates/backend/consumer/apps/api/functions/feed.ts +57 -0
- package/templates/backend/consumer/apps/api/functions/myProfile.ts +17 -0
- package/templates/backend/consumer/apps/api/functions/profilePosts.ts +17 -0
- package/templates/backend/consumer/apps/api/functions/toggleLike.ts +48 -0
- package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +70 -0
- package/templates/backend/consumer/apps/api/package.json +20 -0
- package/templates/backend/consumer/apps/api/schema.ts +130 -0
- package/templates/backend/consumer/apps/api/tsconfig.json +13 -0
- package/templates/expo/chat/apps/expo/App.tsx +384 -0
- package/templates/expo/chat/apps/expo/app.json +25 -0
- package/templates/expo/chat/apps/expo/babel.config.js +6 -0
- package/templates/expo/chat/apps/expo/package.json +30 -0
- package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
- package/templates/expo/consumer/apps/expo/App.tsx +392 -0
- package/templates/expo/consumer/apps/expo/app.json +25 -0
- package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
- package/templates/expo/consumer/apps/expo/package.json +30 -0
- package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
- package/templates/ios/chat/apps/ios/Package.swift +24 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +116 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
- package/templates/ios/chat/apps/ios/project.yml +44 -0
- package/templates/ios/consumer/apps/ios/Package.swift +24 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +57 -0
- package/templates/ios/consumer/apps/ios/project.yml +44 -0
- package/templates/mac/b2b/apps/mac/Package.swift +22 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/b2b/apps/mac/project.yml +34 -0
- package/templates/mac/barebones/apps/mac/Package.swift +33 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/barebones/apps/mac/project.yml +34 -0
- package/templates/mac/chat/apps/mac/Package.swift +34 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +143 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
- package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/chat/apps/mac/project.yml +36 -0
- package/templates/mac/consumer/apps/mac/Package.swift +34 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +59 -0
- package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/consumer/apps/mac/project.yml +36 -0
- package/templates/mac/todo/apps/mac/Package.swift +33 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/todo/apps/mac/project.yml +34 -0
- package/templates/web/b2b/apps/web/next-env.d.ts +2 -0
- package/templates/web/b2b/apps/web/next.config.ts +24 -0
- package/templates/web/b2b/apps/web/package.json +29 -0
- package/templates/web/b2b/apps/web/postcss.config.mjs +3 -0
- package/templates/web/b2b/apps/web/src/app/components/OrgPicker.tsx +171 -0
- package/templates/web/b2b/apps/web/src/app/globals.css +6 -0
- package/templates/web/b2b/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/b2b/apps/web/src/app/page.tsx +39 -0
- package/templates/web/b2b/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/b2b/apps/web/tsconfig.json +26 -0
- package/templates/web/chat/apps/web/next-env.d.ts +2 -0
- package/templates/web/chat/apps/web/next.config.ts +24 -0
- package/templates/web/chat/apps/web/package.json +29 -0
- package/templates/web/chat/apps/web/postcss.config.mjs +3 -0
- package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +231 -0
- package/templates/web/chat/apps/web/src/app/globals.css +6 -0
- package/templates/web/chat/apps/web/src/app/layout.tsx +22 -0
- package/templates/web/chat/apps/web/src/app/page.tsx +12 -0
- package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
- package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/chat/apps/web/tsconfig.json +26 -0
- package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
- package/templates/web/consumer/apps/web/next.config.ts +24 -0
- package/templates/web/consumer/apps/web/package.json +29 -0
- package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
- package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +315 -0
- package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
- package/templates/web/consumer/apps/web/src/app/layout.tsx +22 -0
- package/templates/web/consumer/apps/web/src/app/page.tsx +21 -0
- package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
- package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/consumer/apps/web/tsconfig.json +26 -0
- /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Package.swift +0 -0
- /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/ContentView.swift +0 -0
- /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
- /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
- /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/project.yml +0 -0
- /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Package.swift +0 -0
- /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
- /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/TodoListView.swift +0 -0
- /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
- /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'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'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,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'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,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,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,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,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,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
|
+
}
|