@pylonsync/create-pylon 0.3.321 → 0.3.323
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.323",
|
|
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
|
-
|
|
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
|
|
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 }] },
|