@pylonsync/create-pylon 0.3.320 → 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.
|
|
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"
|
|
@@ -110,7 +110,7 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
|
110
110
|
{mode === "signup" ? (
|
|
111
111
|
<p className="text-[12px] leading-snug text-zinc-500">
|
|
112
112
|
By joining, you agree to our{" "}
|
|
113
|
-
<a href="/company/
|
|
113
|
+
<a href="/company/terms" className="underline underline-offset-2">
|
|
114
114
|
Terms
|
|
115
115
|
</a>{" "}
|
|
116
116
|
&{" "}
|
|
@@ -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
|
-
|
|
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="
|
|
85
|
-
{
|
|
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-
|
|
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's what's
|
|
217
|
+
happening in your workspace.
|
|
218
|
+
</p>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
111
221
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
{
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
{
|
|
137
|
-
<li key={p.id} className="py-2.5
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
187
|
-
<
|
|
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'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
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
|
@@ -382,7 +729,7 @@ function MembersList({
|
|
|
382
729
|
</span>
|
|
383
730
|
)}
|
|
384
731
|
</div>
|
|
385
|
-
{m.name && m.email && (
|
|
732
|
+
{m.name && m.email && m.name !== m.email && (
|
|
386
733
|
<div className="truncate text-xs text-zinc-500">
|
|
387
734
|
{m.email}
|
|
388
735
|
</div>
|
|
@@ -700,8 +1047,13 @@ function BillingView({
|
|
|
700
1047
|
await fn();
|
|
701
1048
|
} catch (e) {
|
|
702
1049
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1050
|
+
// A missing STRIPE_PRICE_PRO surfaces as "plan pro has no monthly
|
|
1051
|
+
// priceId" rather than STRIPE_NOT_CONFIGURED, so treat the price/config
|
|
1052
|
+
// errors the same — both mean "finish the Stripe setup".
|
|
703
1053
|
setError(
|
|
704
|
-
/not.?configured|STRIPE_NOT_CONFIGURED/i.test(
|
|
1054
|
+
/not.?configured|STRIPE_NOT_CONFIGURED|no monthly price|priceId/i.test(
|
|
1055
|
+
msg,
|
|
1056
|
+
)
|
|
705
1057
|
? "Stripe isn't configured yet — set STRIPE_SECRET_KEY + STRIPE_PRICE_PRO to enable billing."
|
|
706
1058
|
: msg,
|
|
707
1059
|
);
|
package/templates/default/app.ts
CHANGED
|
@@ -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 }] },
|
|
@@ -665,6 +665,18 @@ export const siteConfig: SiteConfig = {
|
|
|
665
665
|
{ title: "Your control", body: "Export or delete your data at any time." },
|
|
666
666
|
],
|
|
667
667
|
},
|
|
668
|
+
{
|
|
669
|
+
slug: "terms",
|
|
670
|
+
navLabel: "Terms",
|
|
671
|
+
eyebrow: "Company",
|
|
672
|
+
title: "Terms of Service.",
|
|
673
|
+
summary: "The rules for using Acme, in plain language.",
|
|
674
|
+
sections: [
|
|
675
|
+
{ title: "Using Acme", body: "Use it for lawful work; don't abuse the service or other customers." },
|
|
676
|
+
{ title: "Your content", body: "You own your data — you grant us only what's needed to run the product for you." },
|
|
677
|
+
{ title: "Changes & cancellation", body: "Cancel anytime; we give notice before any material change to these terms." },
|
|
678
|
+
],
|
|
679
|
+
},
|
|
668
680
|
],
|
|
669
681
|
|
|
670
682
|
// Generic, made-up competitors so the template ships no real brand names.
|