@pylonsync/create-pylon 0.3.273 → 0.3.275
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 +286 -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 +207 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/sitemap.ts +9 -0
- package/templates/agency/app.ts +135 -0
- package/templates/agency/components/marketing.tsx +148 -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/declineInquiry.ts +41 -0
- package/templates/agency/functions/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/submitInquiry.ts +55 -0
- package/templates/agency/gitignore +10 -0
- package/templates/agency/lib/agency.ts +27 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +239 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +34 -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 +414 -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/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 +214 -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,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
passwordLogin,
|
|
6
|
+
passwordRegister,
|
|
7
|
+
persistSession,
|
|
8
|
+
ApiError,
|
|
9
|
+
} from "@pylonsync/client";
|
|
10
|
+
|
|
11
|
+
// The email/password form — one form, two modes. It calls the built-in auth API
|
|
12
|
+
// directly (`passwordLogin` / `passwordRegister` POST to `/api/auth/password/*`),
|
|
13
|
+
// then `persistSession` writes the freshly-minted token to local storage so the
|
|
14
|
+
// sync engine authenticates as the real account on the next load. We then do a
|
|
15
|
+
// full navigation to "/" so the SSR runtime re-resolves auth from the HttpOnly
|
|
16
|
+
// cookie and renders the studio.
|
|
17
|
+
//
|
|
18
|
+
// Sign-in is REQUIRED to use the studio: generations are owner-scoped to your
|
|
19
|
+
// account, so the home page redirects unauthenticated visitors here.
|
|
20
|
+
export function AuthForm() {
|
|
21
|
+
const [mode, setMode] = useState<"login" | "register">("login");
|
|
22
|
+
const [email, setEmail] = useState("");
|
|
23
|
+
const [password, setPassword] = useState("");
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
const [pending, setPending] = useState(false);
|
|
26
|
+
|
|
27
|
+
async function onSubmit(e: React.FormEvent) {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
setError(null);
|
|
30
|
+
setPending(true);
|
|
31
|
+
try {
|
|
32
|
+
const session =
|
|
33
|
+
mode === "login"
|
|
34
|
+
? await passwordLogin({ email, password })
|
|
35
|
+
: await passwordRegister({ email, password });
|
|
36
|
+
// Make this session authoritative, replacing any anonymous guest token.
|
|
37
|
+
persistSession(session);
|
|
38
|
+
window.location.assign("/");
|
|
39
|
+
} catch (err) {
|
|
40
|
+
setError(messageFor(err));
|
|
41
|
+
setPending(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="space-y-5">
|
|
47
|
+
<form onSubmit={onSubmit} className="space-y-4">
|
|
48
|
+
<label className="block">
|
|
49
|
+
<span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Email</span>
|
|
50
|
+
<input
|
|
51
|
+
type="email"
|
|
52
|
+
value={email}
|
|
53
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
54
|
+
required
|
|
55
|
+
autoComplete="email"
|
|
56
|
+
placeholder="you@yourbusiness.com"
|
|
57
|
+
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
58
|
+
/>
|
|
59
|
+
</label>
|
|
60
|
+
<label className="block">
|
|
61
|
+
<span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Password</span>
|
|
62
|
+
<input
|
|
63
|
+
type="password"
|
|
64
|
+
value={password}
|
|
65
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
66
|
+
required
|
|
67
|
+
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
|
68
|
+
placeholder={mode === "login" ? "Your password" : "At least 10 characters"}
|
|
69
|
+
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
70
|
+
/>
|
|
71
|
+
</label>
|
|
72
|
+
{error ? (
|
|
73
|
+
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] leading-snug text-red-700">
|
|
74
|
+
{error}
|
|
75
|
+
</p>
|
|
76
|
+
) : null}
|
|
77
|
+
<button
|
|
78
|
+
type="submit"
|
|
79
|
+
disabled={pending}
|
|
80
|
+
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-zinc-900 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
|
|
81
|
+
>
|
|
82
|
+
{pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
|
|
83
|
+
</button>
|
|
84
|
+
</form>
|
|
85
|
+
|
|
86
|
+
<p className="text-center text-[13px] text-zinc-500">
|
|
87
|
+
{mode === "login" ? "First time here?" : "Already have an account?"}{" "}
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={() => {
|
|
91
|
+
setMode(mode === "login" ? "register" : "login");
|
|
92
|
+
setError(null);
|
|
93
|
+
}}
|
|
94
|
+
className="font-medium text-zinc-900 underline underline-offset-2"
|
|
95
|
+
>
|
|
96
|
+
{mode === "login" ? "Create an account" : "Sign in"}
|
|
97
|
+
</button>
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Map the framework's auth error codes to friendly copy. `ApiError` carries a
|
|
104
|
+
// stable `.code` so you branch on the code, not the message.
|
|
105
|
+
function messageFor(err: unknown): string {
|
|
106
|
+
if (err instanceof ApiError) {
|
|
107
|
+
switch (err.code) {
|
|
108
|
+
case "INVALID_CREDENTIALS":
|
|
109
|
+
return "Wrong email or password.";
|
|
110
|
+
case "USER_EXISTS":
|
|
111
|
+
return "That email is already registered — sign in instead.";
|
|
112
|
+
case "WEAK_PASSWORD":
|
|
113
|
+
return "Pick a longer password — at least 10 characters.";
|
|
114
|
+
case "PWNED_PASSWORD":
|
|
115
|
+
return "That password has appeared in a known data breach. Choose a different one.";
|
|
116
|
+
case "RATE_LIMITED":
|
|
117
|
+
return "Too many attempts — try again in a minute.";
|
|
118
|
+
default:
|
|
119
|
+
return err.message;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (err instanceof Error) return err.message;
|
|
123
|
+
return "Something went wrong. Try again.";
|
|
124
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type ErrorBoundaryProps } from "@pylonsync/react";
|
|
3
|
+
|
|
4
|
+
// `app/error.tsx` → the error boundary for this segment. Hydrated + interactive:
|
|
5
|
+
// `reset()` re-attempts the route. The thrown error reaches the client as
|
|
6
|
+
// `{ message, digest }` only — the stack stays in the dev overlay / server logs.
|
|
7
|
+
export default function Error({ error, reset }: ErrorBoundaryProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
|
|
10
|
+
<h1 className="text-2xl font-semibold tracking-tight">Something went wrong</h1>
|
|
11
|
+
<p className="mt-2 text-zinc-500">{error.message}</p>
|
|
12
|
+
{error.digest ? (
|
|
13
|
+
<p className="mt-1 text-xs text-zinc-400">
|
|
14
|
+
Reference: <code>{error.digest}</code>
|
|
15
|
+
</p>
|
|
16
|
+
) : null}
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
onClick={reset}
|
|
20
|
+
className="mt-6 inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
|
|
21
|
+
>
|
|
22
|
+
Try again
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "tw-animate-css";
|
|
3
|
+
|
|
4
|
+
/* Tailwind v4 scans these globs for class names. Add more @source lines if you
|
|
5
|
+
put markup elsewhere. The @pylonsync/client line lets its components
|
|
6
|
+
(EnsureGuest, auth helpers) keep any classes they ship. */
|
|
7
|
+
@source "../app/**/*.{tsx,ts,jsx,js}";
|
|
8
|
+
@source "../components/**/*.{tsx,ts,jsx,js}";
|
|
9
|
+
@source "../lib/**/*.{tsx,ts,jsx,js}";
|
|
10
|
+
@source "../node_modules/@pylonsync/client/**/*.{tsx,ts,jsx,js}";
|
|
11
|
+
|
|
12
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
13
|
+
|
|
14
|
+
/* shadcn/ui design tokens (new-york / zinc) + the marketing brand accent. The
|
|
15
|
+
three brand vars are defaults — app/layout.tsx overrides them from
|
|
16
|
+
lib/site.config.ts on <html>, so re-theming the whole page is one edit there. */
|
|
17
|
+
:root {
|
|
18
|
+
--radius: 0.625rem;
|
|
19
|
+
--brand: #4f46e5;
|
|
20
|
+
--brand-soft: #eef2ff;
|
|
21
|
+
--paper: #fafafa;
|
|
22
|
+
--background: oklch(1 0 0);
|
|
23
|
+
--foreground: oklch(0.141 0.005 285.823);
|
|
24
|
+
--card: oklch(1 0 0);
|
|
25
|
+
--card-foreground: oklch(0.141 0.005 285.823);
|
|
26
|
+
--popover: oklch(1 0 0);
|
|
27
|
+
--popover-foreground: oklch(0.141 0.005 285.823);
|
|
28
|
+
--primary: oklch(0.21 0.006 285.885);
|
|
29
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
30
|
+
--secondary: oklch(0.967 0.001 286.375);
|
|
31
|
+
--secondary-foreground: oklch(0.21 0.006 285.885);
|
|
32
|
+
--muted: oklch(0.967 0.001 286.375);
|
|
33
|
+
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
34
|
+
--accent: oklch(0.967 0.001 286.375);
|
|
35
|
+
--accent-foreground: oklch(0.21 0.006 285.885);
|
|
36
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
37
|
+
--border: oklch(0.92 0.004 286.32);
|
|
38
|
+
--input: oklch(0.92 0.004 286.32);
|
|
39
|
+
--ring: oklch(0.705 0.015 286.067);
|
|
40
|
+
--chart-1: oklch(0.646 0.222 41.116);
|
|
41
|
+
--chart-2: oklch(0.6 0.118 184.704);
|
|
42
|
+
--chart-3: oklch(0.398 0.07 227.392);
|
|
43
|
+
--chart-4: oklch(0.828 0.189 84.429);
|
|
44
|
+
--chart-5: oklch(0.769 0.188 70.08);
|
|
45
|
+
--sidebar: oklch(0.985 0 0);
|
|
46
|
+
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
|
47
|
+
--sidebar-primary: oklch(0.21 0.006 285.885);
|
|
48
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
49
|
+
--sidebar-accent: oklch(0.967 0.001 286.375);
|
|
50
|
+
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
|
51
|
+
--sidebar-border: oklch(0.92 0.004 286.32);
|
|
52
|
+
--sidebar-ring: oklch(0.705 0.015 286.067);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.dark {
|
|
56
|
+
--background: oklch(0.141 0.005 285.823);
|
|
57
|
+
--foreground: oklch(0.985 0 0);
|
|
58
|
+
--card: oklch(0.21 0.006 285.885);
|
|
59
|
+
--card-foreground: oklch(0.985 0 0);
|
|
60
|
+
--popover: oklch(0.21 0.006 285.885);
|
|
61
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
62
|
+
--primary: oklch(0.92 0.004 286.32);
|
|
63
|
+
--primary-foreground: oklch(0.21 0.006 285.885);
|
|
64
|
+
--secondary: oklch(0.274 0.006 286.033);
|
|
65
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
66
|
+
--muted: oklch(0.274 0.006 286.033);
|
|
67
|
+
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
68
|
+
--accent: oklch(0.274 0.006 286.033);
|
|
69
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
70
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
71
|
+
--border: oklch(1 0 0 / 10%);
|
|
72
|
+
--input: oklch(1 0 0 / 15%);
|
|
73
|
+
--ring: oklch(0.552 0.016 285.938);
|
|
74
|
+
--chart-1: oklch(0.488 0.243 264.376);
|
|
75
|
+
--chart-2: oklch(0.696 0.17 162.48);
|
|
76
|
+
--chart-3: oklch(0.769 0.188 70.08);
|
|
77
|
+
--chart-4: oklch(0.627 0.265 303.9);
|
|
78
|
+
--chart-5: oklch(0.645 0.246 16.439);
|
|
79
|
+
--sidebar: oklch(0.21 0.006 285.885);
|
|
80
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
81
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
82
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
83
|
+
--sidebar-accent: oklch(0.274 0.006 286.033);
|
|
84
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
85
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
86
|
+
--sidebar-ring: oklch(0.552 0.016 285.938);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@theme inline {
|
|
90
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
91
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
92
|
+
--radius-lg: var(--radius);
|
|
93
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
94
|
+
--color-background: var(--background);
|
|
95
|
+
--color-foreground: var(--foreground);
|
|
96
|
+
--color-card: var(--card);
|
|
97
|
+
--color-card-foreground: var(--card-foreground);
|
|
98
|
+
--color-popover: var(--popover);
|
|
99
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
100
|
+
--color-primary: var(--primary);
|
|
101
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
102
|
+
--color-secondary: var(--secondary);
|
|
103
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
104
|
+
--color-muted: var(--muted);
|
|
105
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
106
|
+
--color-accent: var(--accent);
|
|
107
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
108
|
+
--color-destructive: var(--destructive);
|
|
109
|
+
--color-border: var(--border);
|
|
110
|
+
--color-input: var(--input);
|
|
111
|
+
--color-ring: var(--ring);
|
|
112
|
+
--color-brand: var(--brand);
|
|
113
|
+
--color-brand-soft: var(--brand-soft);
|
|
114
|
+
--color-paper: var(--paper);
|
|
115
|
+
--color-chart-1: var(--chart-1);
|
|
116
|
+
--color-chart-2: var(--chart-2);
|
|
117
|
+
--color-chart-3: var(--chart-3);
|
|
118
|
+
--color-chart-4: var(--chart-4);
|
|
119
|
+
--color-chart-5: var(--chart-5);
|
|
120
|
+
--color-sidebar: var(--sidebar);
|
|
121
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
122
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
123
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
124
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
125
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
126
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
127
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@layer base {
|
|
131
|
+
*,
|
|
132
|
+
::after,
|
|
133
|
+
::before,
|
|
134
|
+
::backdrop,
|
|
135
|
+
::file-selector-button {
|
|
136
|
+
border-color: var(--color-border, currentColor);
|
|
137
|
+
outline-color: var(--color-ring);
|
|
138
|
+
}
|
|
139
|
+
body {
|
|
140
|
+
background-color: var(--color-background);
|
|
141
|
+
color: var(--color-foreground);
|
|
142
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
143
|
+
-webkit-font-smoothing: antialiased;
|
|
144
|
+
}
|
|
145
|
+
button {
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type PageAuth } from "@pylonsync/react";
|
|
3
|
+
import { siteConfig } from "@/lib/site.config";
|
|
4
|
+
|
|
5
|
+
// App shell: a slim top bar over a full-height chat. `auth.user_id` is resolved
|
|
6
|
+
// server-side from the session cookie before any HTML is sent, so the bar shows
|
|
7
|
+
// the account / "Sign in" with no flash. The chat page fills the rest of the
|
|
8
|
+
// viewport (h-[calc(100vh-3.5rem)] in chat-client.tsx — keep the header at h-14).
|
|
9
|
+
interface LayoutProps {
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
url: string;
|
|
12
|
+
auth: PageAuth;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function RootLayout({ children, url, auth }: LayoutProps) {
|
|
16
|
+
// Resolved server-side from the session cookie. The app requires sign-in, so
|
|
17
|
+
// this is set on every in-app page; the header reflects it with no flash.
|
|
18
|
+
const signedIn = Boolean(auth?.user_id);
|
|
19
|
+
const { brand, colors } = siteConfig;
|
|
20
|
+
|
|
21
|
+
// The auth screen brings its own chrome → render it bare.
|
|
22
|
+
const path = (url ?? "").split("?")[0];
|
|
23
|
+
const isBare = path === "/login" || path.startsWith("/login/");
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<html
|
|
27
|
+
lang="en"
|
|
28
|
+
style={
|
|
29
|
+
{
|
|
30
|
+
"--brand": colors.brand,
|
|
31
|
+
"--brand-soft": colors.brandSoft,
|
|
32
|
+
"--paper": colors.paper,
|
|
33
|
+
} as React.CSSProperties
|
|
34
|
+
}
|
|
35
|
+
>
|
|
36
|
+
<head>
|
|
37
|
+
<meta charSet="utf-8" />
|
|
38
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
39
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
40
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
41
|
+
<link
|
|
42
|
+
rel="stylesheet"
|
|
43
|
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
|
44
|
+
/>
|
|
45
|
+
</head>
|
|
46
|
+
<body className="bg-background text-foreground antialiased">
|
|
47
|
+
{isBare ? (
|
|
48
|
+
children
|
|
49
|
+
) : (
|
|
50
|
+
<>
|
|
51
|
+
<header className="flex h-14 items-center justify-between border-b border-zinc-200 bg-white px-4">
|
|
52
|
+
<Link href="/" className="flex items-center gap-2">
|
|
53
|
+
<span className="flex size-6 items-center justify-center rounded-[7px] bg-brand text-[13px] font-bold text-white">
|
|
54
|
+
{brand.letter}
|
|
55
|
+
</span>
|
|
56
|
+
<span className="text-[15px] font-semibold tracking-tight text-zinc-900">{brand.name}</span>
|
|
57
|
+
</Link>
|
|
58
|
+
{signedIn ? (
|
|
59
|
+
<span className="text-[13px] text-zinc-400">Signed in</span>
|
|
60
|
+
) : (
|
|
61
|
+
<Link
|
|
62
|
+
href="/login"
|
|
63
|
+
className="rounded-full border border-zinc-300 px-3.5 py-1.5 text-[13px] font-medium text-zinc-700 transition-colors hover:bg-zinc-50"
|
|
64
|
+
>
|
|
65
|
+
Sign in
|
|
66
|
+
</Link>
|
|
67
|
+
)}
|
|
68
|
+
</header>
|
|
69
|
+
{children}
|
|
70
|
+
</>
|
|
71
|
+
)}
|
|
72
|
+
</body>
|
|
73
|
+
</html>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
+
import { AuthForm } from "../auth-form";
|
|
4
|
+
import { siteConfig } from "@/lib/site.config";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Sign in — ${siteConfig.brand.name}`,
|
|
8
|
+
robots: "noindex",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// `app/login/page.tsx` → `/login`. Sign-in is required to use the studio
|
|
12
|
+
// (generations are tied to your account). Rendered bare. Already signed in? Skip
|
|
13
|
+
// back to the studio — `response.redirect` in the synchronous shell render is a
|
|
14
|
+
// real 307 before any HTML is sent.
|
|
15
|
+
export default function LoginPage({ auth, response }: PageProps) {
|
|
16
|
+
// Already signed in (a real account, not an anonymous guest)? Skip the form.
|
|
17
|
+
if (auth.user_id && !auth.user_id.startsWith("guest_")) response.redirect("/");
|
|
18
|
+
const { brand } = siteConfig;
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex min-h-screen items-center justify-center bg-white px-6 py-12">
|
|
21
|
+
<div className="w-full max-w-[400px] rounded-2xl border border-zinc-200/70 p-8">
|
|
22
|
+
<Link href="/" className="inline-flex">
|
|
23
|
+
<span className="flex size-9 items-center justify-center rounded-xl bg-zinc-900 text-base font-bold text-white">
|
|
24
|
+
{brand.letter}
|
|
25
|
+
</span>
|
|
26
|
+
</Link>
|
|
27
|
+
<h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">
|
|
28
|
+
Sign in to {brand.name}
|
|
29
|
+
</h1>
|
|
30
|
+
<p className="mt-1 text-[13px] text-zinc-500">
|
|
31
|
+
Create an account or sign in to start generating.
|
|
32
|
+
</p>
|
|
33
|
+
<div className="mt-6">
|
|
34
|
+
<AuthForm />
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type NotFoundProps } from "@pylonsync/react";
|
|
3
|
+
|
|
4
|
+
// `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when a
|
|
5
|
+
// page calls `response.notFound()`). Hydrated, so the link is a client nav.
|
|
6
|
+
export default function NotFound(_props: NotFoundProps) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
|
|
9
|
+
<h1 className="text-3xl font-semibold tracking-tight">404</h1>
|
|
10
|
+
<p className="mt-2 text-zinc-500">We couldn't find that page.</p>
|
|
11
|
+
<Link
|
|
12
|
+
href="/"
|
|
13
|
+
className="mt-6 inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
|
|
14
|
+
>
|
|
15
|
+
Back home
|
|
16
|
+
</Link>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
+
import { Studio } from "./studio-client";
|
|
4
|
+
import { siteConfig } from "@/lib/site.config";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: siteConfig.seo.title,
|
|
8
|
+
description: siteConfig.seo.description,
|
|
9
|
+
openGraph: { title: siteConfig.seo.title, description: siteConfig.seo.description, type: "website" },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// `app/page.tsx` → `/`. A small header over the studio island: a prompt bar +
|
|
13
|
+
// kind selector + a live gallery that fills in as generations finish. Sign-in is
|
|
14
|
+
// REQUIRED — generations are tied to your account — so an unauthenticated
|
|
15
|
+
// visitor is redirected to /login (a real 307 from the synchronous shell render).
|
|
16
|
+
export default function Home({ auth, response }: PageProps) {
|
|
17
|
+
// Real account required — reject anonymous + guest (guest_…) sessions.
|
|
18
|
+
if (!auth.user_id || auth.user_id.startsWith("guest_")) {
|
|
19
|
+
response.redirect("/login");
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const { studio } = siteConfig;
|
|
23
|
+
return (
|
|
24
|
+
<div className="min-h-[calc(100vh-3.5rem)] bg-white">
|
|
25
|
+
<div className="mx-auto max-w-5xl px-4 pt-8 text-center sm:pt-12">
|
|
26
|
+
<h1 className="text-balance text-3xl font-semibold tracking-[-0.02em] text-zinc-900 sm:text-4xl">
|
|
27
|
+
{studio.headline}
|
|
28
|
+
</h1>
|
|
29
|
+
<p className="mx-auto mt-3 max-w-xl text-[15px] leading-relaxed text-zinc-500">{studio.subcopy}</p>
|
|
30
|
+
</div>
|
|
31
|
+
<Studio />
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { 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
|
+
|
|
30
|
+
async function generate() {
|
|
31
|
+
const p = prompt.trim();
|
|
32
|
+
if (!p || busy) return;
|
|
33
|
+
setBusy(true);
|
|
34
|
+
setError(null);
|
|
35
|
+
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
|
+
try {
|
|
39
|
+
await callFn("generate", { kind, prompt: p });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
const msg = e instanceof Error ? e.message : "Couldn't start the generation.";
|
|
42
|
+
setError(/INVALID_ARGS/.test(msg) ? "Enter a prompt (up to 1000 characters)." : msg);
|
|
43
|
+
} finally {
|
|
44
|
+
setBusy(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="mx-auto max-w-5xl px-4 py-8">
|
|
50
|
+
{/* Prompt bar */}
|
|
51
|
+
<div className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm">
|
|
52
|
+
<textarea
|
|
53
|
+
value={prompt}
|
|
54
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
55
|
+
onKeyDown={(e) => {
|
|
56
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
generate();
|
|
59
|
+
}
|
|
60
|
+
}}
|
|
61
|
+
rows={2}
|
|
62
|
+
placeholder={studio.inputPlaceholder}
|
|
63
|
+
aria-label="Prompt"
|
|
64
|
+
className="w-full resize-none bg-transparent text-[15px] text-zinc-900 outline-none placeholder:text-zinc-400"
|
|
65
|
+
/>
|
|
66
|
+
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
|
|
67
|
+
<div className="flex items-center gap-1 rounded-full bg-zinc-100 p-1">
|
|
68
|
+
{studio.kinds.map((k) => (
|
|
69
|
+
<button
|
|
70
|
+
key={k.id}
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => setKind(k.id)}
|
|
73
|
+
className={
|
|
74
|
+
"rounded-full px-3 py-1.5 text-[13px] font-medium transition-colors " +
|
|
75
|
+
(kind === k.id ? "bg-white text-zinc-900 shadow-sm" : "text-zinc-500 hover:text-zinc-800")
|
|
76
|
+
}
|
|
77
|
+
>
|
|
78
|
+
{k.label}
|
|
79
|
+
{!k.wired ? <span className="ml-1 text-[10px] text-zinc-400">stub</span> : null}
|
|
80
|
+
</button>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={generate}
|
|
86
|
+
disabled={busy || !prompt.trim()}
|
|
87
|
+
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
|
+
>
|
|
89
|
+
{busy ? "Generating…" : "Generate"}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{error ? <p className="mt-3 text-[13px] text-red-600">{error}</p> : null}
|
|
95
|
+
|
|
96
|
+
{/* Examples (only before anything's been made) */}
|
|
97
|
+
{generations.length === 0 ? (
|
|
98
|
+
<div className="mt-5 flex flex-wrap gap-2">
|
|
99
|
+
{studio.examples.map((ex) => (
|
|
100
|
+
<button
|
|
101
|
+
key={ex}
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => setPrompt(ex)}
|
|
104
|
+
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"
|
|
105
|
+
>
|
|
106
|
+
{ex}
|
|
107
|
+
</button>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
) : null}
|
|
111
|
+
|
|
112
|
+
{/* Gallery */}
|
|
113
|
+
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
114
|
+
{generations.map((g) => (
|
|
115
|
+
<GenerationCard key={g.id} g={g} />
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function GenerationCard({ g }: { g: GenerationRow }) {
|
|
123
|
+
return (
|
|
124
|
+
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
|
125
|
+
<div className="relative grid aspect-square place-items-center bg-paper">
|
|
126
|
+
<Media g={g} />
|
|
127
|
+
{g.demo && g.status === "done" ? (
|
|
128
|
+
<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
|
+
demo
|
|
130
|
+
</span>
|
|
131
|
+
) : null}
|
|
132
|
+
<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">
|
|
133
|
+
{g.kind}
|
|
134
|
+
</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div className="p-3">
|
|
137
|
+
<p className="line-clamp-2 text-[13px] leading-snug text-zinc-600">{g.prompt}</p>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function Media({ g }: { g: GenerationRow }) {
|
|
144
|
+
if (g.status === "pending" || g.status === "processing") {
|
|
145
|
+
return (
|
|
146
|
+
<div className="flex flex-col items-center gap-2 text-zinc-400">
|
|
147
|
+
<Spinner />
|
|
148
|
+
<span className="text-[12px]">{g.status === "processing" ? "Generating…" : "Queued…"}</span>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
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
|
+
);
|
|
158
|
+
}
|
|
159
|
+
// done
|
|
160
|
+
if (g.kind === "image" && g.resultUrl) {
|
|
161
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
162
|
+
return <img src={g.resultUrl} alt={g.prompt} className="size-full object-cover" />;
|
|
163
|
+
}
|
|
164
|
+
if (g.kind === "audio") {
|
|
165
|
+
return g.resultUrl ? (
|
|
166
|
+
<div className="w-full px-4">
|
|
167
|
+
<AudioWave />
|
|
168
|
+
<audio controls src={g.resultUrl} className="mt-3 w-full" />
|
|
169
|
+
</div>
|
|
170
|
+
) : (
|
|
171
|
+
<DemoNote text="Add REPLICATE_API_TOKEN to generate real audio." />
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (g.kind === "video") {
|
|
175
|
+
return g.resultUrl ? (
|
|
176
|
+
<video controls src={g.resultUrl} className="size-full object-cover" />
|
|
177
|
+
) : (
|
|
178
|
+
<DemoNote text="Add REPLICATE_API_TOKEN to generate real video." />
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return <DemoNote text="No result." />;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function DemoNote({ text }: { text: string }) {
|
|
185
|
+
return (
|
|
186
|
+
<div className="flex flex-col items-center gap-2 px-5 text-center text-zinc-400">
|
|
187
|
+
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
|
188
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
189
|
+
<path d="M3 9h18M9 21V9" />
|
|
190
|
+
</svg>
|
|
191
|
+
<span className="text-[12px] leading-snug">{text}</span>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function AudioWave() {
|
|
197
|
+
return (
|
|
198
|
+
<div className="flex items-end justify-center gap-1">
|
|
199
|
+
{[10, 22, 14, 28, 18, 26, 12].map((h, i) => (
|
|
200
|
+
<span key={i} className="w-1.5 rounded-full bg-brand/60" style={{ height: h }} />
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function Spinner() {
|
|
207
|
+
return (
|
|
208
|
+
<svg className="size-6 animate-spin text-brand" viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
209
|
+
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" opacity="0.2" />
|
|
210
|
+
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
|
211
|
+
</svg>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|