@pylonsync/create-pylon 0.3.50 → 0.3.51

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 (2) hide show
  1. package/bin/create-pylon.js +495 -495
  2. package/package.json +1 -1
@@ -254,12 +254,12 @@ writeJson("apps/api/tsconfig.json", {
254
254
  write(
255
255
  "apps/api/schema.ts",
256
256
  `import {
257
- \tentity,
258
- \tfield,
259
- \tquery,
260
- \taction,
261
- \tpolicy,
262
- \tbuildManifest,
257
+ entity,
258
+ field,
259
+ query,
260
+ action,
261
+ policy,
262
+ buildManifest,
263
263
  } from "@pylonsync/sdk";
264
264
 
265
265
  // ---------------------------------------------------------------------------
@@ -267,14 +267,14 @@ write(
267
267
  // ---------------------------------------------------------------------------
268
268
 
269
269
  const Todo = entity("Todo", {
270
- \ttitle: field.string(),
271
- \tdone: field.bool(),
272
- \tcreatedAt: field.datetime(),
273
- \t// Float position so drag-reorder can insert between two existing
274
- \t// rows without renumbering the whole list. Frontend computes
275
- \t// (prev.position + next.position) / 2 on drop. Optional for
276
- \t// backwards compat with legacy rows.
277
- \tposition: field.float().optional(),
270
+ title: field.string(),
271
+ done: field.bool(),
272
+ createdAt: field.datetime(),
273
+ // Float position so drag-reorder can insert between two existing
274
+ // rows without renumbering the whole list. Frontend computes
275
+ // (prev.position + next.position) / 2 on drop. Optional for
276
+ // backwards compat with legacy rows.
277
+ position: field.float().optional(),
278
278
  });
279
279
 
280
280
  // ---------------------------------------------------------------------------
@@ -285,29 +285,29 @@ const Todo = entity("Todo", {
285
285
  const listTodos = query("listTodos");
286
286
 
287
287
  const addTodo = action("addTodo", {
288
- \tinput: [{ name: "title", type: "string" }],
288
+ input: [{ name: "title", type: "string" }],
289
289
  });
290
290
 
291
291
  const toggleTodo = action("toggleTodo", {
292
- \tinput: [{ name: "id", type: "id(Todo)" }, { name: "done", type: "bool" }],
292
+ input: [{ name: "id", type: "id(Todo)" }, { name: "done", type: "bool" }],
293
293
  });
294
294
 
295
295
  const deleteTodo = action("deleteTodo", {
296
- \tinput: [{ name: "id", type: "id(Todo)" }],
296
+ input: [{ name: "id", type: "id(Todo)" }],
297
297
  });
298
298
 
299
299
  const editTodo = action("editTodo", {
300
- \tinput: [
301
- \t\t{ name: "id", type: "id(Todo)" },
302
- \t\t{ name: "title", type: "string" },
303
- \t],
300
+ input: [
301
+ { name: "id", type: "id(Todo)" },
302
+ { name: "title", type: "string" },
303
+ ],
304
304
  });
305
305
 
306
306
  const reorderTodo = action("reorderTodo", {
307
- \tinput: [
308
- \t\t{ name: "id", type: "id(Todo)" },
309
- \t\t{ name: "position", type: "float" },
310
- \t],
307
+ input: [
308
+ { name: "id", type: "id(Todo)" },
309
+ { name: "position", type: "float" },
310
+ ],
311
311
  });
312
312
 
313
313
  // ---------------------------------------------------------------------------
@@ -315,12 +315,12 @@ const reorderTodo = action("reorderTodo", {
315
315
  // ---------------------------------------------------------------------------
316
316
 
317
317
  const todoPolicy = policy({
318
- \tname: "todo_open",
319
- \tentity: "Todo",
320
- \tallowRead: "true",
321
- \tallowInsert: "true",
322
- \tallowUpdate: "true",
323
- \tallowDelete: "true",
318
+ name: "todo_open",
319
+ entity: "Todo",
320
+ allowRead: "true",
321
+ allowInsert: "true",
322
+ allowUpdate: "true",
323
+ allowDelete: "true",
324
324
  });
325
325
 
326
326
  // ---------------------------------------------------------------------------
@@ -331,13 +331,13 @@ const todoPolicy = policy({
331
331
  // manifest off stdout. The framework expects JSON, not the JS object —
332
332
  // every Pylon entry file ends with this console.log line.
333
333
  const manifest = buildManifest({
334
- \tname: "${projectName}",
335
- \tversion: "0.0.1",
336
- \tentities: [Todo],
337
- \tqueries: [listTodos],
338
- \tactions: [addTodo, toggleTodo, deleteTodo, editTodo, reorderTodo],
339
- \tpolicies: [todoPolicy],
340
- \troutes: [],
334
+ name: "${projectName}",
335
+ version: "0.0.1",
336
+ entities: [Todo],
337
+ queries: [listTodos],
338
+ actions: [addTodo, toggleTodo, deleteTodo, editTodo, reorderTodo],
339
+ policies: [todoPolicy],
340
+ routes: [],
341
341
  });
342
342
 
343
343
  console.log(JSON.stringify(manifest));
@@ -354,63 +354,63 @@ write(
354
354
  * a fallback so the list stays deterministic.
355
355
  */
356
356
  export default query({
357
- \targs: {},
358
- \tasync handler(ctx) {
359
- \t\tconst rows = await ctx.db.query("Todo", {});
360
- \t\treturn [...rows].sort((a: any, b: any) => {
361
- \t\t\tconst ap =
362
- \t\t\t\ttypeof a.position === "number"
363
- \t\t\t\t\t? a.position
364
- \t\t\t\t\t: Date.parse(a.createdAt) || 0;
365
- \t\t\tconst bp =
366
- \t\t\t\ttypeof b.position === "number"
367
- \t\t\t\t\t? b.position
368
- \t\t\t\t\t: Date.parse(b.createdAt) || 0;
369
- \t\t\treturn ap - bp;
370
- \t\t});
371
- \t},
357
+ args: {},
358
+ async handler(ctx) {
359
+ const rows = await ctx.db.query("Todo", {});
360
+ return [...rows].sort((a: any, b: any) => {
361
+ const ap =
362
+ typeof a.position === "number"
363
+ ? a.position
364
+ : Date.parse(a.createdAt) || 0;
365
+ const bp =
366
+ typeof b.position === "number"
367
+ ? b.position
368
+ : Date.parse(b.createdAt) || 0;
369
+ return ap - bp;
370
+ });
371
+ },
372
372
  });
373
373
  `,
374
374
  );
375
375
 
376
376
  write(
377
- \t"apps/api/functions/editTodo.ts",
378
- \t\`import { mutation, v } from "@pylonsync/functions";
377
+ "apps/api/functions/editTodo.ts",
378
+ `import { mutation, v } from "@pylonsync/functions";
379
379
 
380
380
  /**
381
381
  * Rename a Todo. Trims whitespace; rejects empty titles.
382
382
  */
383
383
  export default mutation({
384
- \\targs: { id: v.id("Todo"), title: v.string() },
385
- \\tasync handler(ctx, args: { id: string; title: string }) {
386
- \\t\\tconst trimmed = args.title.trim();
387
- \\t\\tif (!trimmed) {
388
- \\t\\t\\tthrow ctx.error("EMPTY_TITLE", "title cannot be empty");
389
- \\t\\t}
390
- \\t\\tawait ctx.db.update("Todo", args.id, { title: trimmed });
391
- \\t\\treturn await ctx.db.get("Todo", args.id);
392
- \\t},
384
+ args: { id: v.id("Todo"), title: v.string() },
385
+ async handler(ctx, args: { id: string; title: string }) {
386
+ const trimmed = args.title.trim();
387
+ if (!trimmed) {
388
+ throw ctx.error("EMPTY_TITLE", "title cannot be empty");
389
+ }
390
+ await ctx.db.update("Todo", args.id, { title: trimmed });
391
+ return await ctx.db.get("Todo", args.id);
392
+ },
393
393
  });
394
- \`,
394
+ `,
395
395
  );
396
396
 
397
397
  write(
398
- \t"apps/api/functions/reorderTodo.ts",
399
- \t\`import { mutation, v } from "@pylonsync/functions";
398
+ "apps/api/functions/reorderTodo.ts",
399
+ `import { mutation, v } from "@pylonsync/functions";
400
400
 
401
401
  /**
402
- * Drag-reorder. Frontend computes \\\`position\\\` as the midpoint of the
402
+ * Drag-reorder. Frontend computes \`position\` as the midpoint of the
403
403
  * drop target's neighbors; we just write it. Floats give us ~52 inserts
404
404
  * between any two rows before precision matters.
405
405
  */
406
406
  export default mutation({
407
- \\targs: { id: v.id("Todo"), position: v.number() },
408
- \\tasync handler(ctx, args: { id: string; position: number }) {
409
- \\t\\tawait ctx.db.update("Todo", args.id, { position: args.position });
410
- \\t\\treturn await ctx.db.get("Todo", args.id);
411
- \\t},
407
+ args: { id: v.id("Todo"), position: v.number() },
408
+ async handler(ctx, args: { id: string; position: number }) {
409
+ await ctx.db.update("Todo", args.id, { position: args.position });
410
+ return await ctx.db.get("Todo", args.id);
411
+ },
412
412
  });
413
- \`,
413
+ `,
414
414
  );
415
415
 
416
416
  write(
@@ -422,11 +422,11 @@ write(
422
422
  * \`ctx.db.update\` which is only on writable ctx variants.
423
423
  */
424
424
  export default mutation({
425
- \targs: { id: v.id("Todo"), done: v.bool() },
426
- \tasync handler(ctx, args: { id: string; done: boolean }) {
427
- \t\tawait ctx.db.update("Todo", args.id, { done: args.done });
428
- \t\treturn await ctx.db.get("Todo", args.id);
429
- \t},
425
+ args: { id: v.id("Todo"), done: v.bool() },
426
+ async handler(ctx, args: { id: string; done: boolean }) {
427
+ await ctx.db.update("Todo", args.id, { done: args.done });
428
+ return await ctx.db.get("Todo", args.id);
429
+ },
430
430
  });
431
431
  `,
432
432
  );
@@ -440,12 +440,12 @@ write(
440
440
  * the client can show a "todo removed" toast or animate it out.
441
441
  */
442
442
  export default mutation({
443
- \targs: { id: v.id("Todo") },
444
- \tasync handler(ctx, args: { id: string }) {
445
- \t\tconst snapshot = await ctx.db.get("Todo", args.id);
446
- \t\tawait ctx.db.delete("Todo", args.id);
447
- \t\treturn snapshot;
448
- \t},
443
+ args: { id: v.id("Todo") },
444
+ async handler(ctx, args: { id: string }) {
445
+ const snapshot = await ctx.db.get("Todo", args.id);
446
+ await ctx.db.delete("Todo", args.id);
447
+ return snapshot;
448
+ },
449
449
  });
450
450
  `,
451
451
  );
@@ -460,24 +460,24 @@ write(
460
460
  * room for inserts-between without needing global renumber.
461
461
  */
462
462
  export default mutation({
463
- \targs: { title: v.string() },
464
- \tasync handler(ctx, args: { title: string }) {
465
- \t\tconst existing = await ctx.db.query("Todo", {});
466
- \t\tconst maxPos = existing.reduce((acc: number, row: any) => {
467
- \t\t\tconst p =
468
- \t\t\t\ttypeof row.position === "number"
469
- \t\t\t\t\t? row.position
470
- \t\t\t\t\t: Date.parse(row.createdAt) || 0;
471
- \t\t\treturn p > acc ? p : acc;
472
- \t\t}, 0);
473
- \t\tconst id = await ctx.db.insert("Todo", {
474
- \t\t\ttitle: args.title,
475
- \t\t\tdone: false,
476
- \t\t\tcreatedAt: new Date().toISOString(),
477
- \t\t\tposition: maxPos + 1024,
478
- \t\t});
479
- \t\treturn await ctx.db.get("Todo", id);
480
- \t},
463
+ args: { title: v.string() },
464
+ async handler(ctx, args: { title: string }) {
465
+ const existing = await ctx.db.query("Todo", {});
466
+ const maxPos = existing.reduce((acc: number, row: any) => {
467
+ const p =
468
+ typeof row.position === "number"
469
+ ? row.position
470
+ : Date.parse(row.createdAt) || 0;
471
+ return p > acc ? p : acc;
472
+ }, 0);
473
+ const id = await ctx.db.insert("Todo", {
474
+ title: args.title,
475
+ done: false,
476
+ createdAt: new Date().toISOString(),
477
+ position: maxPos + 1024,
478
+ });
479
+ return await ctx.db.get("Todo", id);
480
+ },
481
481
  });
482
482
  `,
483
483
  );
@@ -541,7 +541,7 @@ import { twMerge } from "tailwind-merge";
541
541
  * primitive's bg-neutral-900 base).
542
542
  */
543
543
  export function cn(...inputs: ClassValue[]): string {
544
- \treturn twMerge(clsx(inputs));
544
+ return twMerge(clsx(inputs));
545
545
  }
546
546
  `,
547
547
  );
@@ -555,42 +555,42 @@ type Variant = "default" | "primary" | "ghost";
555
555
  type Size = "sm" | "md";
556
556
 
557
557
  const variants: Record<Variant, string> = {
558
- \tdefault:
559
- \t\t"bg-neutral-100 hover:bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:text-neutral-100",
560
- \tprimary:
561
- \t\t"bg-neutral-900 hover:bg-neutral-800 text-white dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900",
562
- \tghost:
563
- \t\t"bg-transparent hover:bg-neutral-100 text-neutral-700 dark:hover:bg-neutral-800 dark:text-neutral-300",
558
+ default:
559
+ "bg-neutral-100 hover:bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:text-neutral-100",
560
+ primary:
561
+ "bg-neutral-900 hover:bg-neutral-800 text-white dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900",
562
+ ghost:
563
+ "bg-transparent hover:bg-neutral-100 text-neutral-700 dark:hover:bg-neutral-800 dark:text-neutral-300",
564
564
  };
565
565
 
566
566
  const sizes: Record<Size, string> = {
567
- \tsm: "h-8 px-3 text-[13px]",
568
- \tmd: "h-9 px-4 text-sm",
567
+ sm: "h-8 px-3 text-[13px]",
568
+ md: "h-9 px-4 text-sm",
569
569
  };
570
570
 
571
571
  export interface ButtonProps
572
- \textends React.ButtonHTMLAttributes<HTMLButtonElement> {
573
- \tvariant?: Variant;
574
- \tsize?: Size;
572
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
573
+ variant?: Variant;
574
+ size?: Size;
575
575
  }
576
576
 
577
577
  export function Button({
578
- \tclassName,
579
- \tvariant = "default",
580
- \tsize = "md",
581
- \t...props
578
+ className,
579
+ variant = "default",
580
+ size = "md",
581
+ ...props
582
582
  }: ButtonProps) {
583
- \treturn (
584
- \t\t<button
585
- \t\t\tclassName={cn(
586
- \t\t\t\t"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
587
- \t\t\t\tvariants[variant],
588
- \t\t\t\tsizes[size],
589
- \t\t\t\tclassName,
590
- \t\t\t)}
591
- \t\t\t{...props}
592
- \t\t/>
593
- \t);
583
+ return (
584
+ <button
585
+ className={cn(
586
+ "inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
587
+ variants[variant],
588
+ sizes[size],
589
+ className,
590
+ )}
591
+ {...props}
592
+ />
593
+ );
594
594
  }
595
595
  `,
596
596
  );
@@ -603,18 +603,18 @@ import { cn } from "./cn";
603
603
  export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
604
604
 
605
605
  export const Input = React.forwardRef<HTMLInputElement, InputProps>(
606
- \tfunction Input({ className, ...props }, ref) {
607
- \t\treturn (
608
- \t\t\t<input
609
- \t\t\t\tref={ref}
610
- \t\t\t\tclassName={cn(
611
- \t\t\t\t\t"flex h-9 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 disabled:opacity-50",
612
- \t\t\t\t\tclassName,
613
- \t\t\t\t)}
614
- \t\t\t\t{...props}
615
- \t\t\t/>
616
- \t\t);
617
- \t},
606
+ function Input({ className, ...props }, ref) {
607
+ return (
608
+ <input
609
+ ref={ref}
610
+ className={cn(
611
+ "flex h-9 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 disabled:opacity-50",
612
+ className,
613
+ )}
614
+ {...props}
615
+ />
616
+ );
617
+ },
618
618
  );
619
619
  `,
620
620
  );
@@ -625,34 +625,34 @@ write(
625
625
  import { cn } from "./cn";
626
626
 
627
627
  export function Card({
628
- \tclassName,
629
- \t...props
628
+ className,
629
+ ...props
630
630
  }: React.HTMLAttributes<HTMLDivElement>) {
631
- \treturn (
632
- \t\t<div
633
- \t\t\tclassName={cn(
634
- \t\t\t\t"rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900",
635
- \t\t\t\tclassName,
636
- \t\t\t)}
637
- \t\t\t{...props}
638
- \t\t/>
639
- \t);
631
+ return (
632
+ <div
633
+ className={cn(
634
+ "rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900",
635
+ className,
636
+ )}
637
+ {...props}
638
+ />
639
+ );
640
640
  }
641
641
 
642
642
  export function CardHeader({
643
- \tclassName,
644
- \t...props
643
+ className,
644
+ ...props
645
645
  }: React.HTMLAttributes<HTMLDivElement>) {
646
- \treturn (
647
- \t\t<div className={cn("p-5 border-b border-neutral-200 dark:border-neutral-800", className)} {...props} />
648
- \t);
646
+ return (
647
+ <div className={cn("p-5 border-b border-neutral-200 dark:border-neutral-800", className)} {...props} />
648
+ );
649
649
  }
650
650
 
651
651
  export function CardContent({
652
- \tclassName,
653
- \t...props
652
+ className,
653
+ ...props
654
654
  }: React.HTMLAttributes<HTMLDivElement>) {
655
- \treturn <div className={cn("p-5", className)} {...props} />;
655
+ return <div className={cn("p-5", className)} {...props} />;
656
656
  }
657
657
  `,
658
658
  );
@@ -749,22 +749,22 @@ write(
749
749
  const PYLON_API_URL = process.env.PYLON_API_URL ?? "http://localhost:4321";
750
750
 
751
751
  const config: NextConfig = {
752
- \ttranspilePackages: [
753
- \t\t"@${projectName}/ui",
754
- \t\t"@pylonsync/sdk",
755
- \t\t"@pylonsync/react",
756
- \t\t"@pylonsync/next",
757
- \t\t"@pylonsync/functions",
758
- \t\t"@pylonsync/sync",
759
- \t],
760
- \tasync rewrites() {
761
- \t\treturn [
762
- \t\t\t{ source: "/api/fn/:path*", destination: \`\${PYLON_API_URL}/api/fn/:path*\` },
763
- \t\t\t{ source: "/api/auth/:path*", destination: \`\${PYLON_API_URL}/api/auth/:path*\` },
764
- \t\t\t{ source: "/api/sync/:path*", destination: \`\${PYLON_API_URL}/api/sync/:path*\` },
765
- \t\t\t{ source: "/api/:path*", destination: \`\${PYLON_API_URL}/api/:path*\` },
766
- \t\t];
767
- \t},
752
+ transpilePackages: [
753
+ "@${projectName}/ui",
754
+ "@pylonsync/sdk",
755
+ "@pylonsync/react",
756
+ "@pylonsync/next",
757
+ "@pylonsync/functions",
758
+ "@pylonsync/sync",
759
+ ],
760
+ async rewrites() {
761
+ return [
762
+ { source: "/api/fn/:path*", destination: \`\${PYLON_API_URL}/api/fn/:path*\` },
763
+ { source: "/api/auth/:path*", destination: \`\${PYLON_API_URL}/api/auth/:path*\` },
764
+ { source: "/api/sync/:path*", destination: \`\${PYLON_API_URL}/api/sync/:path*\` },
765
+ { source: "/api/:path*", destination: \`\${PYLON_API_URL}/api/:path*\` },
766
+ ];
767
+ },
768
768
  };
769
769
 
770
770
  export default config;
@@ -775,7 +775,7 @@ write(
775
775
  "apps/web/postcss.config.mjs",
776
776
  `/** Tailwind v4 PostCSS pipeline. */
777
777
  export default {
778
- \tplugins: { "@tailwindcss/postcss": {} },
778
+ plugins: { "@tailwindcss/postcss": {} },
779
779
  };
780
780
  `,
781
781
  );
@@ -786,7 +786,7 @@ write(
786
786
  @source "../../../../packages/ui/src/**/*.{ts,tsx}";
787
787
 
788
788
  :root {
789
- \tcolor-scheme: light dark;
789
+ color-scheme: light dark;
790
790
  }
791
791
 
792
792
  html, body { height: 100%; }
@@ -800,22 +800,22 @@ write(
800
800
  import "./globals.css";
801
801
 
802
802
  export const metadata: Metadata = {
803
- \ttitle: "${projectName}",
804
- \tdescription: "Realtime app powered by Pylon",
803
+ title: "${projectName}",
804
+ description: "Realtime app powered by Pylon",
805
805
  };
806
806
 
807
807
  export default function RootLayout({
808
- \tchildren,
808
+ children,
809
809
  }: {
810
- \tchildren: React.ReactNode;
810
+ children: React.ReactNode;
811
811
  }) {
812
- \treturn (
813
- \t\t<html lang="en">
814
- \t\t\t<body className="antialiased min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
815
- \t\t\t\t{children}
816
- \t\t\t</body>
817
- \t\t</html>
818
- \t);
812
+ return (
813
+ <html lang="en">
814
+ <body className="antialiased min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
815
+ {children}
816
+ </body>
817
+ </html>
818
+ );
819
819
  }
820
820
  `,
821
821
  );
@@ -834,7 +834,7 @@ write(
834
834
  * deployment env can't silently break auth.
835
835
  */
836
836
  export const pylon = createPylonServer({
837
- \tcookieName: "${projectName}_session",
837
+ cookieName: "${projectName}_session",
838
838
  });
839
839
  `,
840
840
  );
@@ -850,41 +850,41 @@ import { TodoList } from "./components/TodoList";
850
850
  export const dynamic = "force-dynamic";
851
851
 
852
852
  type Todo = {
853
- \tid: string;
854
- \ttitle: string;
855
- \tdone: boolean;
856
- \tcreatedAt: string;
853
+ id: string;
854
+ title: string;
855
+ done: boolean;
856
+ createdAt: string;
857
857
  };
858
858
 
859
859
  export default async function HomePage() {
860
- \tconst todos = await pylon
861
- \t\t.json<Todo[]>("/api/fn/listTodos", {
862
- \t\t\tmethod: "POST",
863
- \t\t\tbody: "{}",
864
- \t\t\theaders: { "Content-Type": "application/json" },
865
- \t\t})
866
- \t\t.catch(() => [] as Todo[]);
867
-
868
- \treturn (
869
- \t\t<main className="mx-auto max-w-2xl px-6 py-12 space-y-8">
870
- \t\t\t<header className="space-y-2">
871
- \t\t\t\t<h1 className="text-3xl font-semibold tracking-tight">${projectName}</h1>
872
- \t\t\t\t<p className="text-sm text-neutral-500 dark:text-neutral-400">
873
- \t\t\t\t\tA Pylon-powered realtime app. Edit{" "}
874
- \t\t\t\t\t<code className="font-mono text-xs">apps/api/schema.ts</code> to change
875
- \t\t\t\t\tthe data model,{" "}
876
- \t\t\t\t\t<code className="font-mono text-xs">apps/api/functions/</code> to add
877
- \t\t\t\t\thandlers, or{" "}
878
- \t\t\t\t\t<code className="font-mono text-xs">
879
- \t\t\t\t\t\tapps/web/src/app/components/TodoList.tsx
880
- \t\t\t\t\t</code>{" "}
881
- \t\t\t\t\tfor the UI.
882
- \t\t\t\t</p>
883
- \t\t\t</header>
884
-
885
- \t\t\t<TodoList initialTodos={todos} />
886
- \t\t</main>
887
- \t);
860
+ const todos = await pylon
861
+ .json<Todo[]>("/api/fn/listTodos", {
862
+ method: "POST",
863
+ body: "{}",
864
+ headers: { "Content-Type": "application/json" },
865
+ })
866
+ .catch(() => [] as Todo[]);
867
+
868
+ return (
869
+ <main className="mx-auto max-w-2xl px-6 py-12 space-y-8">
870
+ <header className="space-y-2">
871
+ <h1 className="text-3xl font-semibold tracking-tight">${projectName}</h1>
872
+ <p className="text-sm text-neutral-500 dark:text-neutral-400">
873
+ A Pylon-powered realtime app. Edit{" "}
874
+ <code className="font-mono text-xs">apps/api/schema.ts</code> to change
875
+ the data model,{" "}
876
+ <code className="font-mono text-xs">apps/api/functions/</code> to add
877
+ handlers, or{" "}
878
+ <code className="font-mono text-xs">
879
+ apps/web/src/app/components/TodoList.tsx
880
+ </code>{" "}
881
+ for the UI.
882
+ </p>
883
+ </header>
884
+
885
+ <TodoList initialTodos={todos} />
886
+ </main>
887
+ );
888
888
  }
889
889
  `,
890
890
  );
@@ -897,29 +897,29 @@ import { useState, useTransition, useRef, useEffect } from "react";
897
897
  import { Button } from "@${projectName}/ui";
898
898
  import { Input } from "@${projectName}/ui";
899
899
  import {
900
- \tDndContext,
901
- \tclosestCenter,
902
- \tKeyboardSensor,
903
- \tPointerSensor,
904
- \tuseSensor,
905
- \tuseSensors,
906
- \ttype DragEndEvent,
900
+ DndContext,
901
+ closestCenter,
902
+ KeyboardSensor,
903
+ PointerSensor,
904
+ useSensor,
905
+ useSensors,
906
+ type DragEndEvent,
907
907
  } from "@dnd-kit/core";
908
908
  import {
909
- \tarrayMove,
910
- \tSortableContext,
911
- \tsortableKeyboardCoordinates,
912
- \tuseSortable,
913
- \tverticalListSortingStrategy,
909
+ arrayMove,
910
+ SortableContext,
911
+ sortableKeyboardCoordinates,
912
+ useSortable,
913
+ verticalListSortingStrategy,
914
914
  } from "@dnd-kit/sortable";
915
915
  import { CSS } from "@dnd-kit/utilities";
916
916
 
917
917
  type Todo = {
918
- \tid: string;
919
- \ttitle: string;
920
- \tdone: boolean;
921
- \tcreatedAt: string;
922
- \tposition?: number;
918
+ id: string;
919
+ title: string;
920
+ done: boolean;
921
+ createdAt: string;
922
+ position?: number;
923
923
  };
924
924
 
925
925
  /**
@@ -929,265 +929,265 @@ type Todo = {
929
929
  * the midpoint between its new neighbors and POST it to reorderTodo.
930
930
  */
931
931
  export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
932
- \tconst [todos, setTodos] = useState(initialTodos);
933
- \tconst [title, setTitle] = useState("");
934
- \tconst [pending, startTransition] = useTransition();
935
- \tconst sensors = useSensors(
936
- \t\tuseSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
937
- \t\tuseSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
938
- \t);
939
-
940
- \tasync function add() {
941
- \t\tif (!title.trim()) return;
942
- \t\tconst newTitle = title;
943
- \t\tsetTitle("");
944
- \t\tstartTransition(async () => {
945
- \t\t\tconst res = await fetch("/api/fn/addTodo", {
946
- \t\t\t\tmethod: "POST",
947
- \t\t\t\theaders: { "Content-Type": "application/json" },
948
- \t\t\t\tbody: JSON.stringify({ title: newTitle }),
949
- \t\t\t});
950
- \t\t\tif (res.ok) {
951
- \t\t\t\tconst todo = (await res.json()) as Todo;
952
- \t\t\t\tsetTodos((prev) => [...prev, todo]);
953
- \t\t\t}
954
- \t\t});
955
- \t}
956
-
957
- \tasync function toggle(t: Todo) {
958
- \t\tconst next = !t.done;
959
- \t\tsetTodos((prev) =>
960
- \t\t\tprev.map((row) => (row.id === t.id ? { ...row, done: next } : row)),
961
- \t\t);
962
- \t\tstartTransition(async () => {
963
- \t\t\tconst res = await fetch("/api/fn/toggleTodo", {
964
- \t\t\t\tmethod: "POST",
965
- \t\t\t\theaders: { "Content-Type": "application/json" },
966
- \t\t\t\tbody: JSON.stringify({ id: t.id, done: next }),
967
- \t\t\t});
968
- \t\t\tif (!res.ok) {
969
- \t\t\t\tsetTodos((prev) =>
970
- \t\t\t\t\tprev.map((row) => (row.id === t.id ? { ...row, done: t.done } : row)),
971
- \t\t\t\t);
972
- \t\t\t}
973
- \t\t});
974
- \t}
975
-
976
- \tasync function remove(t: Todo) {
977
- \t\tconst snapshot = todos;
978
- \t\tsetTodos((prev) => prev.filter((row) => row.id !== t.id));
979
- \t\tstartTransition(async () => {
980
- \t\t\tconst res = await fetch("/api/fn/deleteTodo", {
981
- \t\t\t\tmethod: "POST",
982
- \t\t\t\theaders: { "Content-Type": "application/json" },
983
- \t\t\t\tbody: JSON.stringify({ id: t.id }),
984
- \t\t\t});
985
- \t\t\tif (!res.ok) setTodos(snapshot);
986
- \t\t});
987
- \t}
988
-
989
- \tasync function rename(t: Todo, newTitle: string) {
990
- \t\tconst trimmed = newTitle.trim();
991
- \t\tif (!trimmed || trimmed === t.title) return;
992
- \t\tsetTodos((prev) =>
993
- \t\t\tprev.map((row) => (row.id === t.id ? { ...row, title: trimmed } : row)),
994
- \t\t);
995
- \t\tstartTransition(async () => {
996
- \t\t\tconst res = await fetch("/api/fn/editTodo", {
997
- \t\t\t\tmethod: "POST",
998
- \t\t\t\theaders: { "Content-Type": "application/json" },
999
- \t\t\t\tbody: JSON.stringify({ id: t.id, title: trimmed }),
1000
- \t\t\t});
1001
- \t\t\tif (!res.ok) {
1002
- \t\t\t\tsetTodos((prev) =>
1003
- \t\t\t\t\tprev.map((row) => (row.id === t.id ? { ...row, title: t.title } : row)),
1004
- \t\t\t\t);
1005
- \t\t\t}
1006
- \t\t});
1007
- \t}
1008
-
1009
- \tfunction onDragEnd(e: DragEndEvent) {
1010
- \t\tconst { active, over } = e;
1011
- \t\tif (!over || active.id === over.id) return;
1012
- \t\tconst oldIndex = todos.findIndex((t) => t.id === active.id);
1013
- \t\tconst newIndex = todos.findIndex((t) => t.id === over.id);
1014
- \t\tif (oldIndex < 0 || newIndex < 0) return;
1015
- \t\tconst reordered = arrayMove(todos, oldIndex, newIndex);
1016
- \t\tsetTodos(reordered);
1017
- \t\tconst prev = reordered[newIndex - 1];
1018
- \t\tconst next = reordered[newIndex + 1];
1019
- \t\tconst prevPos = prev?.position ?? Date.parse(prev?.createdAt ?? "") ?? 0;
1020
- \t\tconst nextPos = next?.position ?? Date.parse(next?.createdAt ?? "") ?? 0;
1021
- \t\tlet position: number;
1022
- \t\tif (prev && next) position = (prevPos + nextPos) / 2;
1023
- \t\telse if (prev) position = prevPos + 1024;
1024
- \t\telse if (next) position = nextPos - 1024;
1025
- \t\telse position = 1024;
1026
- \t\tconst movedId = String(active.id);
1027
- \t\tconst snapshot = todos;
1028
- \t\tstartTransition(async () => {
1029
- \t\t\tconst res = await fetch("/api/fn/reorderTodo", {
1030
- \t\t\t\tmethod: "POST",
1031
- \t\t\t\theaders: { "Content-Type": "application/json" },
1032
- \t\t\t\tbody: JSON.stringify({ id: movedId, position }),
1033
- \t\t\t});
1034
- \t\t\tif (!res.ok) setTodos(snapshot);
1035
- \t\t});
1036
- \t}
1037
-
1038
- \treturn (
1039
- \t\t<div className="space-y-4">
1040
- \t\t\t<form
1041
- \t\t\t\tonSubmit={(e) => {
1042
- \t\t\t\t\te.preventDefault();
1043
- \t\t\t\t\tadd();
1044
- \t\t\t\t}}
1045
- \t\t\t\tclassName="flex gap-2"
1046
- \t\t\t>
1047
- \t\t\t\t<Input
1048
- \t\t\t\t\tvalue={title}
1049
- \t\t\t\t\tonChange={(e) => setTitle(e.target.value)}
1050
- \t\t\t\t\tplaceholder="What needs doing?"
1051
- \t\t\t\t\tdisabled={pending}
1052
- \t\t\t\t\tclassName="flex-1"
1053
- \t\t\t\t/>
1054
- \t\t\t\t<Button
1055
- \t\t\t\t\ttype="submit"
1056
- \t\t\t\t\tvariant="primary"
1057
- \t\t\t\t\tdisabled={pending || !title.trim()}
1058
- \t\t\t\t>
1059
- \t\t\t\t\tAdd
1060
- \t\t\t\t</Button>
1061
- \t\t\t</form>
1062
-
1063
- \t\t\t{todos.length === 0 ? (
1064
- \t\t\t\t<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
1065
- \t\t\t\t\tNo todos yet. Add one above.
1066
- \t\t\t\t</p>
1067
- \t\t\t) : (
1068
- \t\t\t\t<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
1069
- \t\t\t\t\t<SortableContext items={todos.map((t) => t.id)} strategy={verticalListSortingStrategy}>
1070
- \t\t\t\t\t\t<ul className="divide-y divide-neutral-200 dark:divide-neutral-800 rounded-md border border-neutral-200 dark:border-neutral-800">
1071
- \t\t\t\t\t\t\t{todos.map((t) => (
1072
- \t\t\t\t\t\t\t\t<SortableRow
1073
- \t\t\t\t\t\t\t\t\tkey={t.id}
1074
- \t\t\t\t\t\t\t\t\ttodo={t}
1075
- \t\t\t\t\t\t\t\t\tpending={pending}
1076
- \t\t\t\t\t\t\t\t\tonToggle={() => toggle(t)}
1077
- \t\t\t\t\t\t\t\t\tonRemove={() => remove(t)}
1078
- \t\t\t\t\t\t\t\t\tonRename={(next) => rename(t, next)}
1079
- \t\t\t\t\t\t\t\t/>
1080
- \t\t\t\t\t\t\t))}
1081
- \t\t\t\t\t\t</ul>
1082
- \t\t\t\t\t</SortableContext>
1083
- \t\t\t\t</DndContext>
1084
- \t\t\t)}
1085
- \t\t</div>
1086
- \t);
932
+ const [todos, setTodos] = useState(initialTodos);
933
+ const [title, setTitle] = useState("");
934
+ const [pending, startTransition] = useTransition();
935
+ const sensors = useSensors(
936
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
937
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
938
+ );
939
+
940
+ async function add() {
941
+ if (!title.trim()) return;
942
+ const newTitle = title;
943
+ setTitle("");
944
+ startTransition(async () => {
945
+ const res = await fetch("/api/fn/addTodo", {
946
+ method: "POST",
947
+ headers: { "Content-Type": "application/json" },
948
+ body: JSON.stringify({ title: newTitle }),
949
+ });
950
+ if (res.ok) {
951
+ const todo = (await res.json()) as Todo;
952
+ setTodos((prev) => [...prev, todo]);
953
+ }
954
+ });
955
+ }
956
+
957
+ async function toggle(t: Todo) {
958
+ const next = !t.done;
959
+ setTodos((prev) =>
960
+ prev.map((row) => (row.id === t.id ? { ...row, done: next } : row)),
961
+ );
962
+ startTransition(async () => {
963
+ const res = await fetch("/api/fn/toggleTodo", {
964
+ method: "POST",
965
+ headers: { "Content-Type": "application/json" },
966
+ body: JSON.stringify({ id: t.id, done: next }),
967
+ });
968
+ if (!res.ok) {
969
+ setTodos((prev) =>
970
+ prev.map((row) => (row.id === t.id ? { ...row, done: t.done } : row)),
971
+ );
972
+ }
973
+ });
974
+ }
975
+
976
+ async function remove(t: Todo) {
977
+ const snapshot = todos;
978
+ setTodos((prev) => prev.filter((row) => row.id !== t.id));
979
+ startTransition(async () => {
980
+ const res = await fetch("/api/fn/deleteTodo", {
981
+ method: "POST",
982
+ headers: { "Content-Type": "application/json" },
983
+ body: JSON.stringify({ id: t.id }),
984
+ });
985
+ if (!res.ok) setTodos(snapshot);
986
+ });
987
+ }
988
+
989
+ async function rename(t: Todo, newTitle: string) {
990
+ const trimmed = newTitle.trim();
991
+ if (!trimmed || trimmed === t.title) return;
992
+ setTodos((prev) =>
993
+ prev.map((row) => (row.id === t.id ? { ...row, title: trimmed } : row)),
994
+ );
995
+ startTransition(async () => {
996
+ const res = await fetch("/api/fn/editTodo", {
997
+ method: "POST",
998
+ headers: { "Content-Type": "application/json" },
999
+ body: JSON.stringify({ id: t.id, title: trimmed }),
1000
+ });
1001
+ if (!res.ok) {
1002
+ setTodos((prev) =>
1003
+ prev.map((row) => (row.id === t.id ? { ...row, title: t.title } : row)),
1004
+ );
1005
+ }
1006
+ });
1007
+ }
1008
+
1009
+ function onDragEnd(e: DragEndEvent) {
1010
+ const { active, over } = e;
1011
+ if (!over || active.id === over.id) return;
1012
+ const oldIndex = todos.findIndex((t) => t.id === active.id);
1013
+ const newIndex = todos.findIndex((t) => t.id === over.id);
1014
+ if (oldIndex < 0 || newIndex < 0) return;
1015
+ const reordered = arrayMove(todos, oldIndex, newIndex);
1016
+ setTodos(reordered);
1017
+ const prev = reordered[newIndex - 1];
1018
+ const next = reordered[newIndex + 1];
1019
+ const prevPos = prev?.position ?? Date.parse(prev?.createdAt ?? "") ?? 0;
1020
+ const nextPos = next?.position ?? Date.parse(next?.createdAt ?? "") ?? 0;
1021
+ let position: number;
1022
+ if (prev && next) position = (prevPos + nextPos) / 2;
1023
+ else if (prev) position = prevPos + 1024;
1024
+ else if (next) position = nextPos - 1024;
1025
+ else position = 1024;
1026
+ const movedId = String(active.id);
1027
+ const snapshot = todos;
1028
+ startTransition(async () => {
1029
+ const res = await fetch("/api/fn/reorderTodo", {
1030
+ method: "POST",
1031
+ headers: { "Content-Type": "application/json" },
1032
+ body: JSON.stringify({ id: movedId, position }),
1033
+ });
1034
+ if (!res.ok) setTodos(snapshot);
1035
+ });
1036
+ }
1037
+
1038
+ return (
1039
+ <div className="space-y-4">
1040
+ <form
1041
+ onSubmit={(e) => {
1042
+ e.preventDefault();
1043
+ add();
1044
+ }}
1045
+ className="flex gap-2"
1046
+ >
1047
+ <Input
1048
+ value={title}
1049
+ onChange={(e) => setTitle(e.target.value)}
1050
+ placeholder="What needs doing?"
1051
+ disabled={pending}
1052
+ className="flex-1"
1053
+ />
1054
+ <Button
1055
+ type="submit"
1056
+ variant="primary"
1057
+ disabled={pending || !title.trim()}
1058
+ >
1059
+ Add
1060
+ </Button>
1061
+ </form>
1062
+
1063
+ {todos.length === 0 ? (
1064
+ <p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
1065
+ No todos yet. Add one above.
1066
+ </p>
1067
+ ) : (
1068
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
1069
+ <SortableContext items={todos.map((t) => t.id)} strategy={verticalListSortingStrategy}>
1070
+ <ul className="divide-y divide-neutral-200 dark:divide-neutral-800 rounded-md border border-neutral-200 dark:border-neutral-800">
1071
+ {todos.map((t) => (
1072
+ <SortableRow
1073
+ key={t.id}
1074
+ todo={t}
1075
+ pending={pending}
1076
+ onToggle={() => toggle(t)}
1077
+ onRemove={() => remove(t)}
1078
+ onRename={(next) => rename(t, next)}
1079
+ />
1080
+ ))}
1081
+ </ul>
1082
+ </SortableContext>
1083
+ </DndContext>
1084
+ )}
1085
+ </div>
1086
+ );
1087
1087
  }
1088
1088
 
1089
1089
  function SortableRow({ todo, pending, onToggle, onRemove, onRename }: {
1090
- \ttodo: Todo;
1091
- \tpending: boolean;
1092
- \tonToggle: () => void;
1093
- \tonRemove: () => void;
1094
- \tonRename: (next: string) => void;
1090
+ todo: Todo;
1091
+ pending: boolean;
1092
+ onToggle: () => void;
1093
+ onRemove: () => void;
1094
+ onRename: (next: string) => void;
1095
1095
  }) {
1096
- \tconst { attributes, listeners, setNodeRef, transform, transition, isDragging } =
1097
- \t\tuseSortable({ id: todo.id });
1098
- \tconst style = {
1099
- \t\ttransform: CSS.Transform.toString(transform),
1100
- \t\ttransition,
1101
- \t\topacity: isDragging ? 0.4 : 1,
1102
- \t};
1103
- \tconst [editing, setEditing] = useState(false);
1104
- \tconst [draft, setDraft] = useState(todo.title);
1105
- \tconst inputRef = useRef<HTMLInputElement>(null);
1106
-
1107
- \tuseEffect(() => {
1108
- \t\tif (editing) {
1109
- \t\t\tsetDraft(todo.title);
1110
- \t\t\trequestAnimationFrame(() => {
1111
- \t\t\t\tinputRef.current?.focus();
1112
- \t\t\t\tinputRef.current?.select();
1113
- \t\t\t});
1114
- \t\t}
1115
- \t}, [editing, todo.title]);
1116
-
1117
- \tfunction commit() {
1118
- \t\tsetEditing(false);
1119
- \t\tonRename(draft);
1120
- \t}
1121
-
1122
- \treturn (
1123
- \t\t<li
1124
- \t\t\tref={setNodeRef}
1125
- \t\t\tstyle={style}
1126
- \t\t\tclassName="flex items-center gap-3 px-4 py-3 text-sm group bg-white dark:bg-neutral-950"
1127
- \t\t>
1128
- \t\t\t<button
1129
- \t\t\t\ttype="button"
1130
- \t\t\t\t{...attributes}
1131
- \t\t\t\t{...listeners}
1132
- \t\t\t\tclassName="cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500 select-none touch-none"
1133
- \t\t\t\taria-label="Drag to reorder"
1134
- \t\t\t\ttabIndex={-1}
1135
- \t\t\t>
1136
- \t\t\t\t⋮⋮
1137
- \t\t\t</button>
1138
- \t\t\t<input
1139
- \t\t\t\ttype="checkbox"
1140
- \t\t\t\tchecked={todo.done}
1141
- \t\t\t\tonChange={onToggle}
1142
- \t\t\t\tdisabled={pending}
1143
- \t\t\t\tclassName="size-4 cursor-pointer"
1144
- \t\t\t\taria-label={\\\`Mark "\${todo.title}" as \${todo.done ? "not done" : "done"}\\\`}
1145
- \t\t\t/>
1146
- \t\t\t{editing ? (
1147
- \t\t\t\t<input
1148
- \t\t\t\t\tref={inputRef}
1149
- \t\t\t\t\tvalue={draft}
1150
- \t\t\t\t\tonChange={(e) => setDraft(e.target.value)}
1151
- \t\t\t\t\tonBlur={commit}
1152
- \t\t\t\t\tonKeyDown={(e) => {
1153
- \t\t\t\t\t\tif (e.key === "Enter") commit();
1154
- \t\t\t\t\t\telse if (e.key === "Escape") {
1155
- \t\t\t\t\t\t\tsetEditing(false);
1156
- \t\t\t\t\t\t\tsetDraft(todo.title);
1157
- \t\t\t\t\t\t}
1158
- \t\t\t\t\t}}
1159
- \t\t\t\t\tclassName="flex-1 bg-transparent border-b border-neutral-300 dark:border-neutral-700 outline-none text-sm"
1160
- \t\t\t\t\taria-label="Edit title"
1161
- \t\t\t\t/>
1162
- \t\t\t) : (
1163
- \t\t\t\t<button
1164
- \t\t\t\t\ttype="button"
1165
- \t\t\t\t\tonDoubleClick={() => setEditing(true)}
1166
- \t\t\t\t\tclassName={\\\`flex-1 text-left \${todo.done ? "line-through text-neutral-400" : ""}\\\`}
1167
- \t\t\t\t\ttitle="Double-click to edit"
1168
- \t\t\t\t>
1169
- \t\t\t\t\t{todo.title}
1170
- \t\t\t\t</button>
1171
- \t\t\t)}
1172
- \t\t\t<button
1173
- \t\t\t\ttype="button"
1174
- \t\t\t\tonClick={() => setEditing(true)}
1175
- \t\t\t\tclassName="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
1176
- \t\t\t\taria-label={\\\`Edit "\${todo.title}"\\\`}
1177
- \t\t\t>
1178
- \t\t\t\tEdit
1179
- \t\t\t</button>
1180
- \t\t\t<button
1181
- \t\t\t\ttype="button"
1182
- \t\t\t\tonClick={onRemove}
1183
- \t\t\t\tdisabled={pending}
1184
- \t\t\t\tclassName="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-red-500"
1185
- \t\t\t\taria-label={\\\`Delete "\${todo.title}"\\\`}
1186
- \t\t\t>
1187
- \t\t\t\tDelete
1188
- \t\t\t</button>
1189
- \t\t</li>
1190
- \t);
1096
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
1097
+ useSortable({ id: todo.id });
1098
+ const style = {
1099
+ transform: CSS.Transform.toString(transform),
1100
+ transition,
1101
+ opacity: isDragging ? 0.4 : 1,
1102
+ };
1103
+ const [editing, setEditing] = useState(false);
1104
+ const [draft, setDraft] = useState(todo.title);
1105
+ const inputRef = useRef<HTMLInputElement>(null);
1106
+
1107
+ useEffect(() => {
1108
+ if (editing) {
1109
+ setDraft(todo.title);
1110
+ requestAnimationFrame(() => {
1111
+ inputRef.current?.focus();
1112
+ inputRef.current?.select();
1113
+ });
1114
+ }
1115
+ }, [editing, todo.title]);
1116
+
1117
+ function commit() {
1118
+ setEditing(false);
1119
+ onRename(draft);
1120
+ }
1121
+
1122
+ return (
1123
+ <li
1124
+ ref={setNodeRef}
1125
+ style={style}
1126
+ className="flex items-center gap-3 px-4 py-3 text-sm group bg-white dark:bg-neutral-950"
1127
+ >
1128
+ <button
1129
+ type="button"
1130
+ {...attributes}
1131
+ {...listeners}
1132
+ className="cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500 select-none touch-none"
1133
+ aria-label="Drag to reorder"
1134
+ tabIndex={-1}
1135
+ >
1136
+ ⋮⋮
1137
+ </button>
1138
+ <input
1139
+ type="checkbox"
1140
+ checked={todo.done}
1141
+ onChange={onToggle}
1142
+ disabled={pending}
1143
+ className="size-4 cursor-pointer"
1144
+ aria-label={\\\`Mark "\${todo.title}" as \${todo.done ? "not done" : "done"}\\\`}
1145
+ />
1146
+ {editing ? (
1147
+ <input
1148
+ ref={inputRef}
1149
+ value={draft}
1150
+ onChange={(e) => setDraft(e.target.value)}
1151
+ onBlur={commit}
1152
+ onKeyDown={(e) => {
1153
+ if (e.key === "Enter") commit();
1154
+ else if (e.key === "Escape") {
1155
+ setEditing(false);
1156
+ setDraft(todo.title);
1157
+ }
1158
+ }}
1159
+ className="flex-1 bg-transparent border-b border-neutral-300 dark:border-neutral-700 outline-none text-sm"
1160
+ aria-label="Edit title"
1161
+ />
1162
+ ) : (
1163
+ <button
1164
+ type="button"
1165
+ onDoubleClick={() => setEditing(true)}
1166
+ className={\\\`flex-1 text-left \${todo.done ? "line-through text-neutral-400" : ""}\\\`}
1167
+ title="Double-click to edit"
1168
+ >
1169
+ {todo.title}
1170
+ </button>
1171
+ )}
1172
+ <button
1173
+ type="button"
1174
+ onClick={() => setEditing(true)}
1175
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
1176
+ aria-label={\\\`Edit "\${todo.title}"\\\`}
1177
+ >
1178
+ Edit
1179
+ </button>
1180
+ <button
1181
+ type="button"
1182
+ onClick={onRemove}
1183
+ disabled={pending}
1184
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-red-500"
1185
+ aria-label={\\\`Delete "\${todo.title}"\\\`}
1186
+ >
1187
+ Delete
1188
+ </button>
1189
+ </li>
1190
+ );
1191
1191
  }
1192
1192
  `,
1193
1193
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.50",
3
+ "version": "0.3.51",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + Next.js frontend in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"