@getjack/jack 0.1.2 → 0.1.4
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/README.md +77 -29
- package/package.json +54 -47
- package/src/commands/agents.ts +145 -10
- package/src/commands/down.ts +110 -102
- package/src/commands/feedback.ts +189 -0
- package/src/commands/init.ts +8 -12
- package/src/commands/login.ts +88 -0
- package/src/commands/logout.ts +14 -0
- package/src/commands/logs.ts +21 -0
- package/src/commands/mcp.ts +134 -7
- package/src/commands/new.ts +43 -17
- package/src/commands/open.ts +13 -6
- package/src/commands/projects.ts +269 -143
- package/src/commands/secrets.ts +413 -0
- package/src/commands/services.ts +96 -123
- package/src/commands/ship.ts +5 -1
- package/src/commands/whoami.ts +31 -0
- package/src/index.ts +218 -144
- package/src/lib/agent-files.ts +34 -0
- package/src/lib/agents.ts +390 -22
- package/src/lib/asset-hash.ts +50 -0
- package/src/lib/auth/client.ts +115 -0
- package/src/lib/auth/constants.ts +5 -0
- package/src/lib/auth/guard.ts +57 -0
- package/src/lib/auth/index.ts +18 -0
- package/src/lib/auth/store.ts +54 -0
- package/src/lib/binding-validator.ts +136 -0
- package/src/lib/build-helper.ts +211 -0
- package/src/lib/cloudflare-api.ts +24 -0
- package/src/lib/config.ts +5 -6
- package/src/lib/control-plane.ts +295 -0
- package/src/lib/debug.ts +3 -1
- package/src/lib/deploy-mode.ts +93 -0
- package/src/lib/deploy-upload.ts +92 -0
- package/src/lib/errors.ts +2 -0
- package/src/lib/github.ts +31 -1
- package/src/lib/hooks.ts +4 -12
- package/src/lib/intent.ts +88 -0
- package/src/lib/jsonc.ts +125 -0
- package/src/lib/local-paths.test.ts +902 -0
- package/src/lib/local-paths.ts +258 -0
- package/src/lib/managed-deploy.ts +175 -0
- package/src/lib/managed-down.ts +159 -0
- package/src/lib/mcp-config.ts +55 -34
- package/src/lib/names.ts +9 -29
- package/src/lib/project-operations.ts +676 -249
- package/src/lib/project-resolver.ts +476 -0
- package/src/lib/registry.ts +76 -37
- package/src/lib/resources.ts +196 -0
- package/src/lib/schema.ts +30 -1
- package/src/lib/storage/file-filter.ts +1 -0
- package/src/lib/storage/index.ts +5 -1
- package/src/lib/telemetry.ts +14 -0
- package/src/lib/tty.ts +15 -0
- package/src/lib/zip-packager.ts +255 -0
- package/src/mcp/resources/index.ts +8 -2
- package/src/mcp/server.ts +32 -4
- package/src/mcp/tools/index.ts +35 -13
- package/src/mcp/types.ts +6 -0
- package/src/mcp/utils.ts +1 -1
- package/src/templates/index.ts +42 -4
- package/src/templates/types.ts +13 -0
- package/templates/CLAUDE.md +166 -0
- package/templates/api/.jack.json +4 -0
- package/templates/api/bun.lock +1 -0
- package/templates/api/wrangler.jsonc +5 -0
- package/templates/hello/.jack.json +28 -0
- package/templates/hello/package.json +10 -0
- package/templates/hello/src/index.ts +11 -0
- package/templates/hello/tsconfig.json +11 -0
- package/templates/hello/wrangler.jsonc +5 -0
- package/templates/miniapp/.jack.json +15 -4
- package/templates/miniapp/bun.lock +135 -40
- package/templates/miniapp/index.html +1 -0
- package/templates/miniapp/package.json +3 -1
- package/templates/miniapp/public/.well-known/farcaster.json +7 -5
- package/templates/miniapp/public/icon.png +0 -0
- package/templates/miniapp/public/og.png +0 -0
- package/templates/miniapp/schema.sql +8 -0
- package/templates/miniapp/src/App.tsx +254 -3
- package/templates/miniapp/src/components/ShareSheet.tsx +147 -0
- package/templates/miniapp/src/hooks/useAI.ts +35 -0
- package/templates/miniapp/src/hooks/useGuestbook.ts +11 -1
- package/templates/miniapp/src/hooks/useShare.ts +76 -0
- package/templates/miniapp/src/index.css +15 -0
- package/templates/miniapp/src/lib/api.ts +2 -1
- package/templates/miniapp/src/worker.ts +515 -1
- package/templates/miniapp/wrangler.jsonc +15 -3
- package/LICENSE +0 -190
- package/src/commands/cloud.ts +0 -230
- package/templates/api/wrangler.toml +0 -3
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>jack-template</title>
|
|
7
|
+
<meta name="fc:miniapp" content='{"version":"1","imageUrl":"/og.png","button":{"title":"Open App","action":{"type":"launch_miniapp","name":"jack-template","splashImageUrl":"/icon.png","splashBackgroundColor":"#0a0a0a"}}}' />
|
|
7
8
|
</head>
|
|
8
9
|
<body>
|
|
9
10
|
<div id="root"></div>
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
"@farcaster/miniapp-wagmi-connector": "^1.1.0",
|
|
15
15
|
"@tanstack/react-query": "^5.45.1",
|
|
16
16
|
"hono": "^4.6.0",
|
|
17
|
+
"openai": "^4.73.0",
|
|
17
18
|
"react": "^18.3.1",
|
|
18
19
|
"react-dom": "^18.3.1",
|
|
19
20
|
"viem": "^2.21.0",
|
|
20
|
-
"wagmi": "^2.12.0"
|
|
21
|
+
"wagmi": "^2.12.0",
|
|
22
|
+
"workers-og": "^0.0.27"
|
|
21
23
|
},
|
|
22
24
|
"devDependencies": {
|
|
23
25
|
"@cloudflare/vite-plugin": "^1.0.0",
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
"payload": "",
|
|
5
5
|
"signature": ""
|
|
6
6
|
},
|
|
7
|
-
"
|
|
8
|
-
"version": "
|
|
7
|
+
"miniapp": {
|
|
8
|
+
"version": "1",
|
|
9
9
|
"name": "jack-template",
|
|
10
|
-
"iconUrl": "
|
|
11
|
-
"
|
|
10
|
+
"iconUrl": "/icon.png",
|
|
11
|
+
"homeUrl": "/",
|
|
12
|
+
"imageUrl": "/og.png",
|
|
13
|
+
"splashImageUrl": "/icon.png",
|
|
12
14
|
"splashBackgroundColor": "#0a0a0a",
|
|
13
|
-
"
|
|
15
|
+
"buttonTitle": "Open App"
|
|
14
16
|
}
|
|
15
17
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -12,3 +12,11 @@ CREATE TABLE IF NOT EXISTS guestbook (
|
|
|
12
12
|
);
|
|
13
13
|
|
|
14
14
|
CREATE INDEX IF NOT EXISTS idx_guestbook_created_at ON guestbook(created_at DESC);
|
|
15
|
+
|
|
16
|
+
-- AI rate limiting (10 requests per minute per IP)
|
|
17
|
+
-- Uses fixed-window rate limiting for simplicity
|
|
18
|
+
CREATE TABLE IF NOT EXISTS ai_rate_limits (
|
|
19
|
+
identifier TEXT PRIMARY KEY,
|
|
20
|
+
request_count INTEGER DEFAULT 1,
|
|
21
|
+
window_start INTEGER NOT NULL
|
|
22
|
+
);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { sdk } from "@farcaster/miniapp-sdk";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
|
+
import { ShareSheet } from "./components/ShareSheet";
|
|
4
|
+
import { useAI } from "./hooks/useAI";
|
|
3
5
|
import { type GuestbookEntry, useAddGuestbookEntry, useGuestbook } from "./hooks/useGuestbook";
|
|
4
6
|
|
|
5
7
|
interface Notification {
|
|
@@ -40,7 +42,7 @@ interface NotificationsResponse {
|
|
|
40
42
|
export default function App() {
|
|
41
43
|
const [isReady, setIsReady] = useState(false);
|
|
42
44
|
const [context, setContext] = useState<any>(null);
|
|
43
|
-
const [activeTab, setActiveTab] = useState<"notifications" | "guestbook">("guestbook");
|
|
45
|
+
const [activeTab, setActiveTab] = useState<"notifications" | "guestbook" | "ai">("guestbook");
|
|
44
46
|
|
|
45
47
|
// Notifications state
|
|
46
48
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
@@ -56,6 +58,26 @@ export default function App() {
|
|
|
56
58
|
} = useGuestbook();
|
|
57
59
|
const addEntry = useAddGuestbookEntry();
|
|
58
60
|
const [newMessage, setNewMessage] = useState("");
|
|
61
|
+
const [showShareSheet, setShowShareSheet] = useState(false);
|
|
62
|
+
const [lastSignedMessage, setLastSignedMessage] = useState("");
|
|
63
|
+
|
|
64
|
+
// AI state
|
|
65
|
+
const [aiLoading, setAiLoading] = useState(false);
|
|
66
|
+
const [aiError, setAiError] = useState<string | null>(null);
|
|
67
|
+
const [aiResult, setAiResult] = useState<{ category: string; reason: string } | null>(null);
|
|
68
|
+
const resetAI = () => {
|
|
69
|
+
setAiError(null);
|
|
70
|
+
setAiResult(null);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Cast idea generator (demonstrates useAI hook)
|
|
74
|
+
const {
|
|
75
|
+
generate,
|
|
76
|
+
isLoading: castIdeaLoading,
|
|
77
|
+
error: castIdeaError,
|
|
78
|
+
reset: resetCastIdea,
|
|
79
|
+
} = useAI();
|
|
80
|
+
const [castIdea, setCastIdea] = useState<string | null>(null);
|
|
59
81
|
|
|
60
82
|
useEffect(() => {
|
|
61
83
|
const init = async () => {
|
|
@@ -86,20 +108,93 @@ export default function App() {
|
|
|
86
108
|
const handleAddGuestbookEntry = async () => {
|
|
87
109
|
if (!context?.user || !newMessage.trim()) return;
|
|
88
110
|
|
|
111
|
+
const message = newMessage.trim();
|
|
89
112
|
try {
|
|
90
113
|
await addEntry.mutateAsync({
|
|
91
114
|
fid: context.user.fid,
|
|
92
115
|
username: context.user.username,
|
|
93
116
|
displayName: context.user.displayName,
|
|
94
117
|
pfpUrl: context.user.pfpUrl,
|
|
95
|
-
message
|
|
118
|
+
message,
|
|
96
119
|
});
|
|
97
120
|
setNewMessage("");
|
|
121
|
+
setLastSignedMessage(message);
|
|
122
|
+
setShowShareSheet(true);
|
|
98
123
|
} catch (err) {
|
|
99
124
|
console.error("Failed to add entry:", err);
|
|
100
125
|
}
|
|
101
126
|
};
|
|
102
127
|
|
|
128
|
+
// Build the share embed URL - points to /share page with fc:miniapp meta tags
|
|
129
|
+
// This creates a clickable miniapp card in the cast, not just an image
|
|
130
|
+
const getShareEmbedUrl = () => {
|
|
131
|
+
if (!context?.user) return window.location.origin;
|
|
132
|
+
const params = new URLSearchParams({
|
|
133
|
+
username: context.user.username,
|
|
134
|
+
displayName: context.user.displayName || context.user.username,
|
|
135
|
+
message: lastSignedMessage,
|
|
136
|
+
appName: "jack-template",
|
|
137
|
+
});
|
|
138
|
+
if (context.user.pfpUrl) {
|
|
139
|
+
params.set("pfpUrl", context.user.pfpUrl);
|
|
140
|
+
}
|
|
141
|
+
return `${window.location.origin}/share?${params.toString()}`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const analyzeProfile = async () => {
|
|
145
|
+
if (!context?.user) return;
|
|
146
|
+
|
|
147
|
+
resetAI();
|
|
148
|
+
setAiResult(null);
|
|
149
|
+
setAiLoading(true);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
// Call dedicated profile analysis endpoint
|
|
153
|
+
// It fetches user data + recent casts from Neynar, then runs AI
|
|
154
|
+
const res = await fetch("/api/ai/analyze-profile", {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify({ fid: context.user.fid }),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const errorData = (await res.json()) as { error?: string };
|
|
162
|
+
throw new Error(errorData.error || "Analysis failed");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const result = (await res.json()) as { category: string; reason: string };
|
|
166
|
+
setAiResult({ category: result.category, reason: result.reason });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error("Failed to analyze profile:", err);
|
|
169
|
+
setAiError(err instanceof Error ? err.message : "Analysis failed");
|
|
170
|
+
} finally {
|
|
171
|
+
setAiLoading(false);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const generateCastIdea = async () => {
|
|
176
|
+
resetCastIdea();
|
|
177
|
+
setCastIdea(null);
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await generate(
|
|
181
|
+
"Generate a creative, engaging Farcaster cast idea (under 320 chars). Topic: something interesting about web3, crypto culture, or tech. Be witty and conversational. Return ONLY the cast text, no quotes or explanation.",
|
|
182
|
+
);
|
|
183
|
+
setCastIdea(result.result);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error("Failed to generate cast idea:", err);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const copyCastIdea = async () => {
|
|
190
|
+
if (!castIdea) return;
|
|
191
|
+
try {
|
|
192
|
+
await navigator.clipboard.writeText(castIdea);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error("Failed to copy:", err);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
103
198
|
const formatTime = (timestamp: string) => {
|
|
104
199
|
const diff = Date.now() - new Date(timestamp).getTime();
|
|
105
200
|
const mins = Math.floor(diff / 60000);
|
|
@@ -169,6 +264,7 @@ export default function App() {
|
|
|
169
264
|
{/* Tabs */}
|
|
170
265
|
<div className="flex gap-1 bg-zinc-900 p-1 rounded-lg">
|
|
171
266
|
<button
|
|
267
|
+
type="button"
|
|
172
268
|
onClick={() => setActiveTab("guestbook")}
|
|
173
269
|
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
|
174
270
|
activeTab === "guestbook"
|
|
@@ -179,6 +275,7 @@ export default function App() {
|
|
|
179
275
|
Guestbook
|
|
180
276
|
</button>
|
|
181
277
|
<button
|
|
278
|
+
type="button"
|
|
182
279
|
onClick={() => {
|
|
183
280
|
setActiveTab("notifications");
|
|
184
281
|
if (context?.user?.fid && notifications.length === 0) {
|
|
@@ -198,6 +295,15 @@ export default function App() {
|
|
|
198
295
|
</span>
|
|
199
296
|
)}
|
|
200
297
|
</button>
|
|
298
|
+
<button
|
|
299
|
+
type="button"
|
|
300
|
+
onClick={() => setActiveTab("ai")}
|
|
301
|
+
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
|
302
|
+
activeTab === "ai" ? "bg-violet-600 text-white" : "text-zinc-400 hover:text-zinc-200"
|
|
303
|
+
}`}
|
|
304
|
+
>
|
|
305
|
+
AI
|
|
306
|
+
</button>
|
|
201
307
|
</div>
|
|
202
308
|
|
|
203
309
|
{/* Guestbook Tab */}
|
|
@@ -215,6 +321,7 @@ export default function App() {
|
|
|
215
321
|
className="flex-1 px-3 py-2 bg-zinc-900 rounded-lg text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
|
|
216
322
|
/>
|
|
217
323
|
<button
|
|
324
|
+
type="button"
|
|
218
325
|
onClick={handleAddGuestbookEntry}
|
|
219
326
|
disabled={!newMessage.trim() || addEntry.isPending}
|
|
220
327
|
className="px-4 py-2 bg-violet-600 hover:bg-violet-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
|
@@ -224,7 +331,9 @@ export default function App() {
|
|
|
224
331
|
</div>
|
|
225
332
|
|
|
226
333
|
{/* Error */}
|
|
227
|
-
{addEntry.isError &&
|
|
334
|
+
{addEntry.isError && addEntry.error && (
|
|
335
|
+
<p className="text-red-400 text-xs">{addEntry.error.message}</p>
|
|
336
|
+
)}
|
|
228
337
|
|
|
229
338
|
{/* Entries */}
|
|
230
339
|
{guestbookLoading && <p className="text-zinc-500 text-sm">Loading...</p>}
|
|
@@ -295,6 +404,7 @@ export default function App() {
|
|
|
295
404
|
)}
|
|
296
405
|
|
|
297
406
|
<button
|
|
407
|
+
type="button"
|
|
298
408
|
onClick={() => context?.user?.fid && fetchNotifications(context.user.fid)}
|
|
299
409
|
disabled={notifLoading}
|
|
300
410
|
className="w-full py-2 px-4 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
|
@@ -304,14 +414,155 @@ export default function App() {
|
|
|
304
414
|
</div>
|
|
305
415
|
)}
|
|
306
416
|
|
|
417
|
+
{/* AI Tab */}
|
|
418
|
+
{activeTab === "ai" && (
|
|
419
|
+
<div className="flex flex-col gap-4">
|
|
420
|
+
{/* Profile Analysis - Dedicated Endpoint Pattern */}
|
|
421
|
+
<div className="p-4 bg-zinc-900 rounded-xl">
|
|
422
|
+
<h2 className="text-lg font-semibold mb-2">What kind of Farcaster user are you?</h2>
|
|
423
|
+
<p className="text-sm text-zinc-400 mb-4">
|
|
424
|
+
Let AI analyze your profile and categorize your Farcaster activity
|
|
425
|
+
</p>
|
|
426
|
+
|
|
427
|
+
{!context?.user && (
|
|
428
|
+
<p className="text-sm text-zinc-500">User context not available</p>
|
|
429
|
+
)}
|
|
430
|
+
|
|
431
|
+
{context?.user && !aiResult && !aiLoading && (
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
onClick={analyzeProfile}
|
|
435
|
+
className="w-full py-2 px-4 bg-violet-600 hover:bg-violet-700 rounded-lg text-sm font-medium transition-colors"
|
|
436
|
+
>
|
|
437
|
+
Analyze My Profile
|
|
438
|
+
</button>
|
|
439
|
+
)}
|
|
440
|
+
|
|
441
|
+
{aiLoading && (
|
|
442
|
+
<div className="flex items-center justify-center py-8">
|
|
443
|
+
<div className="text-sm text-zinc-400">Analyzing...</div>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{aiError && (
|
|
448
|
+
<div className="p-3 bg-red-900/20 border border-red-500/20 rounded-lg">
|
|
449
|
+
<p className="text-sm text-red-400">{aiError}</p>
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
|
|
453
|
+
{aiResult && (
|
|
454
|
+
<div className="space-y-3">
|
|
455
|
+
<div className="flex items-center gap-3 p-4 bg-violet-900/20 border border-violet-500/20 rounded-lg">
|
|
456
|
+
<div className="text-3xl">
|
|
457
|
+
{aiResult.category === "builder" && "🛠️"}
|
|
458
|
+
{aiResult.category === "creator" && "🎨"}
|
|
459
|
+
{aiResult.category === "collector" && "💎"}
|
|
460
|
+
{aiResult.category === "connector" && "🤝"}
|
|
461
|
+
{aiResult.category === "lurker" && "👀"}
|
|
462
|
+
</div>
|
|
463
|
+
<div className="flex-1">
|
|
464
|
+
<p className="text-lg font-semibold capitalize">{aiResult.category}</p>
|
|
465
|
+
<p className="text-sm text-zinc-300 mt-1">{aiResult.reason}</p>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
onClick={analyzeProfile}
|
|
471
|
+
disabled={aiLoading}
|
|
472
|
+
className="w-full py-2 px-4 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
|
473
|
+
>
|
|
474
|
+
Analyze Again
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
)}
|
|
478
|
+
</div>
|
|
479
|
+
|
|
480
|
+
{/* Divider */}
|
|
481
|
+
<div className="border-t border-zinc-800" />
|
|
482
|
+
|
|
483
|
+
{/* Cast Idea Generator - useAI Hook Pattern */}
|
|
484
|
+
<div className="p-4 bg-zinc-900 rounded-xl">
|
|
485
|
+
<h2 className="text-lg font-semibold mb-2">Need a cast idea?</h2>
|
|
486
|
+
<p className="text-sm text-zinc-400 mb-4">Generate creative cast ideas with AI</p>
|
|
487
|
+
|
|
488
|
+
{!castIdea && !castIdeaLoading && (
|
|
489
|
+
<button
|
|
490
|
+
type="button"
|
|
491
|
+
onClick={generateCastIdea}
|
|
492
|
+
className="w-full py-2 px-4 bg-violet-600 hover:bg-violet-700 rounded-lg text-sm font-medium transition-colors"
|
|
493
|
+
>
|
|
494
|
+
Generate Cast Idea
|
|
495
|
+
</button>
|
|
496
|
+
)}
|
|
497
|
+
|
|
498
|
+
{castIdeaLoading && (
|
|
499
|
+
<div className="flex items-center justify-center py-8">
|
|
500
|
+
<div className="text-sm text-zinc-400">Generating...</div>
|
|
501
|
+
</div>
|
|
502
|
+
)}
|
|
503
|
+
|
|
504
|
+
{castIdeaError && (
|
|
505
|
+
<div className="p-3 bg-red-900/20 border border-red-500/20 rounded-lg">
|
|
506
|
+
<p className="text-sm text-red-400">{castIdeaError}</p>
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
|
|
510
|
+
{castIdea && (
|
|
511
|
+
<div className="space-y-3">
|
|
512
|
+
<div className="p-4 bg-zinc-800 rounded-lg">
|
|
513
|
+
<p className="text-sm text-zinc-100 leading-relaxed">{castIdea}</p>
|
|
514
|
+
</div>
|
|
515
|
+
<div className="flex gap-2">
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
onClick={copyCastIdea}
|
|
519
|
+
className="flex-1 py-2 px-4 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-medium transition-colors"
|
|
520
|
+
>
|
|
521
|
+
Copy
|
|
522
|
+
</button>
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
onClick={generateCastIdea}
|
|
526
|
+
disabled={castIdeaLoading}
|
|
527
|
+
className="flex-1 py-2 px-4 bg-violet-600 hover:bg-violet-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
|
528
|
+
>
|
|
529
|
+
Generate Another
|
|
530
|
+
</button>
|
|
531
|
+
</div>
|
|
532
|
+
<p className="text-xs text-zinc-500 text-center">Powered by useAI() hook</p>
|
|
533
|
+
</div>
|
|
534
|
+
)}
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
|
|
307
539
|
{/* Close button */}
|
|
308
540
|
<button
|
|
541
|
+
type="button"
|
|
309
542
|
onClick={() => sdk.actions.close()}
|
|
310
543
|
className="w-full py-2 px-4 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-medium transition-colors"
|
|
311
544
|
>
|
|
312
545
|
Close
|
|
313
546
|
</button>
|
|
314
547
|
</div>
|
|
548
|
+
|
|
549
|
+
{/* Share Sheet */}
|
|
550
|
+
<ShareSheet
|
|
551
|
+
open={showShareSheet}
|
|
552
|
+
onClose={() => setShowShareSheet(false)}
|
|
553
|
+
text={`I just signed the jack-template guestbook! "${lastSignedMessage}"`}
|
|
554
|
+
embedUrl={getShareEmbedUrl()}
|
|
555
|
+
title="Share your signature"
|
|
556
|
+
user={
|
|
557
|
+
context?.user
|
|
558
|
+
? {
|
|
559
|
+
username: context.user.username,
|
|
560
|
+
displayName: context.user.displayName,
|
|
561
|
+
pfpUrl: context.user.pfpUrl,
|
|
562
|
+
}
|
|
563
|
+
: undefined
|
|
564
|
+
}
|
|
565
|
+
/>
|
|
315
566
|
</div>
|
|
316
567
|
);
|
|
317
568
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useShare } from "../hooks/useShare";
|
|
3
|
+
|
|
4
|
+
interface ShareSheetProps {
|
|
5
|
+
open: boolean;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
text: string;
|
|
8
|
+
embedUrl?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
user?: {
|
|
11
|
+
username: string;
|
|
12
|
+
displayName?: string;
|
|
13
|
+
pfpUrl?: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ShareSheet({
|
|
18
|
+
open,
|
|
19
|
+
onClose,
|
|
20
|
+
text,
|
|
21
|
+
embedUrl,
|
|
22
|
+
title = "Share to Warpcast",
|
|
23
|
+
user,
|
|
24
|
+
}: ShareSheetProps) {
|
|
25
|
+
const { share } = useShare();
|
|
26
|
+
const [isSharing, setIsSharing] = useState(false);
|
|
27
|
+
|
|
28
|
+
if (!open) return null;
|
|
29
|
+
|
|
30
|
+
const handleShare = async () => {
|
|
31
|
+
setIsSharing(true);
|
|
32
|
+
try {
|
|
33
|
+
const result = await share({ text, embedUrl });
|
|
34
|
+
if (result.success) {
|
|
35
|
+
onClose();
|
|
36
|
+
}
|
|
37
|
+
} finally {
|
|
38
|
+
setIsSharing(false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
43
|
+
if (e.target === e.currentTarget) {
|
|
44
|
+
onClose();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Truncate preview text if too long
|
|
49
|
+
const previewText = text.length > 280 ? text.slice(0, 277) + "..." : text;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className="fixed inset-0 z-50 bg-black/60 flex items-end justify-center sm:items-center"
|
|
54
|
+
onClick={handleBackdropClick}
|
|
55
|
+
>
|
|
56
|
+
<div className="w-full max-w-md bg-zinc-900 rounded-t-2xl sm:rounded-2xl p-4 pb-8 sm:pb-4 animate-slide-up">
|
|
57
|
+
{/* Header */}
|
|
58
|
+
<div className="flex items-center justify-between mb-4">
|
|
59
|
+
<h2 className="text-lg font-semibold text-zinc-100">{title}</h2>
|
|
60
|
+
<button
|
|
61
|
+
onClick={onClose}
|
|
62
|
+
className="p-1 text-zinc-400 hover:text-zinc-200 transition-colors"
|
|
63
|
+
aria-label="Close"
|
|
64
|
+
>
|
|
65
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
66
|
+
<path
|
|
67
|
+
strokeLinecap="round"
|
|
68
|
+
strokeLinejoin="round"
|
|
69
|
+
strokeWidth={2}
|
|
70
|
+
d="M6 18L18 6M6 6l12 12"
|
|
71
|
+
/>
|
|
72
|
+
</svg>
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Preview card */}
|
|
77
|
+
<div className="bg-zinc-800 rounded-xl p-4 mb-4">
|
|
78
|
+
{user && (
|
|
79
|
+
<div className="flex items-center gap-2 mb-3">
|
|
80
|
+
{user.pfpUrl ? (
|
|
81
|
+
<img src={user.pfpUrl} alt="" className="w-8 h-8 rounded-full" />
|
|
82
|
+
) : (
|
|
83
|
+
<div className="w-8 h-8 rounded-full bg-zinc-700" />
|
|
84
|
+
)}
|
|
85
|
+
<div>
|
|
86
|
+
<p className="text-sm font-medium text-zinc-100">
|
|
87
|
+
{user.displayName || user.username}
|
|
88
|
+
</p>
|
|
89
|
+
<p className="text-xs text-zinc-500">@{user.username}</p>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
<p className="text-sm text-zinc-300 italic whitespace-pre-wrap">{previewText}</p>
|
|
94
|
+
{embedUrl && <p className="text-xs text-zinc-500 mt-2 truncate">{embedUrl}</p>}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Actions */}
|
|
98
|
+
<div className="flex gap-3">
|
|
99
|
+
<button
|
|
100
|
+
onClick={onClose}
|
|
101
|
+
className="flex-1 py-3 px-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-xl text-sm font-medium transition-colors"
|
|
102
|
+
>
|
|
103
|
+
Cancel
|
|
104
|
+
</button>
|
|
105
|
+
<button
|
|
106
|
+
onClick={handleShare}
|
|
107
|
+
disabled={isSharing}
|
|
108
|
+
className="flex-1 py-3 px-4 bg-violet-600 hover:bg-violet-700 disabled:opacity-50 text-white rounded-xl text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
|
109
|
+
>
|
|
110
|
+
{isSharing ? (
|
|
111
|
+
<>
|
|
112
|
+
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
113
|
+
<circle
|
|
114
|
+
className="opacity-25"
|
|
115
|
+
cx="12"
|
|
116
|
+
cy="12"
|
|
117
|
+
r="10"
|
|
118
|
+
stroke="currentColor"
|
|
119
|
+
strokeWidth="4"
|
|
120
|
+
/>
|
|
121
|
+
<path
|
|
122
|
+
className="opacity-75"
|
|
123
|
+
fill="currentColor"
|
|
124
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
125
|
+
/>
|
|
126
|
+
</svg>
|
|
127
|
+
Sharing...
|
|
128
|
+
</>
|
|
129
|
+
) : (
|
|
130
|
+
<>
|
|
131
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
132
|
+
<path
|
|
133
|
+
strokeLinecap="round"
|
|
134
|
+
strokeLinejoin="round"
|
|
135
|
+
strokeWidth={2}
|
|
136
|
+
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
|
137
|
+
/>
|
|
138
|
+
</svg>
|
|
139
|
+
Share
|
|
140
|
+
</>
|
|
141
|
+
)}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useMutation } from "@tanstack/react-query";
|
|
2
|
+
import { api } from "../lib/api";
|
|
3
|
+
|
|
4
|
+
interface GenerateResult {
|
|
5
|
+
result: string;
|
|
6
|
+
provider: "openai" | "workers-ai";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useAI() {
|
|
10
|
+
const mutation = useMutation<GenerateResult, Error, { prompt: string }>({
|
|
11
|
+
mutationFn: async ({ prompt }: { prompt: string }): Promise<GenerateResult> => {
|
|
12
|
+
const res = await api.api.ai.generate.$post({
|
|
13
|
+
json: { prompt },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const error = await res.json().catch(() => ({ error: "AI request failed" }));
|
|
18
|
+
throw new Error((error as { error: string }).error || "AI request failed");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return res.json() as Promise<GenerateResult>;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const generate = async (prompt: string) => {
|
|
26
|
+
return mutation.mutateAsync({ prompt });
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
generate,
|
|
31
|
+
isLoading: mutation.isPending,
|
|
32
|
+
error: mutation.error?.message ?? null,
|
|
33
|
+
reset: mutation.reset,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -28,7 +28,17 @@ export function useGuestbook() {
|
|
|
28
28
|
export function useAddGuestbookEntry() {
|
|
29
29
|
const qc = useQueryClient();
|
|
30
30
|
|
|
31
|
-
return useMutation
|
|
31
|
+
return useMutation<
|
|
32
|
+
{ entry: GuestbookEntry },
|
|
33
|
+
Error,
|
|
34
|
+
{
|
|
35
|
+
fid: number;
|
|
36
|
+
username: string;
|
|
37
|
+
displayName?: string;
|
|
38
|
+
pfpUrl?: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
>({
|
|
32
42
|
mutationFn: async (entry: {
|
|
33
43
|
fid: number;
|
|
34
44
|
username: string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { sdk } from "@farcaster/miniapp-sdk";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
|
|
4
|
+
const MAX_CAST_LENGTH = 320;
|
|
5
|
+
|
|
6
|
+
export interface ShareOptions {
|
|
7
|
+
text: string;
|
|
8
|
+
embedUrl?: string;
|
|
9
|
+
channelKey?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ShareResult {
|
|
13
|
+
success: boolean;
|
|
14
|
+
castHash?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function truncateText(text: string, maxLength: number): string {
|
|
18
|
+
if (text.length <= maxLength) return text;
|
|
19
|
+
return text.slice(0, maxLength - 1) + "…";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useShare() {
|
|
23
|
+
// Check if we're in a miniapp context
|
|
24
|
+
const isMiniApp = useMemo(() => {
|
|
25
|
+
try {
|
|
26
|
+
return typeof sdk !== "undefined" && sdk.context !== undefined;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const share = useCallback(
|
|
33
|
+
async (options: ShareOptions): Promise<ShareResult> => {
|
|
34
|
+
const { embedUrl, channelKey } = options;
|
|
35
|
+
const text = truncateText(options.text, MAX_CAST_LENGTH);
|
|
36
|
+
|
|
37
|
+
// Build embeds - SDK expects tuple of 0-2 strings
|
|
38
|
+
const embeds = embedUrl ? ([embedUrl] as [string]) : undefined;
|
|
39
|
+
|
|
40
|
+
if (isMiniApp) {
|
|
41
|
+
try {
|
|
42
|
+
const result = await sdk.actions.composeCast({
|
|
43
|
+
text,
|
|
44
|
+
embeds,
|
|
45
|
+
channelKey,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// User may cancel the compose
|
|
49
|
+
if (result?.cast) {
|
|
50
|
+
return { success: true, castHash: result.cast.hash };
|
|
51
|
+
}
|
|
52
|
+
return { success: false };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error("Failed to compose cast:", error);
|
|
55
|
+
return { success: false };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fallback: open Farcaster compose URL
|
|
60
|
+
const url = new URL("https://farcaster.xyz/~/compose");
|
|
61
|
+
url.searchParams.set("text", text);
|
|
62
|
+
if (embedUrl) {
|
|
63
|
+
url.searchParams.append("embeds[]", embedUrl);
|
|
64
|
+
}
|
|
65
|
+
if (channelKey) {
|
|
66
|
+
url.searchParams.set("channelKey", channelKey);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
window.open(url.toString(), "_blank");
|
|
70
|
+
return { success: true };
|
|
71
|
+
},
|
|
72
|
+
[isMiniApp],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return { share, isMiniApp };
|
|
76
|
+
}
|