@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.
- package/bin/create-pylon.js +1 -1
- package/package.json +1 -1
- package/templates/agency/app/dashboard/dashboard-client.tsx +1213 -59
- package/templates/agency/app/layout.tsx +1 -1
- package/templates/agency/app/page.tsx +72 -30
- package/templates/agency/app/seeder.tsx +26 -0
- package/templates/agency/app/work/[slug]/page.tsx +182 -0
- package/templates/agency/app/work/page.tsx +83 -0
- package/templates/agency/app.ts +168 -19
- package/templates/agency/components/marketing.tsx +39 -0
- package/templates/agency/functions/clientsForOwner.ts +27 -0
- package/templates/agency/functions/deleteClient.ts +27 -0
- package/templates/agency/functions/deleteInvoice.ts +19 -0
- package/templates/agency/functions/deleteProject.ts +20 -0
- package/templates/agency/functions/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -0
- package/templates/agency/functions/upsertClient.ts +73 -0
- package/templates/agency/functions/upsertInvoice.ts +113 -0
- package/templates/agency/functions/upsertProject.ts +97 -0
- package/templates/agency/lib/agency.ts +165 -3
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/site.config.ts +180 -1
- package/templates/agency/package.json +2 -1
- package/templates/ai-chat/app/chat-client.tsx +354 -41
- package/templates/ai-chat/functions/deleteConversation.ts +33 -0
- package/templates/ai-studio/app/studio-client.tsx +172 -29
- package/templates/ai-studio/app.ts +7 -7
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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:
|
|
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
|
-
<
|
|
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 &&
|
|
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-
|
|
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-
|
|
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
|
|
24
|
-
//
|
|
25
|
-
// returns a clearly-labeled placeholder so the whole flow +
|
|
26
|
-
//
|
|
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: "
|
|
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, "'");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
// A self-contained SVG "image" data URL — used when no
|
|
35
|
-
// the gallery + realtime flow work with zero config. Clearly a
|
|
36
|
-
// renders the prompt over a gradient), not a fake photo. Returns
|
|
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
|
|
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
|
}
|