@pylonsync/create-pylon 0.3.275 → 0.3.276

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 (31) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/agency/app/dashboard/dashboard-client.tsx +1213 -59
  4. package/templates/agency/app/layout.tsx +1 -1
  5. package/templates/agency/app/page.tsx +72 -30
  6. package/templates/agency/app/seeder.tsx +26 -0
  7. package/templates/agency/app/work/[slug]/page.tsx +182 -0
  8. package/templates/agency/app/work/page.tsx +83 -0
  9. package/templates/agency/app.ts +168 -19
  10. package/templates/agency/components/marketing.tsx +39 -0
  11. package/templates/agency/functions/clientsForOwner.ts +27 -0
  12. package/templates/agency/functions/deleteClient.ts +27 -0
  13. package/templates/agency/functions/deleteInvoice.ts +19 -0
  14. package/templates/agency/functions/deleteProject.ts +20 -0
  15. package/templates/agency/functions/invoicesForOwner.ts +27 -0
  16. package/templates/agency/functions/seedProjects.ts +41 -0
  17. package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
  18. package/templates/agency/functions/setInvoiceStatus.ts +27 -0
  19. package/templates/agency/functions/setProjectFlags.ts +35 -0
  20. package/templates/agency/functions/upsertClient.ts +73 -0
  21. package/templates/agency/functions/upsertInvoice.ts +113 -0
  22. package/templates/agency/functions/upsertProject.ts +97 -0
  23. package/templates/agency/lib/agency.ts +165 -3
  24. package/templates/agency/lib/invoice-pdf.tsx +174 -0
  25. package/templates/agency/lib/site.config.ts +180 -1
  26. package/templates/agency/package.json +2 -1
  27. package/templates/ai-chat/app/chat-client.tsx +354 -41
  28. package/templates/ai-chat/functions/deleteConversation.ts +33 -0
  29. package/templates/ai-studio/app/studio-client.tsx +172 -29
  30. package/templates/ai-studio/app.ts +7 -7
  31. package/templates/ai-studio/lib/studio.ts +5 -5
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import React, { useState } from "react";
3
+ import React, { useEffect, useState } from "react";
4
4
  import { db, callFn } from "@pylonsync/react";
5
5
  import { siteConfig } from "@/lib/site.config";
6
6
  import type { GenerationKind, GenerationRow } from "@/lib/studio";
@@ -26,17 +26,19 @@ function StudioInner() {
26
26
  const [kind, setKind] = useState<GenerationKind>("image");
27
27
  const [busy, setBusy] = useState(false);
28
28
  const [error, setError] = useState<string | null>(null);
29
+ const [openId, setOpenId] = useState<string | null>(null);
29
30
 
30
- async function generate() {
31
- const p = prompt.trim();
32
- if (!p || busy) return;
31
+ // Keep the open modal pointed at the live row so it reflects status changes.
32
+ const open = generations.find((g) => g.id === openId) ?? null;
33
+
34
+ async function generate(p = prompt, k = kind) {
35
+ const text = p.trim();
36
+ if (!text || busy) return;
33
37
  setBusy(true);
34
38
  setError(null);
35
39
  setPrompt("");
36
- // Fire the action: the pending card shows up live via useQuery while it runs;
37
- // we only await to surface input errors + re-enable the button.
38
40
  try {
39
- await callFn("generate", { kind, prompt: p });
41
+ await callFn("generate", { kind: k, prompt: text });
40
42
  } catch (e) {
41
43
  const msg = e instanceof Error ? e.message : "Couldn't start the generation.";
42
44
  setError(/INVALID_ARGS/.test(msg) ? "Enter a prompt (up to 1000 characters)." : msg);
@@ -45,6 +47,22 @@ function StudioInner() {
45
47
  }
46
48
  }
47
49
 
50
+ function reuse(g: GenerationRow) {
51
+ setPrompt(g.prompt);
52
+ setKind((["image", "audio", "video"].includes(g.kind) ? g.kind : "image") as GenerationKind);
53
+ setOpenId(null);
54
+ window.scrollTo({ top: 0, behavior: "smooth" });
55
+ }
56
+
57
+ async function remove(id: string) {
58
+ setOpenId(null);
59
+ try {
60
+ await db.delete("Generation", id);
61
+ } catch {
62
+ /* ignore — the row may already be gone */
63
+ }
64
+ }
65
+
48
66
  return (
49
67
  <div className="mx-auto max-w-5xl px-4 py-8">
50
68
  {/* Prompt bar */}
@@ -76,13 +94,12 @@ function StudioInner() {
76
94
  }
77
95
  >
78
96
  {k.label}
79
- {!k.wired ? <span className="ml-1 text-[10px] text-zinc-400">stub</span> : null}
80
97
  </button>
81
98
  ))}
82
99
  </div>
83
100
  <button
84
101
  type="button"
85
- onClick={generate}
102
+ onClick={() => generate()}
86
103
  disabled={busy || !prompt.trim()}
87
104
  className="inline-flex h-9 items-center gap-1.5 rounded-full bg-brand px-5 text-[13.5px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-40"
88
105
  >
@@ -93,7 +110,6 @@ function StudioInner() {
93
110
 
94
111
  {error ? <p className="mt-3 text-[13px] text-red-600">{error}</p> : null}
95
112
 
96
- {/* Examples (only before anything's been made) */}
97
113
  {generations.length === 0 ? (
98
114
  <div className="mt-5 flex flex-wrap gap-2">
99
115
  {studio.examples.map((ex) => (
@@ -109,22 +125,35 @@ function StudioInner() {
109
125
  </div>
110
126
  ) : null}
111
127
 
112
- {/* Gallery */}
113
128
  <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
114
129
  {generations.map((g) => (
115
- <GenerationCard key={g.id} g={g} />
130
+ <GenerationCard key={g.id} g={g} onOpen={() => setOpenId(g.id)} />
116
131
  ))}
117
132
  </div>
133
+
134
+ {open ? (
135
+ <GenerationModal
136
+ g={open}
137
+ onClose={() => setOpenId(null)}
138
+ onReuse={() => reuse(open)}
139
+ onDelete={() => remove(open.id)}
140
+ />
141
+ ) : null}
118
142
  </div>
119
143
  );
120
144
  }
121
145
 
122
- function GenerationCard({ g }: { g: GenerationRow }) {
146
+ function GenerationCard({ g, onOpen }: { g: GenerationRow; onOpen: () => void }) {
147
+ const done = g.status === "done";
123
148
  return (
124
- <div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
149
+ <button
150
+ type="button"
151
+ onClick={onOpen}
152
+ className="group block overflow-hidden rounded-2xl border border-zinc-200 bg-white text-left transition-shadow hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand/30"
153
+ >
125
154
  <div className="relative grid aspect-square place-items-center bg-paper">
126
155
  <Media g={g} />
127
- {g.demo && g.status === "done" ? (
156
+ {g.demo && done ? (
128
157
  <span className="absolute left-2 top-2 rounded-full border border-dashed border-zinc-300 bg-white/80 px-2 py-0.5 text-[10px] font-medium text-zinc-500">
129
158
  demo
130
159
  </span>
@@ -133,37 +162,125 @@ function GenerationCard({ g }: { g: GenerationRow }) {
133
162
  {g.kind}
134
163
  </span>
135
164
  </div>
136
- <div className="p-3">
137
- <p className="line-clamp-2 text-[13px] leading-snug text-zinc-600">{g.prompt}</p>
165
+ <div className="flex items-center justify-between gap-2 p-3">
166
+ <p className="line-clamp-1 text-[13px] text-zinc-600">{g.prompt}</p>
167
+ <span className="shrink-0 text-[11px] text-zinc-400">{relativeTime(g.createdAt)}</span>
168
+ </div>
169
+ </button>
170
+ );
171
+ }
172
+
173
+ function GenerationModal({
174
+ g,
175
+ onClose,
176
+ onReuse,
177
+ onDelete,
178
+ }: {
179
+ g: GenerationRow;
180
+ onClose: () => void;
181
+ onReuse: () => void;
182
+ onDelete: () => void;
183
+ }) {
184
+ const [copied, setCopied] = useState(false);
185
+ // Close on Escape.
186
+ useEffect(() => {
187
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
188
+ document.addEventListener("keydown", onKey);
189
+ return () => document.removeEventListener("keydown", onKey);
190
+ }, [onClose]);
191
+
192
+ async function copyPrompt() {
193
+ try {
194
+ await navigator.clipboard.writeText(g.prompt);
195
+ setCopied(true);
196
+ setTimeout(() => setCopied(false), 1500);
197
+ } catch {
198
+ /* clipboard unavailable */
199
+ }
200
+ }
201
+
202
+ return (
203
+ <div
204
+ className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/50 p-4"
205
+ onClick={onClose}
206
+ >
207
+ <div
208
+ className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl bg-white shadow-2xl"
209
+ onClick={(e) => e.stopPropagation()}
210
+ >
211
+ <div className="grid max-h-[55vh] place-items-center overflow-hidden bg-paper">
212
+ <Media g={g} full />
213
+ </div>
214
+ <div className="flex-1 overflow-y-auto p-5">
215
+ <div className="flex items-center gap-2">
216
+ <span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-zinc-500">
217
+ {g.kind}
218
+ </span>
219
+ <StatusPill status={g.status} demo={g.demo} />
220
+ <span className="text-[12px] text-zinc-400">{relativeTime(g.createdAt)}</span>
221
+ </div>
222
+ <p className="mt-3 text-[14px] leading-relaxed text-zinc-800">{g.prompt}</p>
223
+ {g.error ? <p className="mt-2 text-[13px] text-red-600">{g.error}</p> : null}
224
+
225
+ <div className="mt-5 flex flex-wrap gap-2">
226
+ {g.status === "done" && g.resultUrl ? (
227
+ <a
228
+ href={g.resultUrl}
229
+ download
230
+ target="_blank"
231
+ rel="noopener noreferrer"
232
+ className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-brand px-4 text-[13px] font-medium text-white transition-opacity hover:opacity-90"
233
+ >
234
+ Download
235
+ </a>
236
+ ) : null}
237
+ <button
238
+ type="button"
239
+ onClick={copyPrompt}
240
+ className="inline-flex h-9 items-center rounded-lg border border-zinc-300 px-4 text-[13px] font-medium text-zinc-700 transition-colors hover:bg-zinc-50"
241
+ >
242
+ {copied ? "Copied ✓" : "Copy prompt"}
243
+ </button>
244
+ <button
245
+ type="button"
246
+ onClick={onReuse}
247
+ className="inline-flex h-9 items-center rounded-lg border border-zinc-300 px-4 text-[13px] font-medium text-zinc-700 transition-colors hover:bg-zinc-50"
248
+ >
249
+ Reuse prompt
250
+ </button>
251
+ <button
252
+ type="button"
253
+ onClick={onDelete}
254
+ className="ml-auto inline-flex h-9 items-center rounded-lg border border-zinc-200 px-4 text-[13px] font-medium text-zinc-500 transition-colors hover:border-red-300 hover:text-red-600"
255
+ >
256
+ Delete
257
+ </button>
258
+ </div>
259
+ </div>
138
260
  </div>
139
261
  </div>
140
262
  );
141
263
  }
142
264
 
143
- function Media({ g }: { g: GenerationRow }) {
265
+ function Media({ g, full }: { g: GenerationRow; full?: boolean }) {
144
266
  if (g.status === "pending" || g.status === "processing") {
145
267
  return (
146
- <div className="flex flex-col items-center gap-2 text-zinc-400">
268
+ <div className="flex flex-col items-center gap-2 py-10 text-zinc-400">
147
269
  <Spinner />
148
270
  <span className="text-[12px]">{g.status === "processing" ? "Generating…" : "Queued…"}</span>
149
271
  </div>
150
272
  );
151
273
  }
152
274
  if (g.status === "failed") {
153
- return (
154
- <div className="px-5 text-center text-[12px] text-red-500">
155
- {g.error || "Generation failed."}
156
- </div>
157
- );
275
+ return <div className="px-5 py-10 text-center text-[12px] text-red-500">{g.error || "Generation failed."}</div>;
158
276
  }
159
- // done
160
277
  if (g.kind === "image" && g.resultUrl) {
161
278
  // eslint-disable-next-line @next/next/no-img-element
162
- return <img src={g.resultUrl} alt={g.prompt} className="size-full object-cover" />;
279
+ return <img src={g.resultUrl} alt={g.prompt} className={full ? "max-h-[55vh] w-auto object-contain" : "size-full object-cover"} />;
163
280
  }
164
281
  if (g.kind === "audio") {
165
282
  return g.resultUrl ? (
166
- <div className="w-full px-4">
283
+ <div className="w-full px-5 py-8">
167
284
  <AudioWave />
168
285
  <audio controls src={g.resultUrl} className="mt-3 w-full" />
169
286
  </div>
@@ -173,7 +290,7 @@ function Media({ g }: { g: GenerationRow }) {
173
290
  }
174
291
  if (g.kind === "video") {
175
292
  return g.resultUrl ? (
176
- <video controls src={g.resultUrl} className="size-full object-cover" />
293
+ <video controls src={g.resultUrl} className={full ? "max-h-[55vh] w-auto" : "size-full object-cover"} />
177
294
  ) : (
178
295
  <DemoNote text="Add REPLICATE_API_TOKEN to generate real video." />
179
296
  );
@@ -181,9 +298,23 @@ function Media({ g }: { g: GenerationRow }) {
181
298
  return <DemoNote text="No result." />;
182
299
  }
183
300
 
301
+ function StatusPill({ status, demo }: { status: string; demo: boolean }) {
302
+ if (status === "done") {
303
+ return demo ? (
304
+ <span className="rounded-full border border-dashed border-zinc-300 px-2 py-0.5 text-[10px] font-medium text-zinc-500">demo</span>
305
+ ) : (
306
+ <span className="rounded-full bg-green-50 px-2 py-0.5 text-[10px] font-medium text-green-700">done</span>
307
+ );
308
+ }
309
+ if (status === "failed") {
310
+ return <span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-medium text-red-600">failed</span>;
311
+ }
312
+ return <span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-medium capitalize text-amber-700">{status}</span>;
313
+ }
314
+
184
315
  function DemoNote({ text }: { text: string }) {
185
316
  return (
186
- <div className="flex flex-col items-center gap-2 px-5 text-center text-zinc-400">
317
+ <div className="flex flex-col items-center gap-2 px-5 py-10 text-center text-zinc-400">
187
318
  <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
188
319
  <rect x="3" y="3" width="18" height="18" rx="2" />
189
320
  <path d="M3 9h18M9 21V9" />
@@ -212,3 +343,15 @@ function Spinner() {
212
343
  );
213
344
  }
214
345
 
346
+ // "just now" / "5m ago" / "3h ago" / "2d ago" from an ISO timestamp.
347
+ function relativeTime(iso: string): string {
348
+ const t = new Date(iso).getTime();
349
+ if (Number.isNaN(t)) return "";
350
+ const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
351
+ if (s < 45) return "just now";
352
+ const m = Math.floor(s / 60);
353
+ if (m < 60) return `${m}m ago`;
354
+ const h = Math.floor(m / 60);
355
+ if (h < 24) return `${h}h ago`;
356
+ return `${Math.floor(h / 24)}d ago`;
357
+ }
@@ -20,10 +20,10 @@ import {
20
20
  // can't insert), and read live via `db.useQuery`.
21
21
  // • User — the account (email/password is built in).
22
22
  //
23
- // Multi-user: every signed-in (or guest) visitor gets their own private studio.
24
- // Image + audio call OpenAI when OPENAI_API_KEY is set; with no key the studio
25
- // returns a clearly-labeled placeholder so the whole flow + live gallery work
26
- // with zero config. Video is a stubbed extension point see functions/generate.ts.
23
+ // Multi-user: every signed-in visitor gets their own private studio. Image,
24
+ // audio, AND video generate via Replicate when REPLICATE_API_TOKEN is set; with
25
+ // no token the studio returns a clearly-labeled placeholder so the whole flow +
26
+ // background job + live gallery work with zero config (see functions/).
27
27
  // ---------------------------------------------------------------------------
28
28
 
29
29
  const Generation = entity(
@@ -76,9 +76,9 @@ const generationPolicy = policy({
76
76
  name: "generation_owner_read",
77
77
  entity: "Generation",
78
78
  allowRead: "auth.userId == data.userId",
79
- allowInsert: "false",
80
- allowUpdate: "false",
81
- allowDelete: "false",
79
+ allowInsert: "false", // created only by the generate pipeline (server-side)
80
+ allowUpdate: "false", // updated only by the pollGeneration job
81
+ allowDelete: "auth.userId == data.userId", // you can delete your own from the gallery
82
82
  });
83
83
 
84
84
  const userPolicy = policy({
@@ -31,10 +31,10 @@ function escapeXml(s: string): string {
31
31
  .replace(/'/g, "&apos;");
32
32
  }
33
33
 
34
- // A self-contained SVG "image" data URL — used when no OPENAI_API_KEY is set, so
35
- // the gallery + realtime flow work with zero config. Clearly a placeholder (it
36
- // renders the prompt over a gradient), not a fake photo. Returns a `data:` URL
37
- // that drops straight into an <img>.
34
+ // A self-contained SVG "image" data URL — used when no REPLICATE_API_TOKEN is
35
+ // set, so the gallery + realtime flow work with zero config. Clearly a
36
+ // placeholder (it renders the prompt over a gradient), not a fake photo. Returns
37
+ // a `data:` URL that drops straight into an <img>.
38
38
  export function placeholderImage(prompt: string): string {
39
39
  const hue = hashHue(prompt);
40
40
  const hue2 = (hue + 60) % 360;
@@ -46,7 +46,7 @@ export function placeholderImage(prompt: string): string {
46
46
  </linearGradient></defs>
47
47
  <rect width="640" height="640" fill="url(#g)"/>
48
48
  <text x="50%" y="44%" fill="rgba(255,255,255,0.95)" font-family="system-ui,sans-serif" font-size="26" font-weight="600" text-anchor="middle">${text}</text>
49
- <text x="50%" y="54%" fill="rgba(255,255,255,0.7)" font-family="system-ui,sans-serif" font-size="15" text-anchor="middle">placeholder · set OPENAI_API_KEY for real images</text>
49
+ <text x="50%" y="54%" fill="rgba(255,255,255,0.7)" font-family="system-ui,sans-serif" font-size="15" text-anchor="middle">placeholder · set REPLICATE_API_TOKEN for real images</text>
50
50
  </svg>`;
51
51
  return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
52
52
  }