@pylonsync/create-pylon 0.3.54 → 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 (35) hide show
  1. package/package.json +1 -1
  2. package/templates/expo/chat/apps/expo/App.tsx +126 -156
  3. package/templates/expo/consumer/apps/expo/App.tsx +211 -179
  4. package/templates/ios/chat/apps/ios/Package.swift +1 -11
  5. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +26 -30
  6. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
  7. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +27 -4
  8. package/templates/ios/chat/apps/ios/project.yml +2 -0
  9. package/templates/ios/consumer/apps/ios/Package.swift +1 -0
  10. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
  11. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
  12. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
  13. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
  14. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +33 -5
  15. package/templates/ios/consumer/apps/ios/project.yml +2 -0
  16. package/templates/mac/chat/apps/mac/Package.swift +1 -0
  17. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +30 -27
  18. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
  19. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +26 -5
  20. package/templates/mac/chat/apps/mac/project.yml +2 -0
  21. package/templates/mac/consumer/apps/mac/Package.swift +1 -0
  22. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
  23. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
  24. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
  25. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
  26. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -6
  27. package/templates/mac/consumer/apps/mac/project.yml +2 -0
  28. package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +52 -71
  29. package/templates/web/chat/apps/web/src/app/layout.tsx +3 -2
  30. package/templates/web/chat/apps/web/src/app/page.tsx +5 -44
  31. package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
  32. package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +139 -119
  33. package/templates/web/consumer/apps/web/src/app/layout.tsx +3 -2
  34. package/templates/web/consumer/apps/web/src/app/page.tsx +8 -42
  35. package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
@@ -1,15 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { useState, useTransition } from "react";
3
+ import { useMemo, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
4
5
  import { Button, Input, Card, CardHeader, CardContent } from "@__APP_NAME_KEBAB__/ui";
5
6
 
6
- type FeedItem = {
7
+ type Post = {
7
8
  id: string;
9
+ authorId: string;
8
10
  body: string;
9
11
  createdAt: string;
10
- author: { id: string; handle: string; displayName: string } | null;
11
- likeCount: number;
12
- likedByMe: boolean;
13
12
  };
14
13
 
15
14
  type Profile = {
@@ -21,88 +20,109 @@ type Profile = {
21
20
  createdAt: string;
22
21
  };
23
22
 
24
- export function Feed({
25
- initialFeed,
26
- initialMe,
23
+ type Like = {
24
+ id: string;
25
+ postId: string;
26
+ profileId: string;
27
+ createdAt: string;
28
+ };
29
+
30
+ export function Feed() {
31
+ // Three live subscriptions. The sync engine pushes diffs over
32
+ // WebSocket so a new Post / Like from any other client renders
33
+ // here within ~ms. Joining client-side keeps the schema simple
34
+ // — for very large feeds, a server-side aggregated query is
35
+ // cheaper, but the live story is more important to demonstrate.
36
+ const { data: posts = [] } = db.useQuery<Post>("Post", {
37
+ orderBy: { createdAt: "desc" },
38
+ limit: 100,
39
+ });
40
+ const { data: profiles = [] } = db.useQuery<Profile>("Profile", {});
41
+ const { data: likes = [] } = db.useQuery<Like>("Like", {});
42
+ const { data: myProfileRows = [] } = db.useQuery<Profile>("Profile", {});
43
+
44
+ const profilesById = useMemo(() => {
45
+ const map = new Map<string, Profile>();
46
+ for (const p of profiles) map.set(p.id, p);
47
+ return map;
48
+ }, [profiles]);
49
+
50
+ const myProfile = useMemo(() => {
51
+ // `myProfile` server-side filters by auth.userId; from the
52
+ // client we don't have userId here without a separate auth
53
+ // fetch, so we lean on the Profile policy: every Profile
54
+ // row in the local store IS visible (public reads). To find
55
+ // "ours" we'd want auth.userId; the scaffold uses a
56
+ // localStorage-cached selection instead.
57
+ if (typeof window === "undefined") return null;
58
+ const id = window.localStorage.getItem("__APP_NAME_SNAKE___profile_id");
59
+ if (!id) return null;
60
+ return myProfileRows.find((p) => p.id === id) ?? null;
61
+ }, [myProfileRows]);
62
+
63
+ if (!myProfile) {
64
+ return (
65
+ <ProfileSetup
66
+ existingHandles={profiles.map((p) => p.handle)}
67
+ onSaved={(p) => {
68
+ window.localStorage.setItem("__APP_NAME_SNAKE___profile_id", p.id);
69
+ // Force re-render by reading from the live profiles array.
70
+ }}
71
+ />
72
+ );
73
+ }
74
+
75
+ const items = posts.map((post) => {
76
+ const author = profilesById.get(post.authorId) ?? null;
77
+ const postLikes = likes.filter((l) => l.postId === post.id);
78
+ const likedByMe = postLikes.some((l) => l.profileId === myProfile.id);
79
+ return { post, author, likeCount: postLikes.length, likedByMe };
80
+ });
81
+
82
+ return <FeedView me={myProfile} items={items} />;
83
+ }
84
+
85
+ function FeedView({
86
+ me,
87
+ items,
27
88
  }: {
28
- initialFeed: FeedItem[];
29
- initialMe: Profile | null;
89
+ me: Profile;
90
+ items: Array<{
91
+ post: Post;
92
+ author: Profile | null;
93
+ likeCount: number;
94
+ likedByMe: boolean;
95
+ }>;
30
96
  }) {
31
- const [feed, setFeed] = useState(initialFeed);
32
- const [me, setMe] = useState(initialMe);
33
97
  const [body, setBody] = useState("");
34
- const [pending, startTransition] = useTransition();
35
-
36
- if (!me) {
37
- return <ProfileSetup onSaved={(p) => setMe(p)} />;
38
- }
98
+ const [posting, setPosting] = useState(false);
39
99
 
40
100
  async function post() {
41
101
  const trimmed = body.trim();
42
102
  if (!trimmed) return;
43
103
  setBody("");
44
- startTransition(async () => {
45
- const res = await fetch("/api/fn/createPost", {
46
- method: "POST",
47
- headers: { "Content-Type": "application/json" },
48
- body: JSON.stringify({ body: trimmed }),
49
- });
50
- if (res.ok) {
51
- const item = (await res.json()) as FeedItem;
52
- setFeed((prev) => [item, ...prev]);
53
- } else {
54
- setBody(trimmed);
55
- }
56
- });
104
+ setPosting(true);
105
+ try {
106
+ await callFn("createPost", { body: trimmed });
107
+ } finally {
108
+ setPosting(false);
109
+ }
57
110
  }
58
111
 
59
- async function toggleLike(item: FeedItem) {
60
- // Optimistic
61
- setFeed((prev) =>
62
- prev.map((p) =>
63
- p.id === item.id
64
- ? {
65
- ...p,
66
- likedByMe: !p.likedByMe,
67
- likeCount: p.likeCount + (p.likedByMe ? -1 : 1),
68
- }
69
- : p,
70
- ),
71
- );
72
- startTransition(async () => {
73
- const res = await fetch("/api/fn/toggleLike", {
74
- method: "POST",
75
- headers: { "Content-Type": "application/json" },
76
- body: JSON.stringify({ postId: item.id }),
77
- });
78
- if (!res.ok) {
79
- // Revert
80
- setFeed((prev) =>
81
- prev.map((p) =>
82
- p.id === item.id
83
- ? {
84
- ...p,
85
- likedByMe: item.likedByMe,
86
- likeCount: item.likeCount,
87
- }
88
- : p,
89
- ),
90
- );
91
- }
92
- });
112
+ async function toggleLike(postId: string) {
113
+ try {
114
+ await callFn("toggleLike", { postId });
115
+ } catch (e) {
116
+ window.alert(`Like failed: ${String(e)}`);
117
+ }
93
118
  }
94
119
 
95
- async function remove(item: FeedItem) {
96
- const snapshot = feed;
97
- setFeed((prev) => prev.filter((p) => p.id !== item.id));
98
- startTransition(async () => {
99
- const res = await fetch("/api/fn/deletePost", {
100
- method: "POST",
101
- headers: { "Content-Type": "application/json" },
102
- body: JSON.stringify({ id: item.id }),
103
- });
104
- if (!res.ok) setFeed(snapshot);
105
- });
120
+ async function remove(postId: string) {
121
+ try {
122
+ await callFn("deletePost", { id: postId });
123
+ } catch (e) {
124
+ window.alert(`Delete failed: ${String(e)}`);
125
+ }
106
126
  }
107
127
 
108
128
  return (
@@ -116,12 +136,6 @@ export function Feed({
116
136
  @{me.handle}
117
137
  </div>
118
138
  </div>
119
- <button
120
- className="text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
121
- onClick={() => setMe(null)}
122
- >
123
- Edit profile
124
- </button>
125
139
  </div>
126
140
  </CardHeader>
127
141
  <CardContent>
@@ -138,7 +152,7 @@ export function Feed({
138
152
  placeholder={`What's on your mind, ${me.displayName.split(" ")[0]}?`}
139
153
  rows={3}
140
154
  maxLength={1000}
141
- disabled={pending}
155
+ disabled={posting}
142
156
  className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 resize-none"
143
157
  />
144
158
  <div className="flex items-center justify-between">
@@ -148,7 +162,7 @@ export function Feed({
148
162
  <Button
149
163
  type="submit"
150
164
  variant="primary"
151
- disabled={pending || !body.trim()}
165
+ disabled={posting || !body.trim()}
152
166
  size="sm"
153
167
  >
154
168
  Post
@@ -158,27 +172,27 @@ export function Feed({
158
172
  </CardContent>
159
173
  </Card>
160
174
 
161
- {feed.length === 0 ? (
175
+ {items.length === 0 ? (
162
176
  <p className="text-sm text-neutral-500 text-center py-8">
163
177
  No posts yet. Be the first.
164
178
  </p>
165
179
  ) : (
166
180
  <ul className="space-y-3">
167
- {feed.map((item) => (
168
- <li key={item.id}>
181
+ {items.map(({ post, author, likeCount, likedByMe }) => (
182
+ <li key={post.id}>
169
183
  <Card>
170
184
  <CardContent className="space-y-3">
171
185
  <div className="flex items-baseline justify-between">
172
186
  <div className="text-sm">
173
187
  <span className="font-medium">
174
- {item.author?.displayName ?? "Unknown"}
188
+ {author?.displayName ?? "Unknown"}
175
189
  </span>{" "}
176
190
  <span className="text-neutral-400 font-mono text-xs">
177
- @{item.author?.handle ?? "?"}
191
+ @{author?.handle ?? "?"}
178
192
  </span>
179
193
  </div>
180
194
  <span className="text-xs text-neutral-400">
181
- {new Date(item.createdAt).toLocaleString(undefined, {
195
+ {new Date(post.createdAt).toLocaleString(undefined, {
182
196
  month: "short",
183
197
  day: "numeric",
184
198
  hour: "numeric",
@@ -187,22 +201,22 @@ export function Feed({
187
201
  </span>
188
202
  </div>
189
203
  <p className="text-sm whitespace-pre-wrap break-words">
190
- {item.body}
204
+ {post.body}
191
205
  </p>
192
206
  <div className="flex items-center gap-2">
193
207
  <button
194
- onClick={() => toggleLike(item)}
208
+ onClick={() => toggleLike(post.id)}
195
209
  className={`text-xs font-mono px-2 py-1 rounded border transition-colors ${
196
- item.likedByMe
210
+ likedByMe
197
211
  ? "border-pink-300 dark:border-pink-700 text-pink-600 dark:text-pink-300 bg-pink-50 dark:bg-pink-950"
198
212
  : "border-neutral-200 dark:border-neutral-800 text-neutral-500 hover:bg-neutral-50 dark:hover:bg-neutral-900"
199
213
  }`}
200
214
  >
201
- {item.likedByMe ? "♥" : "♡"} {item.likeCount}
215
+ {likedByMe ? "♥" : "♡"} {likeCount}
202
216
  </button>
203
- {item.author?.id === me.id && (
217
+ {author?.id === me.id && (
204
218
  <button
205
- onClick={() => remove(item)}
219
+ onClick={() => remove(post.id)}
206
220
  className="text-xs text-neutral-400 hover:text-red-500"
207
221
  >
208
222
  Delete
@@ -219,30 +233,40 @@ export function Feed({
219
233
  );
220
234
  }
221
235
 
222
- function ProfileSetup({ onSaved }: { onSaved: (p: Profile) => void }) {
236
+ function ProfileSetup({
237
+ existingHandles,
238
+ onSaved,
239
+ }: {
240
+ existingHandles: string[];
241
+ onSaved: (p: Profile) => void;
242
+ }) {
223
243
  const [handle, setHandle] = useState("");
224
244
  const [displayName, setDisplayName] = useState("");
225
245
  const [bio, setBio] = useState("");
226
- const [pending, startTransition] = useTransition();
246
+ const [saving, setSaving] = useState(false);
227
247
  const [error, setError] = useState<string | null>(null);
228
248
 
229
249
  async function save(e: React.FormEvent) {
230
250
  e.preventDefault();
231
251
  setError(null);
232
- startTransition(async () => {
233
- const res = await fetch("/api/fn/upsertProfile", {
234
- method: "POST",
235
- headers: { "Content-Type": "application/json" },
236
- body: JSON.stringify({ handle, displayName, bio }),
237
- });
238
- if (res.ok) {
239
- const profile = (await res.json()) as Profile;
240
- onSaved(profile);
241
- } else {
242
- const body = await res.json().catch(() => ({}));
243
- setError(body?.message ?? "save failed");
244
- }
245
- });
252
+ const lower = handle.toLowerCase();
253
+ if (existingHandles.includes(lower)) {
254
+ setError(`@${lower} is taken`);
255
+ return;
256
+ }
257
+ setSaving(true);
258
+ try {
259
+ const profile = (await callFn("upsertProfile", {
260
+ handle: lower,
261
+ displayName,
262
+ bio,
263
+ })) as Profile;
264
+ onSaved(profile);
265
+ } catch (e) {
266
+ setError(String(e));
267
+ } finally {
268
+ setSaving(false);
269
+ }
246
270
  }
247
271
 
248
272
  return (
@@ -253,9 +277,7 @@ function ProfileSetup({ onSaved }: { onSaved: (p: Profile) => void }) {
253
277
  <CardContent>
254
278
  <form onSubmit={save} className="space-y-3">
255
279
  <div>
256
- <label className="text-xs text-neutral-500 block mb-1">
257
- Handle
258
- </label>
280
+ <label className="text-xs text-neutral-500 block mb-1">Handle</label>
259
281
  <Input
260
282
  value={handle}
261
283
  onChange={(e) => setHandle(e.target.value.toLowerCase())}
@@ -275,9 +297,7 @@ function ProfileSetup({ onSaved }: { onSaved: (p: Profile) => void }) {
275
297
  />
276
298
  </div>
277
299
  <div>
278
- <label className="text-xs text-neutral-500 block mb-1">
279
- Bio
280
- </label>
300
+ <label className="text-xs text-neutral-500 block mb-1">Bio</label>
281
301
  <Input
282
302
  value={bio}
283
303
  onChange={(e) => setBio(e.target.value)}
@@ -285,8 +305,8 @@ function ProfileSetup({ onSaved }: { onSaved: (p: Profile) => void }) {
285
305
  />
286
306
  </div>
287
307
  {error && <p className="text-xs text-red-500">{error}</p>}
288
- <Button type="submit" variant="primary" disabled={pending}>
289
- {pending ? "Saving…" : "Save"}
308
+ <Button type="submit" variant="primary" disabled={saving}>
309
+ {saving ? "Saving…" : "Save"}
290
310
  </Button>
291
311
  </form>
292
312
  </CardContent>
@@ -1,9 +1,10 @@
1
1
  import type { Metadata } from "next";
2
2
  import "./globals.css";
3
+ import { Providers } from "./providers";
3
4
 
4
5
  export const metadata: Metadata = {
5
6
  title: "__APP_NAME__",
6
- description: "Multi-tenant SaaS scaffold powered by Pylon",
7
+ description: "Realtime social feed powered by Pylon",
7
8
  };
8
9
 
9
10
  export default function RootLayout({
@@ -14,7 +15,7 @@ export default function RootLayout({
14
15
  return (
15
16
  <html lang="en">
16
17
  <body className="antialiased min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
17
- {children}
18
+ <Providers>{children}</Providers>
18
19
  </body>
19
20
  </html>
20
21
  );
@@ -1,55 +1,21 @@
1
- import { pylon } from "@/lib/pylon";
2
1
  import { Feed } from "./components/Feed";
3
2
 
4
- export const dynamic = "force-dynamic";
5
-
6
- type FeedItem = {
7
- id: string;
8
- body: string;
9
- createdAt: string;
10
- author: { id: string; handle: string; displayName: string } | null;
11
- likeCount: number;
12
- likedByMe: boolean;
13
- };
14
-
15
- type Profile = {
16
- id: string;
17
- userId: string;
18
- handle: string;
19
- displayName: string;
20
- bio?: string | null;
21
- createdAt: string;
22
- };
23
-
24
- export default async function HomePage() {
25
- const [feed, me] = await Promise.all([
26
- pylon
27
- .json<FeedItem[]>("/api/fn/feed", {
28
- method: "POST",
29
- body: "{}",
30
- headers: { "Content-Type": "application/json" },
31
- })
32
- .catch(() => [] as FeedItem[]),
33
- pylon
34
- .json<Profile | null>("/api/fn/myProfile", {
35
- method: "POST",
36
- body: "{}",
37
- headers: { "Content-Type": "application/json" },
38
- })
39
- .catch(() => null),
40
- ]);
41
-
3
+ // Feed subscribes to Post / Profile / Like via db.useQuery — every
4
+ // new post + every like from any other client re-renders without
5
+ // a fetch. No server-side initial data; the sync engine hydrates
6
+ // on first connect.
7
+ export default function HomePage() {
42
8
  return (
43
9
  <main className="mx-auto max-w-2xl px-6 py-12 space-y-8">
44
10
  <header className="space-y-2">
45
11
  <h1 className="text-3xl font-semibold tracking-tight">__APP_NAME__</h1>
46
12
  <p className="text-sm text-neutral-500 dark:text-neutral-400">
47
- Public feed scaffold. Profile, Post, Like — wide-open reads, owner-
48
- only writes (enforced by Pylon row-level policies).
13
+ Public feed. Profile / Post / Like — wide-open reads, owner-only
14
+ writes. Live updates via Pylon&apos;s sync engine.
49
15
  </p>
50
16
  </header>
51
17
 
52
- <Feed initialFeed={feed} initialMe={me} />
18
+ <Feed />
53
19
  </main>
54
20
  );
55
21
  }
@@ -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
+ }