@pylonsync/create-pylon 0.3.22 → 0.3.24

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 +367 -31
  2. package/package.json +1 -1
@@ -270,6 +270,11 @@ const Todo = entity("Todo", {
270
270
  \ttitle: field.string(),
271
271
  \tdone: field.bool(),
272
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(),
273
278
  });
274
279
 
275
280
  // ---------------------------------------------------------------------------
@@ -283,6 +288,28 @@ const addTodo = action("addTodo", {
283
288
  \tinput: [{ name: "title", type: "string" }],
284
289
  });
285
290
 
291
+ const toggleTodo = action("toggleTodo", {
292
+ \tinput: [{ name: "id", type: "id(Todo)" }, { name: "done", type: "bool" }],
293
+ });
294
+
295
+ const deleteTodo = action("deleteTodo", {
296
+ \tinput: [{ name: "id", type: "id(Todo)" }],
297
+ });
298
+
299
+ const editTodo = action("editTodo", {
300
+ \tinput: [
301
+ \t\t{ name: "id", type: "id(Todo)" },
302
+ \t\t{ name: "title", type: "string" },
303
+ \t],
304
+ });
305
+
306
+ const reorderTodo = action("reorderTodo", {
307
+ \tinput: [
308
+ \t\t{ name: "id", type: "id(Todo)" },
309
+ \t\t{ name: "position", type: "float" },
310
+ \t],
311
+ });
312
+
286
313
  // ---------------------------------------------------------------------------
287
314
  // Policies — wide-open by default. Tighten for production.
288
315
  // ---------------------------------------------------------------------------
@@ -308,7 +335,7 @@ const manifest = buildManifest({
308
335
  \tversion: "0.0.1",
309
336
  \tentities: [Todo],
310
337
  \tqueries: [listTodos],
311
- \tactions: [addTodo],
338
+ \tactions: [addTodo, toggleTodo, deleteTodo, editTodo, reorderTodo],
312
339
  \tpolicies: [todoPolicy],
313
340
  \troutes: [],
314
341
  });
@@ -322,15 +349,102 @@ write(
322
349
  `import { query } from "@pylonsync/functions";
323
350
 
324
351
  /**
325
- * Live query — every Todo, newest first. The Pylon runtime
326
- * subscribes the calling client to row-change events so any
327
- * \`useQuery("Todo")\` consumer auto-refreshes when this list
328
- * changes.
352
+ * Live query — every Todo, in user-controlled drag-reorder position.
353
+ * Rows without a \`position\` (legacy data) get sorted by createdAt as
354
+ * a fallback so the list stays deterministic.
329
355
  */
330
356
  export default query({
331
357
  \targs: {},
332
358
  \tasync handler(ctx) {
333
- \t\treturn await ctx.db.query("Todo", { $order: { createdAt: "desc" } });
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},
372
+ });
373
+ `,
374
+ );
375
+
376
+ write(
377
+ \t"apps/api/functions/editTodo.ts",
378
+ \t\`import { mutation, v } from "@pylonsync/functions";
379
+
380
+ /**
381
+ * Rename a Todo. Trims whitespace; rejects empty titles.
382
+ */
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},
393
+ });
394
+ \`,
395
+ );
396
+
397
+ write(
398
+ \t"apps/api/functions/reorderTodo.ts",
399
+ \t\`import { mutation, v } from "@pylonsync/functions";
400
+
401
+ /**
402
+ * Drag-reorder. Frontend computes \\\`position\\\` as the midpoint of the
403
+ * drop target's neighbors; we just write it. Floats give us ~52 inserts
404
+ * between any two rows before precision matters.
405
+ */
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},
412
+ });
413
+ \`,
414
+ );
415
+
416
+ write(
417
+ "apps/api/functions/toggleTodo.ts",
418
+ `import { mutation, v } from "@pylonsync/functions";
419
+
420
+ /**
421
+ * Flip the \`done\` flag on a Todo. Mutation, not action — needs
422
+ * \`ctx.db.update\` which is only on writable ctx variants.
423
+ */
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},
430
+ });
431
+ `,
432
+ );
433
+
434
+ write(
435
+ "apps/api/functions/deleteTodo.ts",
436
+ `import { mutation, v } from "@pylonsync/functions";
437
+
438
+ /**
439
+ * Remove a Todo row. Returns the row as it existed pre-delete so
440
+ * the client can show a "todo removed" toast or animate it out.
441
+ */
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;
334
448
  \t},
335
449
  });
336
450
  `,
@@ -338,22 +452,29 @@ export default query({
338
452
 
339
453
  write(
340
454
  "apps/api/functions/addTodo.ts",
341
- `import { action, v } from "@pylonsync/functions";
455
+ `import { mutation, v } from "@pylonsync/functions";
342
456
 
343
457
  /**
344
- * Insert a new Todo. Runs as an action (not a mutation) so the
345
- * client can call it via POST /api/fn/addTodo and get the
346
- * inserted row back synchronously. The change-event broadcast
347
- * the runtime emits for the insert is what wakes up
348
- * \`useQuery("Todo")\` consumers without an explicit refetch.
458
+ * Insert a new Todo. Seeds \`position\` to (max + 1024) so new rows
459
+ * land at the end of the drag-reorder list; the 1024 step leaves
460
+ * room for inserts-between without needing global renumber.
349
461
  */
350
- export default action({
462
+ export default mutation({
351
463
  \targs: { title: v.string() },
352
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);
353
473
  \t\tconst id = await ctx.db.insert("Todo", {
354
474
  \t\t\ttitle: args.title,
355
475
  \t\t\tdone: false,
356
476
  \t\t\tcreatedAt: new Date().toISOString(),
477
+ \t\t\tposition: maxPos + 1024,
357
478
  \t\t});
358
479
  \t\treturn await ctx.db.get("Todo", id);
359
480
  \t},
@@ -565,6 +686,10 @@ writeJson("apps/web/package.json", {
565
686
  "@pylonsync/sdk": `^${PYLON_VERSION}`,
566
687
  "@pylonsync/react": `^${PYLON_VERSION}`,
567
688
  "@pylonsync/next": `^${PYLON_VERSION}`,
689
+ // Drag-reorder for the scaffolded TodoList demo.
690
+ "@dnd-kit/core": "^6.3.1",
691
+ "@dnd-kit/sortable": "^10.0.0",
692
+ "@dnd-kit/utilities": "^3.2.2",
568
693
  next: "^16.0.0",
569
694
  react: "^19.0.0",
570
695
  "react-dom": "^19.0.0",
@@ -768,27 +893,49 @@ write(
768
893
  "apps/web/src/app/components/TodoList.tsx",
769
894
  `"use client";
770
895
 
771
- import { useState, useTransition } from "react";
896
+ import { useState, useTransition, useRef, useEffect } from "react";
772
897
  import { Button } from "@${projectName}/ui";
773
898
  import { Input } from "@${projectName}/ui";
899
+ import {
900
+ \tDndContext,
901
+ \tclosestCenter,
902
+ \tKeyboardSensor,
903
+ \tPointerSensor,
904
+ \tuseSensor,
905
+ \tuseSensors,
906
+ \ttype DragEndEvent,
907
+ } from "@dnd-kit/core";
908
+ import {
909
+ \tarrayMove,
910
+ \tSortableContext,
911
+ \tsortableKeyboardCoordinates,
912
+ \tuseSortable,
913
+ \tverticalListSortingStrategy,
914
+ } from "@dnd-kit/sortable";
915
+ import { CSS } from "@dnd-kit/utilities";
774
916
 
775
917
  type Todo = {
776
918
  \tid: string;
777
919
  \ttitle: string;
778
920
  \tdone: boolean;
779
921
  \tcreatedAt: string;
922
+ \tposition?: number;
780
923
  };
781
924
 
782
925
  /**
783
- * Optimistic todo list local state mirrors the server-fetched
784
- * initial list and prepends new rows on successful add. Wire
785
- * \`@pylonsync/react\`'s \`useQuery\` hook for full realtime updates
786
- * that re-render on every change-event push.
926
+ * Optimistic todo list with drag-reorder, inline title edit, toggle,
927
+ * and delete. All mutations are optimistic with revert-on-failure.
928
+ * Drag uses @dnd-kit; on drop we compute the new row's position as
929
+ * the midpoint between its new neighbors and POST it to reorderTodo.
787
930
  */
788
931
  export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
789
932
  \tconst [todos, setTodos] = useState(initialTodos);
790
933
  \tconst [title, setTitle] = useState("");
791
934
  \tconst [pending, startTransition] = useTransition();
935
+ \tconst sensors = useSensors(
936
+ \t\tuseSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
937
+ \t\tuseSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
938
+ \t);
792
939
 
793
940
  \tasync function add() {
794
941
  \t\tif (!title.trim()) return;
@@ -802,11 +949,92 @@ export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
802
949
  \t\t\t});
803
950
  \t\t\tif (res.ok) {
804
951
  \t\t\t\tconst todo = (await res.json()) as Todo;
805
- \t\t\t\tsetTodos([todo, ...todos]);
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);
806
1005
  \t\t\t}
807
1006
  \t\t});
808
1007
  \t}
809
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
+
810
1038
  \treturn (
811
1039
  \t\t<div className="space-y-4">
812
1040
  \t\t\t<form
@@ -837,22 +1065,130 @@ export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
837
1065
  \t\t\t\t\tNo todos yet. Add one above.
838
1066
  \t\t\t\t</p>
839
1067
  \t\t\t) : (
840
- \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">
841
- \t\t\t\t\t{todos.map((t) => (
842
- \t\t\t\t\t\t<li
843
- \t\t\t\t\t\t\tkey={t.id}
844
- \t\t\t\t\t\t\tclassName="flex items-center gap-3 px-4 py-3 text-sm"
845
- \t\t\t\t\t\t>
846
- \t\t\t\t\t\t\t<span className={t.done ? "line-through text-neutral-400" : ""}>
847
- \t\t\t\t\t\t\t\t{t.title}
848
- \t\t\t\t\t\t\t</span>
849
- \t\t\t\t\t\t</li>
850
- \t\t\t\t\t))}
851
- \t\t\t\t</ul>
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>
852
1084
  \t\t\t)}
853
1085
  \t\t</div>
854
1086
  \t);
855
1087
  }
1088
+
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;
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);
1191
+ }
856
1192
  `,
857
1193
  );
858
1194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
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"