@pylonsync/create-pylon 0.3.274 → 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 +80 -0
- package/package.json +1 -1
- package/templates/ARCHETYPES.md +339 -0
- package/templates/agency/.env.example +12 -0
- package/templates/agency/AGENTS.md +61 -0
- package/templates/agency/README.md +90 -0
- package/templates/agency/app/auth-form.tsx +129 -0
- package/templates/agency/app/contact-form.tsx +258 -0
- package/templates/agency/app/dashboard/dashboard-client.tsx +1440 -0
- package/templates/agency/app/dashboard/page.tsx +70 -0
- package/templates/agency/app/error.tsx +26 -0
- package/templates/agency/app/globals.css +148 -0
- package/templates/agency/app/layout.tsx +174 -0
- package/templates/agency/app/login/page.tsx +39 -0
- package/templates/agency/app/not-found.tsx +19 -0
- package/templates/agency/app/page.tsx +249 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/seeder.tsx +26 -0
- package/templates/agency/app/sitemap.ts +9 -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 +284 -0
- package/templates/agency/components/marketing.tsx +187 -0
- package/templates/agency/components/section-scroller.tsx +35 -0
- package/templates/agency/components/ui/button.tsx +56 -0
- package/templates/agency/components/ui/card.tsx +90 -0
- package/templates/agency/components.json +20 -0
- package/templates/agency/functions/bookInquiry.ts +42 -0
- package/templates/agency/functions/clientsForOwner.ts +27 -0
- package/templates/agency/functions/declineInquiry.ts +41 -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/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -0
- package/templates/agency/functions/submitInquiry.ts +55 -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/gitignore +10 -0
- package/templates/agency/lib/agency.ts +189 -0
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +418 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +35 -0
- package/templates/agency/tsconfig.json +18 -0
- package/templates/ai-chat/.env.example +33 -0
- package/templates/ai-chat/AGENTS.md +61 -0
- package/templates/ai-chat/README.md +99 -0
- package/templates/ai-chat/app/auth-form.tsx +124 -0
- package/templates/ai-chat/app/chat-client.tsx +727 -0
- package/templates/ai-chat/app/error.tsx +26 -0
- package/templates/ai-chat/app/globals.css +148 -0
- package/templates/ai-chat/app/layout.tsx +75 -0
- package/templates/ai-chat/app/login/page.tsx +39 -0
- package/templates/ai-chat/app/not-found.tsx +19 -0
- package/templates/ai-chat/app/page.tsx +23 -0
- package/templates/ai-chat/app.ts +121 -0
- package/templates/ai-chat/components.json +20 -0
- package/templates/ai-chat/functions/deleteConversation.ts +33 -0
- package/templates/ai-chat/gitignore +10 -0
- package/templates/ai-chat/lib/site.config.ts +103 -0
- package/templates/ai-chat/lib/utils.ts +10 -0
- package/templates/ai-chat/package.json +34 -0
- package/templates/ai-chat/tsconfig.json +18 -0
- package/templates/ai-studio/.env.example +19 -0
- package/templates/ai-studio/AGENTS.md +61 -0
- package/templates/ai-studio/README.md +83 -0
- package/templates/ai-studio/app/auth-form.tsx +124 -0
- package/templates/ai-studio/app/error.tsx +26 -0
- package/templates/ai-studio/app/globals.css +148 -0
- package/templates/ai-studio/app/layout.tsx +75 -0
- package/templates/ai-studio/app/login/page.tsx +39 -0
- package/templates/ai-studio/app/not-found.tsx +19 -0
- package/templates/ai-studio/app/page.tsx +34 -0
- package/templates/ai-studio/app/studio-client.tsx +357 -0
- package/templates/ai-studio/app.ts +108 -0
- package/templates/ai-studio/components.json +20 -0
- package/templates/ai-studio/functions/_getGeneration.ts +25 -0
- package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
- package/templates/ai-studio/functions/generate.ts +42 -0
- package/templates/ai-studio/functions/pollGeneration.ts +134 -0
- package/templates/ai-studio/gitignore +10 -0
- package/templates/ai-studio/lib/site.config.ts +80 -0
- package/templates/ai-studio/lib/studio.ts +52 -0
- package/templates/ai-studio/lib/utils.ts +10 -0
- package/templates/ai-studio/package.json +34 -0
- package/templates/ai-studio/tsconfig.json +18 -0
- package/templates/creator/.env.example +12 -0
- package/templates/creator/AGENTS.md +61 -0
- package/templates/creator/README.md +67 -0
- package/templates/creator/app/auth-form.tsx +129 -0
- package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/creator/app/dashboard/page.tsx +70 -0
- package/templates/creator/app/error.tsx +26 -0
- package/templates/creator/app/globals.css +148 -0
- package/templates/creator/app/layout.tsx +160 -0
- package/templates/creator/app/login/page.tsx +39 -0
- package/templates/creator/app/newsletter-signup.tsx +162 -0
- package/templates/creator/app/not-found.tsx +19 -0
- package/templates/creator/app/page.tsx +160 -0
- package/templates/creator/app/robots.ts +12 -0
- package/templates/creator/app/sitemap.ts +9 -0
- package/templates/creator/app.ts +134 -0
- package/templates/creator/components/marketing.tsx +148 -0
- package/templates/creator/components/section-scroller.tsx +35 -0
- package/templates/creator/components/ui/button.tsx +56 -0
- package/templates/creator/components/ui/card.tsx +90 -0
- package/templates/creator/components.json +20 -0
- package/templates/creator/functions/subscribe.ts +82 -0
- package/templates/creator/functions/subscriberStats.ts +75 -0
- package/templates/creator/gitignore +10 -0
- package/templates/creator/lib/owner.ts +26 -0
- package/templates/creator/lib/site.config.ts +173 -0
- package/templates/creator/lib/stats.ts +30 -0
- package/templates/creator/lib/utils.ts +10 -0
- package/templates/creator/package.json +34 -0
- package/templates/creator/tsconfig.json +18 -0
- package/templates/default/app/layout.tsx +26 -27
- package/templates/default/app/page.tsx +90 -274
- package/templates/default/lib/products.ts +9 -122
- package/templates/default/lib/site.config.ts +739 -0
- package/templates/default/lib/site.ts +14 -261
- package/templates/directory/.env.example +12 -0
- package/templates/directory/AGENTS.md +61 -0
- package/templates/directory/README.md +80 -0
- package/templates/directory/app/auth-form.tsx +129 -0
- package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
- package/templates/directory/app/dashboard/page.tsx +70 -0
- package/templates/directory/app/directory-browse.tsx +328 -0
- package/templates/directory/app/error.tsx +26 -0
- package/templates/directory/app/globals.css +148 -0
- package/templates/directory/app/layout.tsx +171 -0
- package/templates/directory/app/login/page.tsx +39 -0
- package/templates/directory/app/not-found.tsx +19 -0
- package/templates/directory/app/page.tsx +50 -0
- package/templates/directory/app/robots.ts +12 -0
- package/templates/directory/app/sitemap.ts +9 -0
- package/templates/directory/app/submit/page.tsx +30 -0
- package/templates/directory/app/submit-form.tsx +151 -0
- package/templates/directory/app.ts +146 -0
- package/templates/directory/components/marketing.tsx +148 -0
- package/templates/directory/components/section-scroller.tsx +35 -0
- package/templates/directory/components/ui/button.tsx +56 -0
- package/templates/directory/components/ui/card.tsx +90 -0
- package/templates/directory/components.json +20 -0
- package/templates/directory/functions/approveSubmission.ts +45 -0
- package/templates/directory/functions/rejectSubmission.ts +20 -0
- package/templates/directory/functions/seedListings.ts +33 -0
- package/templates/directory/functions/submissionsForOwner.ts +29 -0
- package/templates/directory/functions/submitListing.ts +63 -0
- package/templates/directory/functions/upvote.ts +24 -0
- package/templates/directory/gitignore +10 -0
- package/templates/directory/lib/directory.ts +45 -0
- package/templates/directory/lib/owner.ts +26 -0
- package/templates/directory/lib/site.config.ts +130 -0
- package/templates/directory/lib/utils.ts +10 -0
- package/templates/directory/package.json +34 -0
- package/templates/directory/tsconfig.json +18 -0
- package/templates/local-service/.env.example +12 -0
- package/templates/local-service/AGENTS.md +61 -0
- package/templates/local-service/README.md +82 -0
- package/templates/local-service/app/auth-form.tsx +129 -0
- package/templates/local-service/app/booking-widget.tsx +399 -0
- package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
- package/templates/local-service/app/dashboard/page.tsx +63 -0
- package/templates/local-service/app/error.tsx +26 -0
- package/templates/local-service/app/globals.css +148 -0
- package/templates/local-service/app/layout.tsx +151 -0
- package/templates/local-service/app/login/page.tsx +39 -0
- package/templates/local-service/app/not-found.tsx +19 -0
- package/templates/local-service/app/page.tsx +233 -0
- package/templates/local-service/app/robots.ts +12 -0
- package/templates/local-service/app/sitemap.ts +9 -0
- package/templates/local-service/app.ts +131 -0
- package/templates/local-service/components/marketing.tsx +162 -0
- package/templates/local-service/components/section-scroller.tsx +35 -0
- package/templates/local-service/components/ui/button.tsx +56 -0
- package/templates/local-service/components/ui/card.tsx +90 -0
- package/templates/local-service/components.json +20 -0
- package/templates/local-service/functions/bookingsForOwner.ts +30 -0
- package/templates/local-service/functions/cancelBooking.ts +27 -0
- package/templates/local-service/functions/confirmBooking.ts +18 -0
- package/templates/local-service/functions/createBooking.ts +98 -0
- package/templates/local-service/gitignore +10 -0
- package/templates/local-service/lib/booking.ts +24 -0
- package/templates/local-service/lib/owner.ts +26 -0
- package/templates/local-service/lib/site.config.ts +232 -0
- package/templates/local-service/lib/slots.ts +97 -0
- package/templates/local-service/lib/utils.ts +10 -0
- package/templates/local-service/package.json +34 -0
- package/templates/local-service/tsconfig.json +18 -0
- package/templates/marketplace/.env.example +9 -0
- package/templates/marketplace/AGENTS.md +61 -0
- package/templates/marketplace/README.md +78 -0
- package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
- package/templates/marketplace/app/error.tsx +26 -0
- package/templates/marketplace/app/globals.css +64 -0
- package/templates/marketplace/app/layout.tsx +60 -0
- package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
- package/templates/marketplace/app/me/page.tsx +15 -0
- package/templates/marketplace/app/not-found.tsx +20 -0
- package/templates/marketplace/app/page.tsx +159 -0
- package/templates/marketplace/app/robots.ts +12 -0
- package/templates/marketplace/app/sell/page.tsx +26 -0
- package/templates/marketplace/app/sitemap.ts +14 -0
- package/templates/marketplace/app.ts +190 -0
- package/templates/marketplace/client/AuthNav.tsx +46 -0
- package/templates/marketplace/client/LiveTicker.tsx +104 -0
- package/templates/marketplace/client/LoginCard.tsx +130 -0
- package/templates/marketplace/client/MarketProvider.tsx +148 -0
- package/templates/marketplace/client/MyMarket.tsx +180 -0
- package/templates/marketplace/client/OfferPanel.tsx +355 -0
- package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
- package/templates/marketplace/client/SellForm.tsx +160 -0
- package/templates/marketplace/client/WatchButton.tsx +88 -0
- package/templates/marketplace/client/market.ts +341 -0
- package/templates/marketplace/functions/buyNow.ts +78 -0
- package/templates/marketplace/functions/makeOffer.ts +65 -0
- package/templates/marketplace/functions/respondToOffer.ts +62 -0
- package/templates/marketplace/functions/seedMarket.ts +90 -0
- package/templates/marketplace/gitignore +10 -0
- package/templates/marketplace/package.json +35 -0
- package/templates/marketplace/tsconfig.json +14 -0
- package/templates/marketplace/ui/badge.tsx +30 -0
- package/templates/marketplace/ui/button.tsx +49 -0
- package/templates/marketplace/ui/card.tsx +48 -0
- package/templates/marketplace/ui/input.tsx +17 -0
- package/templates/marketplace/ui/label.tsx +18 -0
- package/templates/marketplace/ui/textarea.tsx +17 -0
- package/templates/marketplace/ui/tokens.css +32 -0
- package/templates/marketplace/ui/utils.ts +6 -0
- package/templates/restaurant/.env.example +12 -0
- package/templates/restaurant/AGENTS.md +61 -0
- package/templates/restaurant/README.md +77 -0
- package/templates/restaurant/app/auth-form.tsx +129 -0
- package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
- package/templates/restaurant/app/dashboard/page.tsx +59 -0
- package/templates/restaurant/app/error.tsx +26 -0
- package/templates/restaurant/app/globals.css +148 -0
- package/templates/restaurant/app/layout.tsx +151 -0
- package/templates/restaurant/app/login/page.tsx +39 -0
- package/templates/restaurant/app/not-found.tsx +19 -0
- package/templates/restaurant/app/page.tsx +194 -0
- package/templates/restaurant/app/reservation-widget.tsx +359 -0
- package/templates/restaurant/app/robots.ts +12 -0
- package/templates/restaurant/app/sitemap.ts +9 -0
- package/templates/restaurant/app.ts +115 -0
- package/templates/restaurant/components/marketing.tsx +162 -0
- package/templates/restaurant/components/section-scroller.tsx +35 -0
- package/templates/restaurant/components/ui/button.tsx +56 -0
- package/templates/restaurant/components/ui/card.tsx +90 -0
- package/templates/restaurant/components.json +20 -0
- package/templates/restaurant/functions/cancelReservation.ts +26 -0
- package/templates/restaurant/functions/confirmReservation.ts +17 -0
- package/templates/restaurant/functions/createReservation.ts +92 -0
- package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
- package/templates/restaurant/gitignore +10 -0
- package/templates/restaurant/lib/owner.ts +26 -0
- package/templates/restaurant/lib/reservation.ts +22 -0
- package/templates/restaurant/lib/site.config.ts +218 -0
- package/templates/restaurant/lib/slots.ts +55 -0
- package/templates/restaurant/lib/utils.ts +10 -0
- package/templates/restaurant/package.json +34 -0
- package/templates/restaurant/tsconfig.json +18 -0
- package/templates/shop/.env.example +32 -0
- package/templates/shop/AGENTS.md +61 -0
- package/templates/shop/README.md +102 -0
- package/templates/shop/app/auth-form.tsx +129 -0
- package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
- package/templates/shop/app/dashboard/page.tsx +59 -0
- package/templates/shop/app/error.tsx +26 -0
- package/templates/shop/app/globals.css +148 -0
- package/templates/shop/app/layout.tsx +160 -0
- package/templates/shop/app/login/page.tsx +39 -0
- package/templates/shop/app/not-found.tsx +19 -0
- package/templates/shop/app/page.tsx +95 -0
- package/templates/shop/app/robots.ts +12 -0
- package/templates/shop/app/shop-client.tsx +436 -0
- package/templates/shop/app/sitemap.ts +9 -0
- package/templates/shop/app/success/page.tsx +33 -0
- package/templates/shop/app.ts +134 -0
- package/templates/shop/components/marketing.tsx +96 -0
- package/templates/shop/components/section-scroller.tsx +35 -0
- package/templates/shop/components/ui/button.tsx +56 -0
- package/templates/shop/components/ui/card.tsx +90 -0
- package/templates/shop/components.json +20 -0
- package/templates/shop/functions/cancelOrder.ts +33 -0
- package/templates/shop/functions/checkout.ts +130 -0
- package/templates/shop/functions/fulfillOrder.ts +17 -0
- package/templates/shop/functions/markGroupPaid.ts +26 -0
- package/templates/shop/functions/ordersForOwner.ts +28 -0
- package/templates/shop/functions/releaseGroup.ts +36 -0
- package/templates/shop/functions/reserveCart.ts +87 -0
- package/templates/shop/functions/restockProduct.ts +23 -0
- package/templates/shop/functions/seedProducts.ts +30 -0
- package/templates/shop/functions/stripeWebhook.ts +72 -0
- package/templates/shop/gitignore +10 -0
- package/templates/shop/lib/owner.ts +26 -0
- package/templates/shop/lib/shop.ts +45 -0
- package/templates/shop/lib/site.config.ts +198 -0
- package/templates/shop/lib/utils.ts +10 -0
- package/templates/shop/package.json +35 -0
- package/templates/shop/tsconfig.json +18 -0
- package/templates/waitlist/.env.example +12 -0
- package/templates/waitlist/AGENTS.md +61 -0
- package/templates/waitlist/README.md +81 -0
- package/templates/waitlist/app/auth-form.tsx +129 -0
- package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/waitlist/app/dashboard/page.tsx +70 -0
- package/templates/waitlist/app/error.tsx +26 -0
- package/templates/waitlist/app/globals.css +148 -0
- package/templates/waitlist/app/layout.tsx +158 -0
- package/templates/waitlist/app/login/page.tsx +39 -0
- package/templates/waitlist/app/not-found.tsx +19 -0
- package/templates/waitlist/app/page.tsx +119 -0
- package/templates/waitlist/app/robots.ts +12 -0
- package/templates/waitlist/app/sitemap.ts +9 -0
- package/templates/waitlist/app/waitlist-hero.tsx +219 -0
- package/templates/waitlist/app.ts +134 -0
- package/templates/waitlist/components/marketing.tsx +96 -0
- package/templates/waitlist/components/ui/button.tsx +56 -0
- package/templates/waitlist/components/ui/card.tsx +90 -0
- package/templates/waitlist/components.json +20 -0
- package/templates/waitlist/functions/joinWaitlist.ts +82 -0
- package/templates/waitlist/functions/waitlistStats.ts +75 -0
- package/templates/waitlist/gitignore +10 -0
- package/templates/waitlist/lib/owner.ts +26 -0
- package/templates/waitlist/lib/site.config.ts +178 -0
- package/templates/waitlist/lib/stats.ts +30 -0
- package/templates/waitlist/lib/utils.ts +10 -0
- package/templates/waitlist/package.json +34 -0
- package/templates/waitlist/tsconfig.json +18 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
|
+
import { siteConfig } from "@/lib/site.config";
|
|
6
|
+
import type { GenerationKind, GenerationRow } from "@/lib/studio";
|
|
7
|
+
|
|
8
|
+
// The studio — a client island, rendered only for a SIGNED-IN user (the page
|
|
9
|
+
// redirects anyone else to /login). `Generation` is an owner-scoped entity read
|
|
10
|
+
// with `db.useQuery`, so your gallery is private to your account and updates
|
|
11
|
+
// live: the generate mutation inserts a "pending" row (it appears instantly), a
|
|
12
|
+
// background job runs the provider call, then flips the row to the finished
|
|
13
|
+
// result — and that change syncs to every open tab. The API token stays on the
|
|
14
|
+
// server.
|
|
15
|
+
|
|
16
|
+
export function Studio() {
|
|
17
|
+
return <StudioInner />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function StudioInner() {
|
|
21
|
+
const { studio } = siteConfig;
|
|
22
|
+
const { data: generations } = db.useQuery<GenerationRow>("Generation", {
|
|
23
|
+
orderBy: { createdAt: "desc" },
|
|
24
|
+
});
|
|
25
|
+
const [prompt, setPrompt] = useState("");
|
|
26
|
+
const [kind, setKind] = useState<GenerationKind>("image");
|
|
27
|
+
const [busy, setBusy] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
30
|
+
|
|
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;
|
|
37
|
+
setBusy(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
setPrompt("");
|
|
40
|
+
try {
|
|
41
|
+
await callFn("generate", { kind: k, prompt: text });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const msg = e instanceof Error ? e.message : "Couldn't start the generation.";
|
|
44
|
+
setError(/INVALID_ARGS/.test(msg) ? "Enter a prompt (up to 1000 characters)." : msg);
|
|
45
|
+
} finally {
|
|
46
|
+
setBusy(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
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
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="mx-auto max-w-5xl px-4 py-8">
|
|
68
|
+
{/* Prompt bar */}
|
|
69
|
+
<div className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm">
|
|
70
|
+
<textarea
|
|
71
|
+
value={prompt}
|
|
72
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
73
|
+
onKeyDown={(e) => {
|
|
74
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
generate();
|
|
77
|
+
}
|
|
78
|
+
}}
|
|
79
|
+
rows={2}
|
|
80
|
+
placeholder={studio.inputPlaceholder}
|
|
81
|
+
aria-label="Prompt"
|
|
82
|
+
className="w-full resize-none bg-transparent text-[15px] text-zinc-900 outline-none placeholder:text-zinc-400"
|
|
83
|
+
/>
|
|
84
|
+
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
|
|
85
|
+
<div className="flex items-center gap-1 rounded-full bg-zinc-100 p-1">
|
|
86
|
+
{studio.kinds.map((k) => (
|
|
87
|
+
<button
|
|
88
|
+
key={k.id}
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={() => setKind(k.id)}
|
|
91
|
+
className={
|
|
92
|
+
"rounded-full px-3 py-1.5 text-[13px] font-medium transition-colors " +
|
|
93
|
+
(kind === k.id ? "bg-white text-zinc-900 shadow-sm" : "text-zinc-500 hover:text-zinc-800")
|
|
94
|
+
}
|
|
95
|
+
>
|
|
96
|
+
{k.label}
|
|
97
|
+
</button>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() => generate()}
|
|
103
|
+
disabled={busy || !prompt.trim()}
|
|
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"
|
|
105
|
+
>
|
|
106
|
+
{busy ? "Generating…" : "Generate"}
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{error ? <p className="mt-3 text-[13px] text-red-600">{error}</p> : null}
|
|
112
|
+
|
|
113
|
+
{generations.length === 0 ? (
|
|
114
|
+
<div className="mt-5 flex flex-wrap gap-2">
|
|
115
|
+
{studio.examples.map((ex) => (
|
|
116
|
+
<button
|
|
117
|
+
key={ex}
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => setPrompt(ex)}
|
|
120
|
+
className="rounded-full border border-zinc-200 bg-white px-3 py-1.5 text-[12.5px] text-zinc-500 transition-colors hover:border-brand hover:text-zinc-800"
|
|
121
|
+
>
|
|
122
|
+
{ex}
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
) : null}
|
|
127
|
+
|
|
128
|
+
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
129
|
+
{generations.map((g) => (
|
|
130
|
+
<GenerationCard key={g.id} g={g} onOpen={() => setOpenId(g.id)} />
|
|
131
|
+
))}
|
|
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}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function GenerationCard({ g, onOpen }: { g: GenerationRow; onOpen: () => void }) {
|
|
147
|
+
const done = g.status === "done";
|
|
148
|
+
return (
|
|
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
|
+
>
|
|
154
|
+
<div className="relative grid aspect-square place-items-center bg-paper">
|
|
155
|
+
<Media g={g} />
|
|
156
|
+
{g.demo && done ? (
|
|
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">
|
|
158
|
+
demo
|
|
159
|
+
</span>
|
|
160
|
+
) : null}
|
|
161
|
+
<span className="absolute right-2 top-2 rounded-full bg-white/85 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-500 backdrop-blur">
|
|
162
|
+
{g.kind}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
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>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function Media({ g, full }: { g: GenerationRow; full?: boolean }) {
|
|
266
|
+
if (g.status === "pending" || g.status === "processing") {
|
|
267
|
+
return (
|
|
268
|
+
<div className="flex flex-col items-center gap-2 py-10 text-zinc-400">
|
|
269
|
+
<Spinner />
|
|
270
|
+
<span className="text-[12px]">{g.status === "processing" ? "Generating…" : "Queued…"}</span>
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (g.status === "failed") {
|
|
275
|
+
return <div className="px-5 py-10 text-center text-[12px] text-red-500">{g.error || "Generation failed."}</div>;
|
|
276
|
+
}
|
|
277
|
+
if (g.kind === "image" && g.resultUrl) {
|
|
278
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
279
|
+
return <img src={g.resultUrl} alt={g.prompt} className={full ? "max-h-[55vh] w-auto object-contain" : "size-full object-cover"} />;
|
|
280
|
+
}
|
|
281
|
+
if (g.kind === "audio") {
|
|
282
|
+
return g.resultUrl ? (
|
|
283
|
+
<div className="w-full px-5 py-8">
|
|
284
|
+
<AudioWave />
|
|
285
|
+
<audio controls src={g.resultUrl} className="mt-3 w-full" />
|
|
286
|
+
</div>
|
|
287
|
+
) : (
|
|
288
|
+
<DemoNote text="Add REPLICATE_API_TOKEN to generate real audio." />
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (g.kind === "video") {
|
|
292
|
+
return g.resultUrl ? (
|
|
293
|
+
<video controls src={g.resultUrl} className={full ? "max-h-[55vh] w-auto" : "size-full object-cover"} />
|
|
294
|
+
) : (
|
|
295
|
+
<DemoNote text="Add REPLICATE_API_TOKEN to generate real video." />
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return <DemoNote text="No result." />;
|
|
299
|
+
}
|
|
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
|
+
|
|
315
|
+
function DemoNote({ text }: { text: string }) {
|
|
316
|
+
return (
|
|
317
|
+
<div className="flex flex-col items-center gap-2 px-5 py-10 text-center text-zinc-400">
|
|
318
|
+
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
|
319
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
320
|
+
<path d="M3 9h18M9 21V9" />
|
|
321
|
+
</svg>
|
|
322
|
+
<span className="text-[12px] leading-snug">{text}</span>
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function AudioWave() {
|
|
328
|
+
return (
|
|
329
|
+
<div className="flex items-end justify-center gap-1">
|
|
330
|
+
{[10, 22, 14, 28, 18, 26, 12].map((h, i) => (
|
|
331
|
+
<span key={i} className="w-1.5 rounded-full bg-brand/60" style={{ height: h }} />
|
|
332
|
+
))}
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function Spinner() {
|
|
338
|
+
return (
|
|
339
|
+
<svg className="size-6 animate-spin text-brand" viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
340
|
+
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" opacity="0.2" />
|
|
341
|
+
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
|
342
|
+
</svg>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
policy,
|
|
5
|
+
auth,
|
|
6
|
+
buildManifest,
|
|
7
|
+
discoverAppRoutes,
|
|
8
|
+
} from "@pylonsync/sdk";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// ai-studio — a generative media studio (image / audio / video). The realtime
|
|
12
|
+
// hook is the generation gallery: kick off a generation and a "generating…"
|
|
13
|
+
// card appears instantly, then flips to the finished result the moment the
|
|
14
|
+
// server-side `generate` action resolves — live, across every open tab. The
|
|
15
|
+
// provider call (and your API key) stays on the server.
|
|
16
|
+
//
|
|
17
|
+
// Two entities:
|
|
18
|
+
// • Generation — one generation request + its result. Owner-scoped: you only
|
|
19
|
+
// see your own. Written ONLY by the generate action (clients
|
|
20
|
+
// can't insert), and read live via `db.useQuery`.
|
|
21
|
+
// • User — the account (email/password is built in).
|
|
22
|
+
//
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const Generation = entity(
|
|
30
|
+
"Generation",
|
|
31
|
+
{
|
|
32
|
+
// Stamped from the session inside the generate action (server-side), not by
|
|
33
|
+
// the client — so it's an owner-scoped READ, with no client writes at all.
|
|
34
|
+
userId: field.string(),
|
|
35
|
+
kind: field.string(), // "image" | "audio" | "video"
|
|
36
|
+
prompt: field.string(),
|
|
37
|
+
status: field.string().default("pending"), // "pending" | "processing" | "done" | "failed"
|
|
38
|
+
// The result: an image/audio/video URL (or data: URL), ready to drop into
|
|
39
|
+
// an <img>/<audio>/<video>.
|
|
40
|
+
resultUrl: field.string().optional(),
|
|
41
|
+
error: field.string().optional(),
|
|
42
|
+
// The Replicate prediction id, while a generation is "processing" — the
|
|
43
|
+
// client polls checkGeneration with the row's id until it settles.
|
|
44
|
+
predictionId: field.string().optional(),
|
|
45
|
+
// True when this is the no-API-key placeholder (the UI shows a "demo" badge).
|
|
46
|
+
demo: field.boolean().default(false),
|
|
47
|
+
createdAt: field.datetime().defaultNow(),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
indexes: [
|
|
51
|
+
{ name: "by_user", fields: ["userId"], unique: false },
|
|
52
|
+
{ name: "by_created", fields: ["createdAt"], unique: false },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const User = entity(
|
|
58
|
+
"User",
|
|
59
|
+
{
|
|
60
|
+
email: field.string(),
|
|
61
|
+
displayName: field.string().optional(),
|
|
62
|
+
passwordHash: field.string().serverOnly().optional(),
|
|
63
|
+
avatarColor: field.string().optional(),
|
|
64
|
+
emailVerified: field.datetime().optional(),
|
|
65
|
+
createdAt: field.datetime().defaultNow(),
|
|
66
|
+
},
|
|
67
|
+
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Generations are PRIVATE per user: you can READ only your own, and you can't
|
|
71
|
+
// write them from the client at all — the generate action (which runs the
|
|
72
|
+
// provider call with the server-side key) is the only writer. So the gallery is
|
|
73
|
+
// live (the sync engine ships you your rows as the action updates them) without
|
|
74
|
+
// ever exposing one user's generations to another.
|
|
75
|
+
const generationPolicy = policy({
|
|
76
|
+
name: "generation_owner_read",
|
|
77
|
+
entity: "Generation",
|
|
78
|
+
allowRead: "auth.userId == data.userId",
|
|
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
|
+
});
|
|
83
|
+
|
|
84
|
+
const userPolicy = policy({
|
|
85
|
+
name: "user_self",
|
|
86
|
+
entity: "User",
|
|
87
|
+
allowRead: "auth.userId == data.id",
|
|
88
|
+
allowInsert: "false",
|
|
89
|
+
allowUpdate: "false",
|
|
90
|
+
allowDelete: "false",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const manifest = buildManifest({
|
|
94
|
+
name: "__APP_NAME__",
|
|
95
|
+
version: "0.1.0",
|
|
96
|
+
entities: [Generation, User],
|
|
97
|
+
// generate (public action) + _createGeneration / _finishGeneration (internal
|
|
98
|
+
// mutations it calls) live in functions/ and are discovered automatically.
|
|
99
|
+
queries: [],
|
|
100
|
+
actions: [],
|
|
101
|
+
policies: [generationPolicy, userPolicy],
|
|
102
|
+
auth: auth(),
|
|
103
|
+
routes: await discoverAppRoutes(),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
107
|
+
|
|
108
|
+
export default manifest;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "app/globals.css",
|
|
9
|
+
"baseColor": "zinc",
|
|
10
|
+
"cssVariables": true
|
|
11
|
+
},
|
|
12
|
+
"aliases": {
|
|
13
|
+
"components": "@/components",
|
|
14
|
+
"utils": "@/lib/utils",
|
|
15
|
+
"ui": "@/components/ui",
|
|
16
|
+
"lib": "@/lib",
|
|
17
|
+
"hooks": "@/hooks"
|
|
18
|
+
},
|
|
19
|
+
"iconLibrary": "lucide"
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { query, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
// Internal: read a Generation by id, bypassing the owner read-policy (the
|
|
4
|
+
// pollGeneration job runs without a user session). Returns the fields the job
|
|
5
|
+
// needs, or null.
|
|
6
|
+
export default query<
|
|
7
|
+
{ id: string },
|
|
8
|
+
| {
|
|
9
|
+
id: string;
|
|
10
|
+
kind: string;
|
|
11
|
+
prompt: string;
|
|
12
|
+
status: string;
|
|
13
|
+
predictionId?: string | null;
|
|
14
|
+
}
|
|
15
|
+
| null
|
|
16
|
+
>({
|
|
17
|
+
internal: true,
|
|
18
|
+
args: { id: v.id("Generation") },
|
|
19
|
+
async handler(ctx, args) {
|
|
20
|
+
const g = (await ctx.db.unsafe.get("Generation", args.id)) as
|
|
21
|
+
| { id: string; kind: string; prompt: string; status: string; predictionId?: string | null }
|
|
22
|
+
| null;
|
|
23
|
+
return g;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
// Internal: patch a Generation. Called by the pollGeneration background job to
|
|
4
|
+
// move a row through pending → processing → done/failed. Each write syncs to the
|
|
5
|
+
// owner's open tabs, so the gallery card updates live. Only set fields are
|
|
6
|
+
// touched. Not client-reachable (Generation is allowInsert/Update:"false").
|
|
7
|
+
export default mutation<
|
|
8
|
+
{
|
|
9
|
+
id: string;
|
|
10
|
+
status?: string;
|
|
11
|
+
resultUrl?: string | null;
|
|
12
|
+
error?: string | null;
|
|
13
|
+
demo?: boolean;
|
|
14
|
+
predictionId?: string;
|
|
15
|
+
},
|
|
16
|
+
{ ok: boolean }
|
|
17
|
+
>({
|
|
18
|
+
internal: true,
|
|
19
|
+
args: {
|
|
20
|
+
id: v.id("Generation"),
|
|
21
|
+
status: v.optional(v.string()),
|
|
22
|
+
resultUrl: v.optional(v.string()),
|
|
23
|
+
error: v.optional(v.string()),
|
|
24
|
+
demo: v.optional(v.boolean()),
|
|
25
|
+
predictionId: v.optional(v.string()),
|
|
26
|
+
},
|
|
27
|
+
async handler(ctx, args) {
|
|
28
|
+
const patch: Record<string, unknown> = {};
|
|
29
|
+
if (args.status !== undefined) patch.status = args.status;
|
|
30
|
+
if (args.resultUrl !== undefined) patch.resultUrl = args.resultUrl;
|
|
31
|
+
if (args.error !== undefined) patch.error = args.error;
|
|
32
|
+
if (args.demo !== undefined) patch.demo = args.demo;
|
|
33
|
+
if (args.predictionId !== undefined) patch.predictionId = args.predictionId;
|
|
34
|
+
await ctx.db.unsafe.update("Generation", args.id, patch);
|
|
35
|
+
return { ok: true };
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
// generate — kick off a generation. A `mutation`: it inserts a pending row
|
|
4
|
+
// (stamped to the caller) and enqueues the `pollGeneration` background job, then
|
|
5
|
+
// returns. It does NO network I/O itself — generation runs in the job, off the
|
|
6
|
+
// request path, so a slow model (video can take minutes) never blocks or times
|
|
7
|
+
// out the call. The pending row syncs to the gallery instantly; the job flips it
|
|
8
|
+
// to the result when it's ready.
|
|
9
|
+
//
|
|
10
|
+
// `auth: "user"` — generating requires a signed-in account (the page already
|
|
11
|
+
// redirects anyone else to /login).
|
|
12
|
+
export default mutation<{ kind: string; prompt: string }, { id: string }>({
|
|
13
|
+
auth: "user",
|
|
14
|
+
args: { kind: v.string(), prompt: v.string() },
|
|
15
|
+
async handler(ctx, args) {
|
|
16
|
+
const userId = ctx.auth.userId;
|
|
17
|
+
if (!userId) throw ctx.error("AUTH_REQUIRED", "Sign in to generate.");
|
|
18
|
+
const kind = args.kind;
|
|
19
|
+
const prompt = args.prompt.trim();
|
|
20
|
+
if (!["image", "audio", "video"].includes(kind)) {
|
|
21
|
+
throw ctx.error("INVALID_ARGS", "kind must be image, audio, or video.");
|
|
22
|
+
}
|
|
23
|
+
if (prompt.length < 2 || prompt.length > 1000) {
|
|
24
|
+
throw ctx.error("INVALID_ARGS", "Enter a prompt (up to 1000 characters).");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const id = await ctx.db.unsafe.insert("Generation", {
|
|
28
|
+
userId,
|
|
29
|
+
kind,
|
|
30
|
+
prompt,
|
|
31
|
+
status: "pending",
|
|
32
|
+
demo: false,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Enqueue the background job (deferred until this mutation commits). It
|
|
37
|
+
// owns the provider call + polling + the final update.
|
|
38
|
+
await ctx.scheduler.runAfter(0, "pollGeneration", { id });
|
|
39
|
+
|
|
40
|
+
return { id };
|
|
41
|
+
},
|
|
42
|
+
});
|