@pylonsync/create-pylon 0.3.321 → 0.3.322

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.321",
3
+ "version": "0.3.322",
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"
@@ -14,11 +14,26 @@ import {
14
14
  type PendingInvite,
15
15
  } from "@pylonsync/client";
16
16
  import { Button } from "@/components/ui/button";
17
+ import {
18
+ FolderKanban,
19
+ Users2,
20
+ CreditCard,
21
+ ArrowRight,
22
+ FolderPlus,
23
+ Plus,
24
+ Pencil,
25
+ Archive,
26
+ ArchiveRestore,
27
+ Trash2,
28
+ Check,
29
+ } from "lucide-react";
17
30
 
18
31
  export interface Project {
19
32
  id: string;
20
33
  orgId: string;
21
34
  name: string;
35
+ description?: string;
36
+ status?: string;
22
37
  createdAt: string;
23
38
  }
24
39
 
@@ -78,17 +93,89 @@ function Card({
78
93
  );
79
94
  }
80
95
 
81
- function Stat({ label, value }: { label: string; value: React.ReactNode }) {
96
+ // A stat tile: icon + label, a big value, and a small context line.
97
+ function StatCard({
98
+ icon,
99
+ label,
100
+ value,
101
+ hint,
102
+ }: {
103
+ icon: React.ReactNode;
104
+ label: string;
105
+ value: React.ReactNode;
106
+ hint?: React.ReactNode;
107
+ }) {
82
108
  return (
83
109
  <div className="rounded-xl border border-zinc-200 bg-white p-4">
84
- <div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">
85
- {label}
110
+ <div className="flex items-center gap-2 text-zinc-400">
111
+ {icon}
112
+ <span className="text-[11px] font-medium uppercase tracking-wide">
113
+ {label}
114
+ </span>
86
115
  </div>
87
- <div className="mt-1 text-2xl font-semibold text-zinc-900">{value}</div>
116
+ <div className="mt-2 text-2xl font-semibold text-zinc-900">{value}</div>
117
+ {hint != null && <div className="mt-0.5 text-xs text-zinc-400">{hint}</div>}
88
118
  </div>
89
119
  );
90
120
  }
91
121
 
122
+ // Deterministic, timezone-independent date (UTC parts) so the SSR render and
123
+ // client hydration always agree — a locale/tz-dependent format would mismatch
124
+ // and throw a React #418 hydration error.
125
+ const MONTHS = [
126
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
127
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
128
+ ];
129
+ function fmtDate(iso: string) {
130
+ const d = new Date(iso);
131
+ if (Number.isNaN(d.getTime())) return "";
132
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
133
+ }
134
+
135
+ // A stable accent per project (hashed from its id) so each card/monogram gets a
136
+ // consistent color without persisting one.
137
+ const ACCENTS = [
138
+ "bg-blue-50 text-blue-600",
139
+ "bg-violet-50 text-violet-600",
140
+ "bg-emerald-50 text-emerald-600",
141
+ "bg-amber-50 text-amber-600",
142
+ "bg-rose-50 text-rose-600",
143
+ "bg-cyan-50 text-cyan-600",
144
+ ];
145
+ function accentFor(id: string) {
146
+ let h = 0;
147
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
148
+ return ACCENTS[h % ACCENTS.length];
149
+ }
150
+ function monogram(name: string) {
151
+ const parts = name.trim().split(/\s+/).filter(Boolean);
152
+ const s =
153
+ parts.length >= 2 ? parts[0][0] + parts[1][0] : name.trim()[0] ?? "?";
154
+ return s.toUpperCase();
155
+ }
156
+
157
+ function StatusPill({ status }: { status: string }) {
158
+ const archived = status === "archived";
159
+ return (
160
+ <span
161
+ className={
162
+ "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium " +
163
+ (archived
164
+ ? "bg-zinc-100 text-zinc-500"
165
+ : "bg-emerald-50 text-emerald-600")
166
+ }
167
+ >
168
+ <span
169
+ className={
170
+ "size-1.5 rounded-full " +
171
+ (archived ? "bg-zinc-400" : "bg-emerald-500")
172
+ }
173
+ />
174
+ {archived ? "Archived" : "Active"}
175
+ </span>
176
+ );
177
+ }
178
+
92
179
  const inputCls =
93
180
  "h-9 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-zinc-900 focus:ring-2 focus:ring-zinc-900/10";
94
181
 
@@ -96,46 +183,118 @@ const inputCls =
96
183
 
97
184
  export function Overview({
98
185
  tenantId,
186
+ orgName,
187
+ userEmail,
99
188
  projects,
100
189
  memberCount,
101
190
  plan,
102
191
  }: {
103
192
  tenantId: string | null;
193
+ orgName?: string;
194
+ userEmail?: string;
104
195
  projects: Project[];
105
196
  memberCount: number;
106
197
  plan: string;
107
198
  }) {
108
199
  if (!tenantId) return <NoOrg />;
200
+ const activeCount = projects.filter(
201
+ (p) => (p.status ?? "active") !== "archived",
202
+ ).length;
203
+ const archivedCount = projects.length - activeCount;
204
+ const recent = [...projects]
205
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
206
+ .slice(0, 5);
207
+ const firstName = (userEmail ?? "").split("@")[0];
208
+
109
209
  return (
110
210
  <div className="space-y-6">
211
+ <div>
212
+ <h1 className="text-xl font-semibold tracking-tight text-zinc-900">
213
+ {orgName ? `${orgName} overview` : "Overview"}
214
+ </h1>
215
+ <p className="mt-1 text-sm text-zinc-500">
216
+ {firstName ? `Welcome back, ${firstName}. ` : ""}Here&apos;s what&apos;s
217
+ happening in your workspace.
218
+ </p>
219
+ </div>
220
+
111
221
  <div className="grid gap-4 sm:grid-cols-3">
112
- <Stat label="Projects" value={projects.length} />
113
- <Stat label="Members" value={memberCount} />
114
- <Stat
222
+ <StatCard
223
+ icon={<FolderKanban className="size-4" />}
224
+ label="Projects"
225
+ value={activeCount}
226
+ hint={archivedCount > 0 ? `${archivedCount} archived` : "active"}
227
+ />
228
+ <StatCard
229
+ icon={<Users2 className="size-4" />}
230
+ label="Members"
231
+ value={memberCount}
232
+ hint={memberCount === 1 ? "just you" : "in this workspace"}
233
+ />
234
+ <StatCard
235
+ icon={<CreditCard className="size-4" />}
115
236
  label="Plan"
116
237
  value={<span className="capitalize">{plan}</span>}
238
+ hint={
239
+ plan === "free" ? (
240
+ <a
241
+ href="/dashboard/billing"
242
+ className="text-brand hover:underline"
243
+ >
244
+ Upgrade to Pro →
245
+ </a>
246
+ ) : (
247
+ "thanks for the support"
248
+ )
249
+ }
117
250
  />
118
251
  </div>
252
+
119
253
  <Card
120
254
  title="Recent projects"
121
255
  action={
122
256
  <a
123
257
  href="/dashboard/projects"
124
- className="text-[13px] font-medium text-brand hover:underline"
258
+ className="inline-flex items-center gap-1 text-[13px] font-medium text-brand hover:underline"
125
259
  >
126
- View all
260
+ View all <ArrowRight className="size-3.5" />
127
261
  </a>
128
262
  }
129
263
  >
130
- {projects.length === 0 ? (
131
- <p className="text-sm text-zinc-500">
132
- No projects yet create one on the Projects tab.
133
- </p>
264
+ {recent.length === 0 ? (
265
+ <div className="flex flex-col items-center gap-2 py-8 text-center">
266
+ <span className="flex size-10 items-center justify-center rounded-xl bg-zinc-50 text-zinc-400">
267
+ <FolderPlus className="size-5" />
268
+ </span>
269
+ <p className="text-sm text-zinc-500">No projects yet.</p>
270
+ <a
271
+ href="/dashboard/projects"
272
+ className="text-[13px] font-medium text-brand hover:underline"
273
+ >
274
+ Create your first project →
275
+ </a>
276
+ </div>
134
277
  ) : (
135
- <ul className="divide-y divide-zinc-100">
136
- {projects.slice(0, 5).map((p) => (
137
- <li key={p.id} className="py-2.5 text-sm text-zinc-700">
138
- {p.name}
278
+ <ul className="-my-1 divide-y divide-zinc-100">
279
+ {recent.map((p) => (
280
+ <li key={p.id} className="flex items-center gap-3 py-2.5">
281
+ <span
282
+ className={
283
+ "flex size-8 shrink-0 items-center justify-center rounded-lg text-[12px] font-semibold " +
284
+ accentFor(p.id)
285
+ }
286
+ >
287
+ {monogram(p.name)}
288
+ </span>
289
+ <div className="min-w-0 flex-1">
290
+ <div className="truncate text-sm font-medium text-zinc-900">
291
+ {p.name}
292
+ </div>
293
+ <div className="truncate text-xs text-zinc-400">
294
+ Created {fmtDate(p.createdAt)}
295
+ </div>
296
+ </div>
297
+ <StatusPill status={p.status ?? "active"} />
139
298
  </li>
140
299
  ))}
141
300
  </ul>
@@ -171,58 +330,246 @@ function ProjectsList({
171
330
  initial: Project[];
172
331
  }) {
173
332
  const [name, setName] = useState("");
333
+ const [desc, setDesc] = useState("");
334
+ // Render the server-seeded `initial` rows on the server AND the client's
335
+ // first paint, then swap to the live reactive query once mounted. Gating the
336
+ // swap on a post-mount flag (rather than `loading`, which is already settled
337
+ // during SSR) keeps the server HTML and the first client render identical —
338
+ // no hydration mismatch, and still no empty-state flash.
339
+ const [hydrated, setHydrated] = useState(false);
340
+ useEffect(() => setHydrated(true), []);
174
341
  const { data, loading } = db.useQuery<Project>("Project");
175
- const projects = loading ? initial : data.filter((p) => p.orgId === orgId);
342
+ const rows =
343
+ !hydrated || loading ? initial : data.filter((p) => p.orgId === orgId);
344
+ const projects = rows
345
+ .slice()
346
+ .sort((a, b) => {
347
+ // Active first, then newest first.
348
+ const aa = (a.status ?? "active") === "archived" ? 1 : 0;
349
+ const bb = (b.status ?? "active") === "archived" ? 1 : 0;
350
+ if (aa !== bb) return aa - bb;
351
+ return a.createdAt < b.createdAt ? 1 : -1;
352
+ });
176
353
 
177
354
  async function add(e: React.FormEvent) {
178
355
  e.preventDefault();
179
356
  const value = name.trim();
180
357
  if (!value) return;
358
+ const description = desc.trim();
181
359
  setName("");
182
- await db.insert("Project", { orgId, name: value });
360
+ setDesc("");
361
+ await db.insert("Project", {
362
+ orgId,
363
+ name: value,
364
+ status: "active",
365
+ ...(description ? { description } : {}),
366
+ });
183
367
  }
184
368
 
185
369
  return (
186
- <Card title="Projects">
187
- <form onSubmit={add} className="flex items-center gap-2">
370
+ <div className="space-y-5">
371
+ <div className="rounded-xl border border-zinc-200 bg-white p-4">
372
+ <form
373
+ onSubmit={add}
374
+ className="flex flex-col gap-2 sm:flex-row sm:items-center"
375
+ >
376
+ <input
377
+ value={name}
378
+ onChange={(e) => setName(e.target.value)}
379
+ placeholder="Project name"
380
+ aria-label="Project name"
381
+ className={inputCls + " sm:flex-1"}
382
+ />
383
+ <input
384
+ value={desc}
385
+ onChange={(e) => setDesc(e.target.value)}
386
+ placeholder="Short description (optional)"
387
+ aria-label="Project description"
388
+ className={inputCls + " sm:flex-1"}
389
+ />
390
+ <Button type="submit" size="sm" className="shrink-0">
391
+ <Plus className="size-4" /> New project
392
+ </Button>
393
+ </form>
394
+ <p className="mt-2 text-xs text-zinc-400">
395
+ Tenant-scoped + live — only this workspace&apos;s projects (enforced by
396
+ policy), and every change syncs across tabs instantly.
397
+ </p>
398
+ </div>
399
+
400
+ {projects.length === 0 ? (
401
+ <div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-zinc-300 py-14 text-center">
402
+ <span className="flex size-11 items-center justify-center rounded-xl bg-zinc-50 text-zinc-400">
403
+ <FolderPlus className="size-5" />
404
+ </span>
405
+ <p className="text-sm font-medium text-zinc-700">No projects yet</p>
406
+ <p className="text-xs text-zinc-400">
407
+ Create your first project above to get started.
408
+ </p>
409
+ </div>
410
+ ) : (
411
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
412
+ {projects.map((p) => (
413
+ <ProjectCard key={p.id} p={p} />
414
+ ))}
415
+ </div>
416
+ )}
417
+ </div>
418
+ );
419
+ }
420
+
421
+ function IconBtn({
422
+ label,
423
+ danger,
424
+ onClick,
425
+ children,
426
+ }: {
427
+ label: string;
428
+ danger?: boolean;
429
+ onClick: () => void;
430
+ children: React.ReactNode;
431
+ }) {
432
+ return (
433
+ <button
434
+ type="button"
435
+ aria-label={label}
436
+ title={label}
437
+ onClick={onClick}
438
+ className={
439
+ "flex size-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-100 " +
440
+ (danger ? "hover:text-red-600" : "hover:text-zinc-700")
441
+ }
442
+ >
443
+ {children}
444
+ </button>
445
+ );
446
+ }
447
+
448
+ // One project card. Reads/writes live via the reactive `db` — rename, archive,
449
+ // and delete are optimistic and sync across tabs (all gated by the tenant
450
+ // policy). Editing swaps the card for an inline name + description form.
451
+ function ProjectCard({ p }: { p: Project }) {
452
+ const archived = (p.status ?? "active") === "archived";
453
+ const [editing, setEditing] = useState(false);
454
+ const [name, setName] = useState(p.name);
455
+ const [desc, setDesc] = useState(p.description ?? "");
456
+
457
+ async function save(e: React.FormEvent) {
458
+ e.preventDefault();
459
+ const n = name.trim();
460
+ if (!n) return;
461
+ setEditing(false);
462
+ await db.update("Project", p.id, {
463
+ name: n,
464
+ description: desc.trim() || undefined,
465
+ });
466
+ }
467
+ function cancel() {
468
+ setEditing(false);
469
+ setName(p.name);
470
+ setDesc(p.description ?? "");
471
+ }
472
+
473
+ if (editing) {
474
+ return (
475
+ <form
476
+ onSubmit={save}
477
+ className="flex flex-col gap-2 rounded-xl border border-zinc-300 bg-white p-4"
478
+ >
188
479
  <input
480
+ autoFocus
189
481
  value={name}
190
482
  onChange={(e) => setName(e.target.value)}
191
- placeholder="New project…"
192
483
  aria-label="Project name"
193
484
  className={inputCls}
194
485
  />
195
- <Button type="submit" size="sm">
196
- Add
197
- </Button>
486
+ <textarea
487
+ value={desc}
488
+ onChange={(e) => setDesc(e.target.value)}
489
+ rows={2}
490
+ placeholder="Add a description…"
491
+ aria-label="Project description"
492
+ className={inputCls + " h-auto resize-none py-2"}
493
+ />
494
+ <div className="flex items-center gap-2">
495
+ <Button type="submit" size="sm">
496
+ <Check className="size-3.5" /> Save
497
+ </Button>
498
+ <button
499
+ type="button"
500
+ onClick={cancel}
501
+ className="text-[13px] font-medium text-zinc-500 transition-colors hover:text-zinc-800"
502
+ >
503
+ Cancel
504
+ </button>
505
+ </div>
198
506
  </form>
199
- {projects.length === 0 ? (
200
- <p className="mt-3 text-sm text-zinc-500">No projects yet.</p>
201
- ) : (
202
- <ul className="mt-3 space-y-1.5">
203
- {projects.map((p) => (
204
- <li
205
- key={p.id}
206
- className="flex items-center justify-between rounded-md border border-zinc-200 px-3 py-2 text-sm"
207
- >
208
- <span className="truncate">{p.name}</span>
209
- <button
210
- type="button"
211
- aria-label="Delete project"
212
- onClick={() => db.delete("Project", p.id)}
213
- className="text-zinc-300 transition-colors hover:text-red-600"
214
- >
215
-
216
- </button>
217
- </li>
218
- ))}
219
- </ul>
220
- )}
221
- <p className="mt-3 text-xs text-zinc-400">
222
- Tenant-scoped + live: only this org&apos;s projects (enforced by policy),
223
- seeded from the server so there&apos;s no load flash.
224
- </p>
225
- </Card>
507
+ );
508
+ }
509
+
510
+ return (
511
+ <div
512
+ className={
513
+ "flex flex-col rounded-xl border border-zinc-200 bg-white p-4 transition-colors hover:border-zinc-300 " +
514
+ (archived ? "opacity-60" : "")
515
+ }
516
+ >
517
+ <div className="flex items-start gap-3">
518
+ <span
519
+ className={
520
+ "flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold " +
521
+ accentFor(p.id)
522
+ }
523
+ >
524
+ {monogram(p.name)}
525
+ </span>
526
+ <div className="min-w-0 flex-1">
527
+ <div className="truncate text-sm font-semibold text-zinc-900">
528
+ {p.name}
529
+ </div>
530
+ <p
531
+ className={
532
+ "mt-0.5 line-clamp-2 text-xs " +
533
+ (p.description ? "text-zinc-500" : "text-zinc-300")
534
+ }
535
+ >
536
+ {p.description || "No description"}
537
+ </p>
538
+ </div>
539
+ <div className="-mr-1 flex shrink-0 items-center">
540
+ <IconBtn label="Edit project" onClick={() => setEditing(true)}>
541
+ <Pencil className="size-3.5" />
542
+ </IconBtn>
543
+ <IconBtn
544
+ label={archived ? "Unarchive project" : "Archive project"}
545
+ onClick={() =>
546
+ db.update("Project", p.id, {
547
+ status: archived ? "active" : "archived",
548
+ })
549
+ }
550
+ >
551
+ {archived ? (
552
+ <ArchiveRestore className="size-3.5" />
553
+ ) : (
554
+ <Archive className="size-3.5" />
555
+ )}
556
+ </IconBtn>
557
+ <IconBtn
558
+ label="Delete project"
559
+ danger
560
+ onClick={() => db.delete("Project", p.id)}
561
+ >
562
+ <Trash2 className="size-3.5" />
563
+ </IconBtn>
564
+ </div>
565
+ </div>
566
+ <div className="mt-4 flex items-center justify-between">
567
+ <StatusPill status={p.status ?? "active"} />
568
+ <span className="text-[11px] text-zinc-400">
569
+ Created {fmtDate(p.createdAt)}
570
+ </span>
571
+ </div>
572
+ </div>
226
573
  );
227
574
  }
228
575
 
@@ -58,6 +58,8 @@ export default function DashboardPage({
58
58
  >
59
59
  <Overview
60
60
  tenantId={auth.tenant_id}
61
+ orgName={org?.name}
62
+ userEmail={me?.email}
61
63
  projects={projects}
62
64
  memberCount={memberCount}
63
65
  plan={plan}
@@ -107,6 +107,10 @@ const Project = entity(
107
107
  {
108
108
  orgId: field.id("Org"),
109
109
  name: field.string(),
110
+ description: field.string().optional(),
111
+ // "active" | "archived" — archived projects stay in the workspace but drop
112
+ // out of the default view. Toggled from the Projects tab.
113
+ status: field.string().default("active"),
110
114
  createdAt: field.datetime().defaultNow(),
111
115
  },
112
116
  { indexes: [{ name: "by_org", fields: ["orgId"], unique: false }] },