@pylonsync/create-pylon 0.3.169 → 0.3.170

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.
@@ -64,7 +64,11 @@ const PYLON_VERSION = JSON.parse(
64
64
  // without value. Pick a different template if you want mobile.
65
65
  // ---------------------------------------------------------------------------
66
66
 
67
- const PLATFORMS_AVAILABLE = ["web", "ios", "mac", "expo"];
67
+ // `web` and `vite` both render into apps/web they're mutually
68
+ // exclusive web-frontend toolchains. `web` is Next.js 16 (SSR +
69
+ // server actions); `vite` is plain Vite + React for an SPA setup.
70
+ // Validation below rejects passing both.
71
+ const PLATFORMS_AVAILABLE = ["web", "vite", "ios", "mac", "expo"];
68
72
 
69
73
  const TEMPLATE_REGISTRY = {
70
74
  barebones: {
@@ -73,7 +77,7 @@ const TEMPLATE_REGISTRY = {
73
77
  },
74
78
  todo: {
75
79
  blurb: "CRUD + drag-reorder + optimistic mutations.",
76
- platforms: ["web", "ios", "mac", "expo"],
80
+ platforms: ["web", "vite", "ios", "mac", "expo"],
77
81
  },
78
82
  b2b: {
79
83
  blurb: "Multi-tenant SaaS: orgs, members, roles, RBAC policies.",
@@ -201,6 +205,13 @@ if (platforms.length === 0) {
201
205
  console.error(`\nError: at least one platform required.\n`);
202
206
  exit(1);
203
207
  }
208
+ if (platforms.includes("web") && platforms.includes("vite")) {
209
+ console.error(
210
+ `\nError: --platforms web and vite are mutually exclusive (both render into apps/web).\n` +
211
+ ` Pick one: web for Next.js 16, vite for plain Vite + React.\n`,
212
+ );
213
+ exit(1);
214
+ }
204
215
  if (!TEMPLATES_AVAILABLE.includes(flags.template)) {
205
216
  console.error(
206
217
  `\nError: unknown template "${flags.template}". Valid: ${TEMPLATES_AVAILABLE.join(", ")}\n`,
@@ -324,10 +335,17 @@ function copyTemplate(srcSubpath, destSubpath = "") {
324
335
  copyTemplate("_root");
325
336
  copyTemplate(`backend/${flags.template}`);
326
337
 
338
+ // `web` (Next.js) and `vite` are alternative web-frontend toolchains;
339
+ // the mutex check above guarantees at most one of them is set. Either
340
+ // way we also pull in packages/ui so the shared primitives are present.
327
341
  if (platforms.includes("web")) {
328
342
  copyTemplate("ui");
329
343
  copyTemplate(`web/${flags.template}`);
330
344
  }
345
+ if (platforms.includes("vite")) {
346
+ copyTemplate("ui");
347
+ copyTemplate(`vite/${flags.template}`);
348
+ }
331
349
  for (const p of ["ios", "mac", "expo"]) {
332
350
  if (platforms.includes(p)) copyTemplate(`${p}/${flags.template}`);
333
351
  }
@@ -374,7 +392,8 @@ const rootPkg = {
374
392
  workspaces: ["apps/*", "packages/*"].filter((p) => {
375
393
  // Only declare packages/* as a workspace if we actually scaffolded
376
394
  // packages/ui — otherwise the empty match warns on bun install.
377
- if (p === "packages/*") return platforms.includes("web");
395
+ if (p === "packages/*")
396
+ return platforms.includes("web") || platforms.includes("vite");
378
397
  return true;
379
398
  }),
380
399
  scripts: {
@@ -421,6 +440,8 @@ const platformLines = [];
421
440
  platformLines.push(" → api http://localhost:4321 (Pylon control plane)");
422
441
  if (platforms.includes("web"))
423
442
  platformLines.push(" → web http://localhost:3000 (Next.js)");
443
+ if (platforms.includes("vite"))
444
+ platformLines.push(" → web http://localhost:3000 (Vite + React)");
424
445
  if (platforms.includes("expo"))
425
446
  platformLines.push(` → expo ${runDev} (Metro + simulator, alongside web/api)`);
426
447
  if (platforms.includes("ios"))
@@ -433,6 +454,10 @@ if (platforms.includes("web")) {
433
454
  layoutLines.push(" apps/web Next.js 16 + React 19 + Tailwind v4");
434
455
  layoutLines.push(" packages/ui shared UI primitives");
435
456
  }
457
+ if (platforms.includes("vite")) {
458
+ layoutLines.push(" apps/web Vite + React 19 + Tailwind v4");
459
+ layoutLines.push(" packages/ui shared UI primitives");
460
+ }
436
461
  if (platforms.includes("ios"))
437
462
  layoutLines.push(" apps/ios Swift / SwiftUI (iOS)");
438
463
  if (platforms.includes("mac"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.169",
3
+ "version": "0.3.170",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>__APP_NAME__</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@__APP_NAME_KEBAB__/web",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --port 3000",
8
+ "build": "tsc --noEmit && vite build",
9
+ "preview": "vite preview --port 3000"
10
+ },
11
+ "dependencies": {
12
+ "@__APP_NAME_KEBAB__/ui": "__WORKSPACE_DEP__",
13
+ "@pylonsync/react": "^__PYLON_VERSION__",
14
+ "@pylonsync/sync": "^__PYLON_VERSION__",
15
+ "@dnd-kit/core": "^6.3.1",
16
+ "@dnd-kit/sortable": "^10.0.0",
17
+ "@dnd-kit/utilities": "^3.2.2",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@tailwindcss/vite": "^4.0.0",
23
+ "@types/node": "^20.0.0",
24
+ "@types/react": "^19.0.0",
25
+ "@types/react-dom": "^19.0.0",
26
+ "@vitejs/plugin-react": "^4.3.0",
27
+ "tailwindcss": "^4.0.0",
28
+ "typescript": "^5.5.0",
29
+ "vite": "^5.4.0"
30
+ }
31
+ }
@@ -0,0 +1,16 @@
1
+ import { TodoList } from "./components/TodoList";
2
+
3
+ export function App() {
4
+ return (
5
+ <main className="mx-auto max-w-2xl px-6 py-12 space-y-8">
6
+ <header className="space-y-2">
7
+ <h1 className="text-3xl font-semibold tracking-tight">__APP_NAME__</h1>
8
+ <p className="text-sm text-neutral-500 dark:text-neutral-400">
9
+ Drag rows to reorder, double-click a title to edit, hover for
10
+ delete. All mutations are optimistic with revert-on-failure.
11
+ </p>
12
+ </header>
13
+ <TodoList />
14
+ </main>
15
+ );
16
+ }
@@ -0,0 +1,281 @@
1
+ import { useState, useTransition, useRef, useEffect } from "react";
2
+ import { Button, Input } from "@__APP_NAME_KEBAB__/ui";
3
+ import { db } from "@pylonsync/react";
4
+ import {
5
+ DndContext,
6
+ closestCenter,
7
+ KeyboardSensor,
8
+ PointerSensor,
9
+ useSensor,
10
+ useSensors,
11
+ type DragEndEvent,
12
+ } from "@dnd-kit/core";
13
+ import {
14
+ arrayMove,
15
+ SortableContext,
16
+ sortableKeyboardCoordinates,
17
+ useSortable,
18
+ verticalListSortingStrategy,
19
+ } from "@dnd-kit/sortable";
20
+ import { CSS } from "@dnd-kit/utilities";
21
+
22
+ type Todo = {
23
+ id: string;
24
+ title: string;
25
+ done: boolean;
26
+ createdAt: string;
27
+ position?: number;
28
+ };
29
+
30
+ // Live query — db.useQuery subscribes to Todo and re-renders on every
31
+ // server-pushed change. Compare to a Next.js Server Component which
32
+ // fetches once at request time and goes stale. The order matches
33
+ // listTodos: explicit `position` first, falling back to createdAt
34
+ // for legacy rows.
35
+ function useTodos(): Todo[] {
36
+ const { data } = db.useQuery<Todo>("Todo");
37
+ const rows = data ?? [];
38
+ return [...rows].sort((a, b) => {
39
+ const ap =
40
+ typeof a.position === "number" ? a.position : Date.parse(a.createdAt) || 0;
41
+ const bp =
42
+ typeof b.position === "number" ? b.position : Date.parse(b.createdAt) || 0;
43
+ return ap - bp;
44
+ });
45
+ }
46
+
47
+ export function TodoList() {
48
+ const todos = useTodos();
49
+ const [title, setTitle] = useState("");
50
+ const [pending, startTransition] = useTransition();
51
+ const sensors = useSensors(
52
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
53
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
54
+ );
55
+
56
+ function add() {
57
+ if (!title.trim()) return;
58
+ const newTitle = title;
59
+ setTitle("");
60
+ startTransition(async () => {
61
+ await db.fn<Todo>("addTodo", { title: newTitle }).catch(() => null);
62
+ });
63
+ }
64
+
65
+ function toggle(t: Todo) {
66
+ startTransition(async () => {
67
+ await db
68
+ .fn("toggleTodo", { id: t.id, done: !t.done })
69
+ .catch(() => null);
70
+ });
71
+ }
72
+
73
+ function remove(t: Todo) {
74
+ startTransition(async () => {
75
+ await db.fn("deleteTodo", { id: t.id }).catch(() => null);
76
+ });
77
+ }
78
+
79
+ function rename(t: Todo, newTitle: string) {
80
+ const trimmed = newTitle.trim();
81
+ if (!trimmed || trimmed === t.title) return;
82
+ startTransition(async () => {
83
+ await db.fn("editTodo", { id: t.id, title: trimmed }).catch(() => null);
84
+ });
85
+ }
86
+
87
+ function onDragEnd(e: DragEndEvent) {
88
+ const { active, over } = e;
89
+ if (!over || active.id === over.id) return;
90
+ const oldIndex = todos.findIndex((t) => t.id === active.id);
91
+ const newIndex = todos.findIndex((t) => t.id === over.id);
92
+ if (oldIndex < 0 || newIndex < 0) return;
93
+ const reordered = arrayMove(todos, oldIndex, newIndex);
94
+ const prev = reordered[newIndex - 1];
95
+ const next = reordered[newIndex + 1];
96
+ const prevPos = prev?.position ?? Date.parse(prev?.createdAt ?? "") ?? 0;
97
+ const nextPos = next?.position ?? Date.parse(next?.createdAt ?? "") ?? 0;
98
+ let position: number;
99
+ if (prev && next) position = (prevPos + nextPos) / 2;
100
+ else if (prev) position = prevPos + 1024;
101
+ else if (next) position = nextPos - 1024;
102
+ else position = 1024;
103
+ const movedId = String(active.id);
104
+ startTransition(async () => {
105
+ await db.fn("reorderTodo", { id: movedId, position }).catch(() => null);
106
+ });
107
+ }
108
+
109
+ return (
110
+ <div className="space-y-4">
111
+ <form
112
+ onSubmit={(e) => {
113
+ e.preventDefault();
114
+ add();
115
+ }}
116
+ className="flex gap-2"
117
+ >
118
+ <Input
119
+ value={title}
120
+ onChange={(e) => setTitle(e.target.value)}
121
+ placeholder="What needs doing?"
122
+ disabled={pending}
123
+ className="flex-1"
124
+ />
125
+ <Button
126
+ type="submit"
127
+ variant="primary"
128
+ disabled={pending || !title.trim()}
129
+ >
130
+ Add
131
+ </Button>
132
+ </form>
133
+
134
+ {todos.length === 0 ? (
135
+ <p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
136
+ No todos yet. Add one above.
137
+ </p>
138
+ ) : (
139
+ <DndContext
140
+ sensors={sensors}
141
+ collisionDetection={closestCenter}
142
+ onDragEnd={onDragEnd}
143
+ >
144
+ <SortableContext
145
+ items={todos.map((t) => t.id)}
146
+ strategy={verticalListSortingStrategy}
147
+ >
148
+ <ul className="divide-y divide-neutral-200 dark:divide-neutral-800 rounded-md border border-neutral-200 dark:border-neutral-800">
149
+ {todos.map((t) => (
150
+ <SortableRow
151
+ key={t.id}
152
+ todo={t}
153
+ pending={pending}
154
+ onToggle={() => toggle(t)}
155
+ onRemove={() => remove(t)}
156
+ onRename={(next) => rename(t, next)}
157
+ />
158
+ ))}
159
+ </ul>
160
+ </SortableContext>
161
+ </DndContext>
162
+ )}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function SortableRow({
168
+ todo,
169
+ pending,
170
+ onToggle,
171
+ onRemove,
172
+ onRename,
173
+ }: {
174
+ todo: Todo;
175
+ pending: boolean;
176
+ onToggle: () => void;
177
+ onRemove: () => void;
178
+ onRename: (next: string) => void;
179
+ }) {
180
+ const {
181
+ attributes,
182
+ listeners,
183
+ setNodeRef,
184
+ transform,
185
+ transition,
186
+ isDragging,
187
+ } = useSortable({ id: todo.id });
188
+ const style = {
189
+ transform: CSS.Transform.toString(transform),
190
+ transition,
191
+ opacity: isDragging ? 0.4 : 1,
192
+ };
193
+ const [editing, setEditing] = useState(false);
194
+ const [draft, setDraft] = useState(todo.title);
195
+ const inputRef = useRef<HTMLInputElement>(null);
196
+
197
+ useEffect(() => {
198
+ if (editing) {
199
+ setDraft(todo.title);
200
+ requestAnimationFrame(() => {
201
+ inputRef.current?.focus();
202
+ inputRef.current?.select();
203
+ });
204
+ }
205
+ }, [editing, todo.title]);
206
+
207
+ function commit() {
208
+ setEditing(false);
209
+ onRename(draft);
210
+ }
211
+
212
+ return (
213
+ <li
214
+ ref={setNodeRef}
215
+ style={style}
216
+ className="flex items-center gap-3 px-4 py-3 text-sm group bg-white dark:bg-neutral-950"
217
+ >
218
+ <button
219
+ type="button"
220
+ {...attributes}
221
+ {...listeners}
222
+ className="cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500 select-none touch-none"
223
+ aria-label="Drag to reorder"
224
+ tabIndex={-1}
225
+ >
226
+ ⋮⋮
227
+ </button>
228
+ <input
229
+ type="checkbox"
230
+ checked={todo.done}
231
+ onChange={onToggle}
232
+ disabled={pending}
233
+ className="size-4 cursor-pointer"
234
+ aria-label={`Mark "${todo.title}" as ${todo.done ? "not done" : "done"}`}
235
+ />
236
+ {editing ? (
237
+ <input
238
+ ref={inputRef}
239
+ value={draft}
240
+ onChange={(e) => setDraft(e.target.value)}
241
+ onBlur={commit}
242
+ onKeyDown={(e) => {
243
+ if (e.key === "Enter") commit();
244
+ else if (e.key === "Escape") {
245
+ setEditing(false);
246
+ setDraft(todo.title);
247
+ }
248
+ }}
249
+ className="flex-1 bg-transparent border-b border-neutral-300 dark:border-neutral-700 outline-none text-sm"
250
+ aria-label="Edit title"
251
+ />
252
+ ) : (
253
+ <button
254
+ type="button"
255
+ onDoubleClick={() => setEditing(true)}
256
+ className={`flex-1 text-left ${todo.done ? "line-through text-neutral-400" : ""}`}
257
+ title="Double-click to edit"
258
+ >
259
+ {todo.title}
260
+ </button>
261
+ )}
262
+ <button
263
+ type="button"
264
+ onClick={() => setEditing(true)}
265
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
266
+ aria-label={`Edit "${todo.title}"`}
267
+ >
268
+ Edit
269
+ </button>
270
+ <button
271
+ type="button"
272
+ onClick={onRemove}
273
+ disabled={pending}
274
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-red-500"
275
+ aria-label={`Delete "${todo.title}"`}
276
+ >
277
+ Delete
278
+ </button>
279
+ </li>
280
+ );
281
+ }
@@ -0,0 +1,14 @@
1
+ @import "tailwindcss";
2
+ @source "../../../packages/ui/src/**/*.{ts,tsx}";
3
+
4
+ :root { color-scheme: light dark; }
5
+ html, body, #root { height: 100%; }
6
+ body {
7
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
8
+ margin: 0;
9
+ background: white;
10
+ color: #171717;
11
+ }
12
+ @media (prefers-color-scheme: dark) {
13
+ body { background: #0a0a0a; color: #f5f5f5; }
14
+ }
@@ -0,0 +1,18 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { init } from "@pylonsync/react";
4
+ import { App } from "./App";
5
+ import "./index.css";
6
+
7
+ // Same-origin baseUrl — the Vite dev server proxies /api/* to the
8
+ // Pylon backend (see vite.config.ts). In production, configure your
9
+ // CDN / reverse proxy the same way and this works unchanged.
10
+ init({ baseUrl: "", appName: "__APP_NAME_SNAKE__" });
11
+
12
+ const root = document.getElementById("root");
13
+ if (!root) throw new Error("#root not found");
14
+ createRoot(root).render(
15
+ <StrictMode>
16
+ <App />
17
+ </StrictMode>,
18
+ );
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "allowImportingTsExtensions": false,
8
+ "verbatimModuleSyntax": true,
9
+ "noEmit": true,
10
+ "jsx": "react-jsx",
11
+ "strict": true,
12
+ "isolatedModules": true,
13
+ "skipLibCheck": true,
14
+ "resolveJsonModule": true,
15
+ "useDefineForClassFields": true,
16
+ "esModuleInterop": true,
17
+ "allowSyntheticDefaultImports": true,
18
+ "baseUrl": ".",
19
+ "paths": {
20
+ "@/*": ["./src/*"]
21
+ }
22
+ },
23
+ "include": ["src", "vite.config.ts"]
24
+ }
@@ -0,0 +1,30 @@
1
+ import { defineConfig, type ProxyOptions } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+
5
+ // Backend origin. `pylon dev` defaults to :4321 — override via the
6
+ // PYLON_TARGET env if you're running it elsewhere. In a production
7
+ // build (`vite build` → static hosting + a separate API origin),
8
+ // configure your reverse proxy / CDN to route /api/* to the same
9
+ // place this proxy points at.
10
+ const PYLON_TARGET = process.env.PYLON_TARGET ?? "http://localhost:4321";
11
+
12
+ const proxyConfig: Record<string, string | ProxyOptions> = {
13
+ // HTTP — /api/auth/*, /api/fn/*, /api/sync/pull, etc.
14
+ "/api": {
15
+ target: PYLON_TARGET,
16
+ changeOrigin: true,
17
+ // Vite's http-proxy needs ws:true for the WebSocket upgrade
18
+ // to propagate. Without it `/api/sync/ws` 404s and db.useQuery
19
+ // hangs in a "connecting" state forever.
20
+ ws: true,
21
+ },
22
+ };
23
+
24
+ export default defineConfig({
25
+ plugins: [react(), tailwindcss()],
26
+ server: {
27
+ port: 3000,
28
+ proxy: proxyConfig,
29
+ },
30
+ });