@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.
Files changed (91) hide show
  1. package/README.md +77 -29
  2. package/package.json +54 -47
  3. package/src/commands/agents.ts +145 -10
  4. package/src/commands/down.ts +110 -102
  5. package/src/commands/feedback.ts +189 -0
  6. package/src/commands/init.ts +8 -12
  7. package/src/commands/login.ts +88 -0
  8. package/src/commands/logout.ts +14 -0
  9. package/src/commands/logs.ts +21 -0
  10. package/src/commands/mcp.ts +134 -7
  11. package/src/commands/new.ts +43 -17
  12. package/src/commands/open.ts +13 -6
  13. package/src/commands/projects.ts +269 -143
  14. package/src/commands/secrets.ts +413 -0
  15. package/src/commands/services.ts +96 -123
  16. package/src/commands/ship.ts +5 -1
  17. package/src/commands/whoami.ts +31 -0
  18. package/src/index.ts +218 -144
  19. package/src/lib/agent-files.ts +34 -0
  20. package/src/lib/agents.ts +390 -22
  21. package/src/lib/asset-hash.ts +50 -0
  22. package/src/lib/auth/client.ts +115 -0
  23. package/src/lib/auth/constants.ts +5 -0
  24. package/src/lib/auth/guard.ts +57 -0
  25. package/src/lib/auth/index.ts +18 -0
  26. package/src/lib/auth/store.ts +54 -0
  27. package/src/lib/binding-validator.ts +136 -0
  28. package/src/lib/build-helper.ts +211 -0
  29. package/src/lib/cloudflare-api.ts +24 -0
  30. package/src/lib/config.ts +5 -6
  31. package/src/lib/control-plane.ts +295 -0
  32. package/src/lib/debug.ts +3 -1
  33. package/src/lib/deploy-mode.ts +93 -0
  34. package/src/lib/deploy-upload.ts +92 -0
  35. package/src/lib/errors.ts +2 -0
  36. package/src/lib/github.ts +31 -1
  37. package/src/lib/hooks.ts +4 -12
  38. package/src/lib/intent.ts +88 -0
  39. package/src/lib/jsonc.ts +125 -0
  40. package/src/lib/local-paths.test.ts +902 -0
  41. package/src/lib/local-paths.ts +258 -0
  42. package/src/lib/managed-deploy.ts +175 -0
  43. package/src/lib/managed-down.ts +159 -0
  44. package/src/lib/mcp-config.ts +55 -34
  45. package/src/lib/names.ts +9 -29
  46. package/src/lib/project-operations.ts +676 -249
  47. package/src/lib/project-resolver.ts +476 -0
  48. package/src/lib/registry.ts +76 -37
  49. package/src/lib/resources.ts +196 -0
  50. package/src/lib/schema.ts +30 -1
  51. package/src/lib/storage/file-filter.ts +1 -0
  52. package/src/lib/storage/index.ts +5 -1
  53. package/src/lib/telemetry.ts +14 -0
  54. package/src/lib/tty.ts +15 -0
  55. package/src/lib/zip-packager.ts +255 -0
  56. package/src/mcp/resources/index.ts +8 -2
  57. package/src/mcp/server.ts +32 -4
  58. package/src/mcp/tools/index.ts +35 -13
  59. package/src/mcp/types.ts +6 -0
  60. package/src/mcp/utils.ts +1 -1
  61. package/src/templates/index.ts +42 -4
  62. package/src/templates/types.ts +13 -0
  63. package/templates/CLAUDE.md +166 -0
  64. package/templates/api/.jack.json +4 -0
  65. package/templates/api/bun.lock +1 -0
  66. package/templates/api/wrangler.jsonc +5 -0
  67. package/templates/hello/.jack.json +28 -0
  68. package/templates/hello/package.json +10 -0
  69. package/templates/hello/src/index.ts +11 -0
  70. package/templates/hello/tsconfig.json +11 -0
  71. package/templates/hello/wrangler.jsonc +5 -0
  72. package/templates/miniapp/.jack.json +15 -4
  73. package/templates/miniapp/bun.lock +135 -40
  74. package/templates/miniapp/index.html +1 -0
  75. package/templates/miniapp/package.json +3 -1
  76. package/templates/miniapp/public/.well-known/farcaster.json +7 -5
  77. package/templates/miniapp/public/icon.png +0 -0
  78. package/templates/miniapp/public/og.png +0 -0
  79. package/templates/miniapp/schema.sql +8 -0
  80. package/templates/miniapp/src/App.tsx +254 -3
  81. package/templates/miniapp/src/components/ShareSheet.tsx +147 -0
  82. package/templates/miniapp/src/hooks/useAI.ts +35 -0
  83. package/templates/miniapp/src/hooks/useGuestbook.ts +11 -1
  84. package/templates/miniapp/src/hooks/useShare.ts +76 -0
  85. package/templates/miniapp/src/index.css +15 -0
  86. package/templates/miniapp/src/lib/api.ts +2 -1
  87. package/templates/miniapp/src/worker.ts +515 -1
  88. package/templates/miniapp/wrangler.jsonc +15 -3
  89. package/LICENSE +0 -190
  90. package/src/commands/cloud.ts +0 -230
  91. 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
- "frame": {
8
- "version": "next",
7
+ "miniapp": {
8
+ "version": "1",
9
9
  "name": "jack-template",
10
- "iconUrl": "https://placehold.co/200x200/7c3aed/white?text=jack-template",
11
- "splashImageUrl": "https://placehold.co/200x200/7c3aed/white?text=jack-template",
10
+ "iconUrl": "/icon.png",
11
+ "homeUrl": "/",
12
+ "imageUrl": "/og.png",
13
+ "splashImageUrl": "/icon.png",
12
14
  "splashBackgroundColor": "#0a0a0a",
13
- "homeUrl": "https://jack-template.workers.dev"
15
+ "buttonTitle": "Open App"
14
16
  }
15
17
  }
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: newMessage.trim(),
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 && <p className="text-red-400 text-xs">{addEntry.error.message}</p>}
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
+ }