proto-plugin 0.1.0

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 (209) hide show
  1. package/AGENTS.md +350 -0
  2. package/README.md +279 -0
  3. package/components.json +18 -0
  4. package/package.json +73 -0
  5. package/scripts/download-mobbin-reference-images.mjs +112 -0
  6. package/scripts/lib/host-config.example.sh +48 -0
  7. package/scripts/lib/host-config.sh +25 -0
  8. package/scripts/lib/resolve-host-root.mjs +18 -0
  9. package/scripts/lib/source-path.sh +49 -0
  10. package/scripts/link-source-db.sh +128 -0
  11. package/scripts/link-source.sh +40 -0
  12. package/scripts/publish-package.sh +147 -0
  13. package/scripts/share-prototype.sh +279 -0
  14. package/scripts/sync-from-source.sh +74 -0
  15. package/scripts/verify-prototype-component-ids.mjs +151 -0
  16. package/scripts/verify-prototype-preview-states.mjs +135 -0
  17. package/src/app.ts +4 -0
  18. package/src/components/platform-ui/code-block.tsx +21 -0
  19. package/src/components/platform-ui/controls-panel-options.tsx +71 -0
  20. package/src/components/platform-ui/controls-panel-select.tsx +438 -0
  21. package/src/components/platform-ui/empty-state.tsx +15 -0
  22. package/src/components/platform-ui/icon-button.tsx +18 -0
  23. package/src/components/platform-ui/panel-select.tsx +123 -0
  24. package/src/components/platform-ui/panel.tsx +97 -0
  25. package/src/components/platform-ui/review-dropdown-menu.tsx +35 -0
  26. package/src/components/platform-ui/sidebar.tsx +293 -0
  27. package/src/components/platform-ui/toolbar-icon-button.tsx +34 -0
  28. package/src/components/platform-ui/toolbar.tsx +37 -0
  29. package/src/components/prototype-gallery-client.tsx +57 -0
  30. package/src/components/prototype-provider.tsx +20 -0
  31. package/src/components/prototypes/mini-pill-label.tsx +37 -0
  32. package/src/components/prototypes/prototype-change-log-panel.tsx +207 -0
  33. package/src/components/prototypes/prototype-comment-provider.tsx +61 -0
  34. package/src/components/prototypes/prototype-comment-toolbar.tsx +65 -0
  35. package/src/components/prototypes/prototype-control.tsx +31 -0
  36. package/src/components/prototypes/prototype-controls.tsx +553 -0
  37. package/src/components/prototypes/prototype-create-exploration-modal.tsx +117 -0
  38. package/src/components/prototypes/prototype-create-state-modal.tsx +117 -0
  39. package/src/components/prototypes/prototype-default-state-map-page.tsx +66 -0
  40. package/src/components/prototypes/prototype-design-brief-modal.tsx +128 -0
  41. package/src/components/prototypes/prototype-design-brief-panel.tsx +525 -0
  42. package/src/components/prototypes/prototype-design-context-panel.tsx +65 -0
  43. package/src/components/prototypes/prototype-floating-pill.module.scss +1341 -0
  44. package/src/components/prototypes/prototype-mobbin-gallery.tsx +330 -0
  45. package/src/components/prototypes/prototype-reference-docs.tsx +222 -0
  46. package/src/components/prototypes/prototype-review-chrome.module.scss +630 -0
  47. package/src/components/prototypes/prototype-review-chrome.tsx +1241 -0
  48. package/src/components/prototypes/prototype-review-dropdown-menu-item.tsx +1 -0
  49. package/src/components/prototypes/prototype-review-dropdown-menu-separator.tsx +1 -0
  50. package/src/components/prototypes/prototype-review-panel-menu-item.tsx +1 -0
  51. package/src/components/prototypes/prototype-review-panel-menu-separator.tsx +1 -0
  52. package/src/components/prototypes/prototype-review-panel-select.tsx +4 -0
  53. package/src/components/prototypes/prototype-review-sidebar.tsx +167 -0
  54. package/src/components/prototypes/prototype-share-link-restore.tsx +75 -0
  55. package/src/components/prototypes/prototype-share-mode.tsx +206 -0
  56. package/src/components/prototypes/prototype-spec-panel-content.tsx +538 -0
  57. package/src/components/prototypes/prototype-state-canvas-overlay.module.scss +336 -0
  58. package/src/components/prototypes/prototype-state-canvas-overlay.tsx +1023 -0
  59. package/src/components/prototypes/prototype-state-canvas.tsx +38 -0
  60. package/src/components/prototypes/prototype-state-screenshot-preview.tsx +47 -0
  61. package/src/components/prototypes/prototype-target.tsx +120 -0
  62. package/src/components/prototypes/prototype-tweak-highlight.tsx +132 -0
  63. package/src/components/prototypes/prototype-tweaks.tsx +48 -0
  64. package/src/components/prototypes/prototype-variant-explorer.tsx +347 -0
  65. package/src/components/prototypes/prototype-variant-rationale-panel.tsx +49 -0
  66. package/src/components/prototypes/prototype-variant-tabs.tsx +207 -0
  67. package/src/components/prototypes/prototype-vercel-preview-modal.tsx +120 -0
  68. package/src/components/prototypes/state-map-annotation-tooltip.module.scss +89 -0
  69. package/src/components/prototypes/state-map-annotation-tooltip.tsx +137 -0
  70. package/src/components/prototypes/state-map-wireframe-highlight.module.scss +25 -0
  71. package/src/components/prototypes/state-map-wireframe-highlight.tsx +82 -0
  72. package/src/components/shell/prototype-gallery-shell.tsx +79 -0
  73. package/src/components/shell/prototype-screenshot-capture.tsx +45 -0
  74. package/src/components/shell/prototype-shell.module.scss +194 -0
  75. package/src/components/shell/prototype-shell.tsx +160 -0
  76. package/src/components/shell/prototype-state-screenshot-capture.tsx +68 -0
  77. package/src/components/ui/button.tsx +58 -0
  78. package/src/components/ui/dialog.module.scss +81 -0
  79. package/src/components/ui/dialog.tsx +127 -0
  80. package/src/components/ui/dropdown-menu.tsx +200 -0
  81. package/src/components/ui/input.tsx +22 -0
  82. package/src/components/ui/label.tsx +26 -0
  83. package/src/components/ui/option-select.tsx +81 -0
  84. package/src/components/ui/scroll-area.tsx +48 -0
  85. package/src/components/ui/separator.tsx +31 -0
  86. package/src/components/ui/slider.tsx +28 -0
  87. package/src/components/ui/switch.tsx +29 -0
  88. package/src/components/ui/tabs.tsx +55 -0
  89. package/src/components/ui/toggle-group.tsx +80 -0
  90. package/src/components/ui/tooltip.tsx +32 -0
  91. package/src/config/create-prototype-gallery-page.tsx +65 -0
  92. package/src/config/create-prototype-host-app.tsx +121 -0
  93. package/src/config/create-prototype-page.tsx +41 -0
  94. package/src/config/create-prototype-site-layout.tsx +28 -0
  95. package/src/config/define-prototype-config.ts +7 -0
  96. package/src/config/prototype-icon.tsx +25 -0
  97. package/src/config/prototype-site-layout-client.tsx +35 -0
  98. package/src/config/with-prototype.ts +20 -0
  99. package/src/index.ts +303 -0
  100. package/src/lib/pr-split/build-pr-agent-prompt.ts +68 -0
  101. package/src/lib/pr-split/build-pr-split-prototype-url.ts +12 -0
  102. package/src/lib/pr-split/pr-split-highlight.ts +6 -0
  103. package/src/lib/pr-split/pr-split-types.ts +58 -0
  104. package/src/lib/pr-split/resolve-source-preview-url.ts +45 -0
  105. package/src/lib/pr-split/use-pr-vercel-previews.ts +69 -0
  106. package/src/lib/prototype-comments/core/annotation-channel.ts +41 -0
  107. package/src/lib/prototype-comments/core/annotation-status.ts +60 -0
  108. package/src/lib/prototype-comments/core/annotation-target.ts +567 -0
  109. package/src/lib/prototype-comments/core/capture-theme.ts +41 -0
  110. package/src/lib/prototype-comments/core/capture.ts +11 -0
  111. package/src/lib/prototype-comments/core/comment-capture-blocked.ts +47 -0
  112. package/src/lib/prototype-comments/core/comment-threads.ts +52 -0
  113. package/src/lib/prototype-comments/core/element-identification.ts +615 -0
  114. package/src/lib/prototype-comments/core/export.ts +35 -0
  115. package/src/lib/prototype-comments/core/freeze-animations.ts +266 -0
  116. package/src/lib/prototype-comments/core/identify-element.ts +76 -0
  117. package/src/lib/prototype-comments/core/normalize-viewport.ts +35 -0
  118. package/src/lib/prototype-comments/core/react-detection.ts +704 -0
  119. package/src/lib/prototype-comments/core/restore.ts +7 -0
  120. package/src/lib/prototype-comments/core/screenshot.ts +428 -0
  121. package/src/lib/prototype-comments/core/source-location.ts +906 -0
  122. package/src/lib/prototype-comments/core/storage.ts +80 -0
  123. package/src/lib/prototype-comments/core/types.ts +79 -0
  124. package/src/lib/prototype-comments/core/validation.ts +17 -0
  125. package/src/lib/prototype-comments/hooks/useLayoutShift.ts +61 -0
  126. package/src/lib/prototype-comments/index.ts +58 -0
  127. package/src/lib/prototype-comments/react/CommentCaptureToolbar.tsx +1398 -0
  128. package/src/lib/prototype-comments/react/CommentProvider.tsx +298 -0
  129. package/src/lib/prototype-comments/scss.d.ts +10 -0
  130. package/src/lib/prototype-comments/ui/CommentAnnotationOverlay.tsx +253 -0
  131. package/src/lib/prototype-comments/ui/CommentDetailPage.tsx +56 -0
  132. package/src/lib/prototype-comments/ui/CommentHeaderCount.tsx +28 -0
  133. package/src/lib/prototype-comments/ui/CommentThread.tsx +331 -0
  134. package/src/lib/prototype-comments/ui/CommentsGrid.tsx +780 -0
  135. package/src/lib/prototype-comments/ui/CommentsListPage.tsx +25 -0
  136. package/src/lib/prototype-comments/ui/CommentsSidebar.tsx +77 -0
  137. package/src/lib/prototype-comments/ui/annotation-marker/index.tsx +200 -0
  138. package/src/lib/prototype-comments/ui/annotation-marker/styles.module.scss +194 -0
  139. package/src/lib/prototype-comments/ui/annotation-popup/index.tsx +335 -0
  140. package/src/lib/prototype-comments/ui/annotation-popup/styles.module.scss +469 -0
  141. package/src/lib/prototype-comments/ui/capture-styles.module.scss +2225 -0
  142. package/src/lib/prototype-comments/ui/comment-highlight.ts +48 -0
  143. package/src/lib/prototype-comments/ui/comment-thread.module.scss +203 -0
  144. package/src/lib/prototype-comments/ui/comments-grid-card.module.scss +253 -0
  145. package/src/lib/prototype-comments/ui/comments-sidebar.module.scss +123 -0
  146. package/src/lib/prototype-comments/ui/icon-transitions.module.scss +51 -0
  147. package/src/lib/prototype-comments/ui/icons.tsx +1086 -0
  148. package/src/lib/prototype-comments/ui/is-comment-detail-path.ts +7 -0
  149. package/src/lib/prototypes/capture-prototype-viewport.ts +32 -0
  150. package/src/lib/prototypes/changelog-meta-context.tsx +162 -0
  151. package/src/lib/prototypes/changelog-meta-storage.ts +62 -0
  152. package/src/lib/prototypes/changelog-meta.ts +95 -0
  153. package/src/lib/prototypes/create-prototype-registry.ts +74 -0
  154. package/src/lib/prototypes/create-state-screenshot-thumbnail.ts +46 -0
  155. package/src/lib/prototypes/design-exploration-default-storage.ts +68 -0
  156. package/src/lib/prototypes/design-exploration-default.ts +51 -0
  157. package/src/lib/prototypes/design-exploration-types.ts +203 -0
  158. package/src/lib/prototypes/prototype-comment-registry.tsx +145 -0
  159. package/src/lib/prototypes/prototype-comment-review-bridge.tsx +40 -0
  160. package/src/lib/prototypes/prototype-comment-review-state.ts +57 -0
  161. package/src/lib/prototypes/prototype-component-registry.ts +40 -0
  162. package/src/lib/prototypes/prototype-config-types.ts +34 -0
  163. package/src/lib/prototypes/prototype-preview-state-registry.ts +443 -0
  164. package/src/lib/prototypes/prototype-preview-state-types.ts +89 -0
  165. package/src/lib/prototypes/prototype-review-context.tsx +905 -0
  166. package/src/lib/prototypes/prototype-review-url.ts +134 -0
  167. package/src/lib/prototypes/prototype-share-link.ts +175 -0
  168. package/src/lib/prototypes/prototype-state-canvas-constants.ts +118 -0
  169. package/src/lib/prototypes/prototype-state-canvas-types.ts +159 -0
  170. package/src/lib/prototypes/prototype-state-screenshot.ts +39 -0
  171. package/src/lib/prototypes/prototype-tweak-registry.ts +96 -0
  172. package/src/lib/prototypes/prototype-tweak-types.ts +31 -0
  173. package/src/lib/prototypes/reference-docs.ts +137 -0
  174. package/src/lib/prototypes/screenshot-manifest.ts +33 -0
  175. package/src/lib/prototypes/screenshot-src.ts +7 -0
  176. package/src/lib/prototypes/share-command.ts +8 -0
  177. package/src/lib/prototypes/state-map-annotation.ts +70 -0
  178. package/src/lib/prototypes/use-design-exploration-default.ts +114 -0
  179. package/src/lib/prototypes/use-persisted-local-state.ts +140 -0
  180. package/src/lib/prototypes/use-prototype-comments.ts +63 -0
  181. package/src/lib/prototypes/use-prototype-mobile-viewport-frame.ts +46 -0
  182. package/src/lib/prototypes/use-prototype-state-screenshot-src.ts +71 -0
  183. package/src/lib/prototypes/use-prototype-tool-theme.tsx +115 -0
  184. package/src/lib/prototypes/use-prototype-viewport-frame.ts +34 -0
  185. package/src/lib/prototypes/use-register-prototype-reference-docs.ts +16 -0
  186. package/src/lib/prototypes/use-snap-corner-position.ts +417 -0
  187. package/src/lib/prototypes/use-toolbar-hover-panel.ts +98 -0
  188. package/src/lib/prototypes/variant-set-lucide-icon.tsx +55 -0
  189. package/src/lib/tool-portal.ts +33 -0
  190. package/src/lib/use-copy-to-clipboard.ts +81 -0
  191. package/src/lib/utils.ts +6 -0
  192. package/src/lib/vercel-preview/parse-vercel-github-comment.ts +81 -0
  193. package/src/server/create-host-api-route.ts +100 -0
  194. package/src/server/create-prototype-api-routes.ts +428 -0
  195. package/src/server/gallery/create-gallery-api.ts +56 -0
  196. package/src/server/gallery/folders-route.ts +156 -0
  197. package/src/server/gallery/gallery-env.ts +39 -0
  198. package/src/server/gallery/gallery-route.ts +150 -0
  199. package/src/server/gallery/gallery-types.ts +15 -0
  200. package/src/server/gallery/image-route.ts +59 -0
  201. package/src/server/gallery/item-route.ts +92 -0
  202. package/src/server/pr-vercel-preview-route.ts +162 -0
  203. package/src/server/redis/client.ts +55 -0
  204. package/src/server/redis/prototype-changelog-meta.ts +31 -0
  205. package/src/server/redis/prototype-comments.ts +92 -0
  206. package/src/server/redis/prototype-design-exploration-default.ts +32 -0
  207. package/src/server.ts +51 -0
  208. package/src/styles/globals.css +624 -0
  209. package/tsconfig.json +19 -0
@@ -0,0 +1,150 @@
1
+ import { del, put } from "@vercel/blob";
2
+ import { kv } from "@vercel/kv";
3
+ import { NextRequest, NextResponse } from "next/server";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ import { getGalleryStorageStatus } from "./gallery-env";
7
+ import type { GalleryItem } from "./gallery-types";
8
+
9
+ export const maxDuration = 60;
10
+
11
+ const GALLERY_KEY = "gallery:items";
12
+
13
+ export async function GET() {
14
+ const status = getGalleryStorageStatus();
15
+ if (!status.ok) {
16
+ return NextResponse.json(
17
+ {
18
+ error: "Gallery storage is not configured.",
19
+ missing: status.missing,
20
+ },
21
+ { status: 503 },
22
+ );
23
+ }
24
+
25
+ try {
26
+ const items = (await kv.get<GalleryItem[]>(GALLERY_KEY)) ?? [];
27
+ return NextResponse.json(items);
28
+ } catch (error) {
29
+ console.error("Gallery GET error:", error);
30
+ return NextResponse.json({ error: "Failed to fetch gallery" }, { status: 500 });
31
+ }
32
+ }
33
+
34
+ export async function POST(request: NextRequest) {
35
+ const status = getGalleryStorageStatus();
36
+ if (!status.ok) {
37
+ return NextResponse.json(
38
+ {
39
+ error: "Gallery storage is not configured.",
40
+ missing: status.missing,
41
+ },
42
+ { status: 503 },
43
+ );
44
+ }
45
+
46
+ try {
47
+ const formData = await request.formData();
48
+ const file = formData.get("file") as File | null;
49
+ const originalFile = formData.get("originalFile") as File | null;
50
+ const title = (formData.get("title") as string) || "Untitled";
51
+ const tagsRaw = formData.get("tags") as string | null;
52
+ const width = parseInt(formData.get("width") as string, 10) || 0;
53
+ const height = parseInt(formData.get("height") as string, 10) || 0;
54
+ const sourceFrameId = (formData.get("sourceFrameId") as string) || undefined;
55
+
56
+ if (!file || !(file instanceof Blob)) {
57
+ return NextResponse.json({ error: "No image file provided" }, { status: 400 });
58
+ }
59
+
60
+ const tags: string[] = tagsRaw
61
+ ? (JSON.parse(tagsRaw) as string[]).filter(
62
+ (t) => typeof t === "string" && t.trim().length > 0,
63
+ )
64
+ : [];
65
+
66
+ const id = randomUUID();
67
+
68
+ const contentType = file.type === "image/jpeg" ? "image/jpeg" : "image/png";
69
+ const ext = contentType === "image/jpeg" ? "jpg" : "png";
70
+ const fileBuffer = Buffer.from(await file.arrayBuffer());
71
+ const resultBlob = await put(`gallery/${id}.${ext}`, fileBuffer, {
72
+ access: "public",
73
+ contentType,
74
+ });
75
+
76
+ const blobUrl = typeof resultBlob?.url === "string" ? resultBlob.url : "";
77
+ if (!blobUrl) {
78
+ console.error("Gallery POST: put() did not return a url", resultBlob);
79
+ return NextResponse.json(
80
+ { error: "Failed to get image URL from storage" },
81
+ { status: 500 },
82
+ );
83
+ }
84
+
85
+ let originalBlobUrl: string | undefined;
86
+ if (originalFile && originalFile instanceof Blob) {
87
+ const origBuffer = Buffer.from(await originalFile.arrayBuffer());
88
+ const origBlob = await put(`gallery/${id}-original.png`, origBuffer, {
89
+ access: "public",
90
+ contentType: "image/png",
91
+ });
92
+ if (typeof origBlob?.url === "string") originalBlobUrl = origBlob.url;
93
+ }
94
+
95
+ const item: GalleryItem = {
96
+ id,
97
+ blobUrl,
98
+ originalBlobUrl,
99
+ title,
100
+ tags,
101
+ width,
102
+ height,
103
+ sourceFrameId,
104
+ uploadedBy: "anonymous",
105
+ createdAt: new Date().toISOString(),
106
+ };
107
+
108
+ const items = (await kv.get<GalleryItem[]>(GALLERY_KEY)) ?? [];
109
+ items.push(item);
110
+ await kv.set(GALLERY_KEY, items);
111
+
112
+ return NextResponse.json(item);
113
+ } catch (error) {
114
+ console.error("Gallery POST error:", error);
115
+ const message = error instanceof Error ? error.message : "Failed to upload image";
116
+ return NextResponse.json({ error: message }, { status: 500 });
117
+ }
118
+ }
119
+
120
+ /** DELETE: clear all gallery items (KV + Blob). No auth. */
121
+ export async function DELETE() {
122
+ const status = getGalleryStorageStatus();
123
+ if (!status.ok) {
124
+ return NextResponse.json(
125
+ {
126
+ error: "Gallery storage is not configured.",
127
+ missing: status.missing,
128
+ },
129
+ { status: 503 },
130
+ );
131
+ }
132
+
133
+ try {
134
+ const items = (await kv.get<GalleryItem[]>(GALLERY_KEY)) ?? [];
135
+ const urlsToDelete: string[] = [];
136
+ for (const item of items) {
137
+ if (item.blobUrl) urlsToDelete.push(item.blobUrl);
138
+ if (item.originalBlobUrl) urlsToDelete.push(item.originalBlobUrl);
139
+ }
140
+ if (urlsToDelete.length > 0) {
141
+ await del(urlsToDelete);
142
+ }
143
+ await kv.set(GALLERY_KEY, []);
144
+ return NextResponse.json({ ok: true, deleted: items.length });
145
+ } catch (error) {
146
+ console.error("Gallery DELETE error:", error);
147
+ const message = error instanceof Error ? error.message : "Failed to clear gallery";
148
+ return NextResponse.json({ error: message }, { status: 500 });
149
+ }
150
+ }
@@ -0,0 +1,15 @@
1
+ /** Marker type — gallery routes enabled when config is present (not `false`). */
2
+ export type GalleryConfig = Record<string, never>;
3
+
4
+ export interface GalleryItem {
5
+ id: string;
6
+ blobUrl: string;
7
+ originalBlobUrl?: string;
8
+ title: string;
9
+ tags: string[];
10
+ width: number;
11
+ height: number;
12
+ sourceFrameId?: string;
13
+ uploadedBy: string;
14
+ createdAt: string;
15
+ }
@@ -0,0 +1,59 @@
1
+ import { kv } from "@vercel/kv";
2
+ import { NextRequest, NextResponse } from "next/server";
3
+
4
+ import { getGalleryKvStatus } from "./gallery-env";
5
+
6
+ const GALLERY_KEY = "gallery:items";
7
+
8
+ interface GalleryItemStored {
9
+ id?: string;
10
+ blobUrl?: string;
11
+ blob_url?: string;
12
+ originalBlobUrl?: string;
13
+ original_blob_url?: string;
14
+ }
15
+
16
+ export async function GET(
17
+ _request: NextRequest,
18
+ { params }: { params: Promise<{ id: string }> },
19
+ ) {
20
+ const status = getGalleryKvStatus();
21
+ if (!status.ok) {
22
+ return NextResponse.json({ error: "Gallery not configured" }, { status: 503 });
23
+ }
24
+
25
+ const { id } = await params;
26
+ if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
27
+
28
+ try {
29
+ const items = (await kv.get<GalleryItemStored[]>(GALLERY_KEY)) ?? [];
30
+ const item = items.find((i) => String(i?.id) === id);
31
+ const blobUrl = item?.blobUrl ?? item?.blob_url;
32
+ if (!blobUrl) {
33
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
34
+ }
35
+ const imageUrl = blobUrl;
36
+ const headers: Record<string, string> = { "User-Agent": "PrototypeGallery/1.0" };
37
+ const blobToken = process.env.BLOB_READ_WRITE_TOKEN;
38
+ if (blobToken && imageUrl.includes("blob.vercel-storage.com")) {
39
+ headers["Authorization"] = `Bearer ${blobToken}`;
40
+ }
41
+ const res = await fetch(imageUrl, { cache: "no-store", headers });
42
+ if (!res.ok) {
43
+ console.error("Gallery image proxy: fetch failed", res.status, imageUrl.slice(0, 80));
44
+ return NextResponse.json({ error: "Failed to fetch image" }, { status: 502 });
45
+ }
46
+ const blob = await res.arrayBuffer();
47
+ const contentType = res.headers.get("content-type") || "image/png";
48
+ return new NextResponse(blob, {
49
+ headers: {
50
+ "Content-Type": contentType,
51
+ "Cache-Control": "public, max-age=300",
52
+ Vary: "Accept",
53
+ },
54
+ });
55
+ } catch (error) {
56
+ console.error("Gallery image proxy error:", error);
57
+ return NextResponse.json({ error: "Failed to load image" }, { status: 500 });
58
+ }
59
+ }
@@ -0,0 +1,92 @@
1
+ import { del } from "@vercel/blob";
2
+ import { kv } from "@vercel/kv";
3
+ import { NextRequest, NextResponse } from "next/server";
4
+
5
+ import { getGalleryKvStatus, getGalleryStorageStatus } from "./gallery-env";
6
+
7
+ const GALLERY_KEY = "gallery:items";
8
+
9
+ interface GalleryItemStored {
10
+ id?: string;
11
+ blobUrl?: string;
12
+ originalBlobUrl?: string;
13
+ tags?: string[];
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ export async function PATCH(
18
+ request: NextRequest,
19
+ { params }: { params: Promise<{ id: string }> },
20
+ ) {
21
+ const status = getGalleryKvStatus();
22
+ if (!status.ok) {
23
+ return NextResponse.json({ error: "Gallery not configured" }, { status: 503 });
24
+ }
25
+
26
+ const { id } = await params;
27
+ if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
28
+
29
+ try {
30
+ const body = await request.json();
31
+ const newTags: string[] | undefined = body.tags;
32
+ if (!newTags || !Array.isArray(newTags)) {
33
+ return NextResponse.json({ error: "tags array required" }, { status: 400 });
34
+ }
35
+ const validTags = newTags.filter((t) => typeof t === "string" && t.trim().length > 0);
36
+ if (validTags.length === 0) {
37
+ return NextResponse.json({ error: "At least one tag required" }, { status: 400 });
38
+ }
39
+
40
+ const items = (await kv.get<GalleryItemStored[]>(GALLERY_KEY)) ?? [];
41
+ const idx = items.findIndex((i) => String(i?.id) === id);
42
+ if (idx === -1) {
43
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
44
+ }
45
+
46
+ items[idx] = { ...items[idx], tags: validTags };
47
+ await kv.set(GALLERY_KEY, items);
48
+
49
+ return NextResponse.json({ ok: true, id, tags: validTags });
50
+ } catch (error) {
51
+ console.error("Gallery item PATCH error:", error);
52
+ const message = error instanceof Error ? error.message : "Failed to update item";
53
+ return NextResponse.json({ error: message }, { status: 500 });
54
+ }
55
+ }
56
+
57
+ export async function DELETE(
58
+ _request: NextRequest,
59
+ { params }: { params: Promise<{ id: string }> },
60
+ ) {
61
+ const status = getGalleryStorageStatus();
62
+ if (!status.ok) {
63
+ return NextResponse.json({ error: "Gallery not configured" }, { status: 503 });
64
+ }
65
+
66
+ const { id } = await params;
67
+ if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
68
+
69
+ try {
70
+ const items = (await kv.get<GalleryItemStored[]>(GALLERY_KEY)) ?? [];
71
+ const item = items.find((i) => String(i?.id) === id);
72
+ if (!item) {
73
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
74
+ }
75
+
76
+ const urlsToDelete: string[] = [];
77
+ if (item.blobUrl) urlsToDelete.push(item.blobUrl);
78
+ if (item.originalBlobUrl) urlsToDelete.push(item.originalBlobUrl);
79
+ if (urlsToDelete.length > 0) {
80
+ await del(urlsToDelete);
81
+ }
82
+
83
+ const remaining = items.filter((i) => String(i?.id) !== id);
84
+ await kv.set(GALLERY_KEY, remaining);
85
+
86
+ return NextResponse.json({ ok: true, id });
87
+ } catch (error) {
88
+ console.error("Gallery item DELETE error:", error);
89
+ const message = error instanceof Error ? error.message : "Failed to delete item";
90
+ return NextResponse.json({ error: message }, { status: 500 });
91
+ }
92
+ }
@@ -0,0 +1,162 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { NextRequest, NextResponse } from "next/server";
4
+
5
+ import {
6
+ parseGithubPrUrl,
7
+ parseVercelGithubCommentBody,
8
+ type VercelPreviewFromPr,
9
+ } from "../lib/vercel-preview/parse-vercel-github-comment";
10
+
11
+ type GithubIssueComment = {
12
+ user?: { login?: string };
13
+ body?: string;
14
+ };
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ async function fetchGithubIssueComments(
19
+ owner: string,
20
+ repo: string,
21
+ number: number,
22
+ token: string,
23
+ ): Promise<GithubIssueComment[]> {
24
+ const response = await fetch(
25
+ `https://api.github.com/repos/${owner}/${repo}/issues/${number}/comments`,
26
+ {
27
+ headers: {
28
+ Accept: "application/vnd.github+json",
29
+ Authorization: `Bearer ${token}`,
30
+ "X-GitHub-Api-Version": "2022-11-28",
31
+ },
32
+ next: { revalidate: 300 },
33
+ },
34
+ );
35
+
36
+ if (!response.ok) {
37
+ throw new Error(`GitHub API error (${response.status})`);
38
+ }
39
+
40
+ return (await response.json()) as GithubIssueComment[];
41
+ }
42
+
43
+ async function fetchGithubIssueCommentsViaGhCli(
44
+ owner: string,
45
+ repo: string,
46
+ number: number,
47
+ ): Promise<GithubIssueComment[]> {
48
+ const { stdout } = await execFileAsync(
49
+ "gh",
50
+ ["api", `repos/${owner}/${repo}/issues/${number}/comments`],
51
+ { timeout: 20_000 },
52
+ );
53
+ return JSON.parse(stdout) as GithubIssueComment[];
54
+ }
55
+
56
+ function extractVercelPreviewFromComments(
57
+ comments: GithubIssueComment[],
58
+ ): VercelPreviewFromPr | null {
59
+ for (const comment of comments) {
60
+ const login = comment.user?.login?.toLowerCase() ?? "";
61
+ if (!login.includes("vercel")) continue;
62
+ const preview = parseVercelGithubCommentBody(comment.body ?? "");
63
+ if (preview) return preview;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ async function fetchVercelPreviewForPr(
69
+ prUrl: string,
70
+ token?: string,
71
+ ): Promise<VercelPreviewFromPr | null> {
72
+ const parsed = parseGithubPrUrl(prUrl);
73
+ if (!parsed) return null;
74
+
75
+ let comments: GithubIssueComment[] | null = null;
76
+
77
+ if (token) {
78
+ try {
79
+ comments = await fetchGithubIssueComments(
80
+ parsed.owner,
81
+ parsed.repo,
82
+ parsed.number,
83
+ token,
84
+ );
85
+ } catch (error) {
86
+ console.warn("GitHub token fetch failed, trying gh CLI:", error);
87
+ }
88
+ }
89
+
90
+ if (!comments) {
91
+ try {
92
+ comments = await fetchGithubIssueCommentsViaGhCli(
93
+ parsed.owner,
94
+ parsed.repo,
95
+ parsed.number,
96
+ );
97
+ } catch (error) {
98
+ console.error("gh CLI fetch failed:", error);
99
+ return null;
100
+ }
101
+ }
102
+
103
+ return extractVercelPreviewFromComments(comments);
104
+ }
105
+
106
+ export async function GET(request: NextRequest) {
107
+ const token = process.env.GITHUB_TOKEN;
108
+
109
+ const prUrl = request.nextUrl.searchParams.get("prUrl");
110
+ const prUrlsParam = request.nextUrl.searchParams.get("prUrls");
111
+
112
+ const prUrls = [
113
+ ...(prUrl ? [prUrl] : []),
114
+ ...(prUrlsParam
115
+ ? prUrlsParam.split(",").map((value) => value.trim()).filter(Boolean)
116
+ : []),
117
+ ];
118
+
119
+ if (prUrls.length === 0) {
120
+ return NextResponse.json(
121
+ { error: "Provide prUrl or prUrls query parameter." },
122
+ { status: 400 },
123
+ );
124
+ }
125
+
126
+ try {
127
+ const previews = await Promise.all(
128
+ prUrls.map(async (url) => {
129
+ try {
130
+ const preview = await fetchVercelPreviewForPr(url, token);
131
+ return { prUrl: url, preview };
132
+ } catch (error) {
133
+ console.error("PR Vercel preview fetch error:", url, error);
134
+ return {
135
+ prUrl: url,
136
+ preview: null,
137
+ error: "Failed to fetch preview.",
138
+ };
139
+ }
140
+ }),
141
+ );
142
+
143
+ if (prUrls.length === 1) {
144
+ const [entry] = previews;
145
+ if (!entry.preview) {
146
+ return NextResponse.json(
147
+ { error: entry.error ?? "No Vercel preview found for this PR." },
148
+ { status: 404 },
149
+ );
150
+ }
151
+ return NextResponse.json(entry.preview);
152
+ }
153
+
154
+ return NextResponse.json({ previews });
155
+ } catch (error) {
156
+ console.error("PR Vercel preview route error:", error);
157
+ return NextResponse.json(
158
+ { error: "Failed to load Vercel previews." },
159
+ { status: 500 },
160
+ );
161
+ }
162
+ }
@@ -0,0 +1,55 @@
1
+ import { Redis } from "@upstash/redis";
2
+
3
+ /** Copy legacy KV_* env names so Redis.fromEnv() can connect. */
4
+ export function normalizeRedisEnv(): void {
5
+ if (!process.env.UPSTASH_REDIS_REST_URL && process.env.KV_REST_API_URL) {
6
+ process.env.UPSTASH_REDIS_REST_URL = process.env.KV_REST_API_URL;
7
+ }
8
+ if (!process.env.UPSTASH_REDIS_REST_TOKEN && process.env.KV_REST_API_TOKEN) {
9
+ process.env.UPSTASH_REDIS_REST_TOKEN = process.env.KV_REST_API_TOKEN;
10
+ }
11
+
12
+ if (!process.env.KV_REST_API_URL) {
13
+ const urlKey = Object.keys(process.env).find((k) => k.endsWith("_KV_REST_API_URL"));
14
+ if (urlKey) process.env.KV_REST_API_URL = process.env[urlKey];
15
+ }
16
+ if (!process.env.KV_REST_API_TOKEN) {
17
+ const tokenKey = Object.keys(process.env).find((k) =>
18
+ k.endsWith("_KV_REST_API_TOKEN"),
19
+ );
20
+ if (tokenKey) process.env.KV_REST_API_TOKEN = process.env[tokenKey];
21
+ }
22
+
23
+ if (!process.env.UPSTASH_REDIS_REST_URL && process.env.KV_REST_API_URL) {
24
+ process.env.UPSTASH_REDIS_REST_URL = process.env.KV_REST_API_URL;
25
+ }
26
+ if (!process.env.UPSTASH_REDIS_REST_TOKEN && process.env.KV_REST_API_TOKEN) {
27
+ process.env.UPSTASH_REDIS_REST_TOKEN = process.env.KV_REST_API_TOKEN;
28
+ }
29
+ }
30
+
31
+ export function getRedisStatus(): { ok: boolean; missing: string[] } {
32
+ normalizeRedisEnv();
33
+ const missing: string[] = [];
34
+ const hasUpstash =
35
+ process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN;
36
+ const hasLegacy = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN;
37
+ if (!hasUpstash && !hasLegacy) {
38
+ if (!process.env.UPSTASH_REDIS_REST_URL && !process.env.KV_REST_API_URL) {
39
+ missing.push("UPSTASH_REDIS_REST_URL or KV_REST_API_URL");
40
+ }
41
+ if (!process.env.UPSTASH_REDIS_REST_TOKEN && !process.env.KV_REST_API_TOKEN) {
42
+ missing.push("UPSTASH_REDIS_REST_TOKEN or KV_REST_API_TOKEN");
43
+ }
44
+ }
45
+ return { ok: missing.length === 0, missing };
46
+ }
47
+
48
+ export function getRedis(): Redis {
49
+ normalizeRedisEnv();
50
+ return Redis.fromEnv();
51
+ }
52
+
53
+ export function prototypeCommentsRedisKey(slug: string): string {
54
+ return `prototype-comments:${slug}`;
55
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ normalizeChangelogMetaPayload,
3
+ parseChangelogMeta,
4
+ prototypeChangelogMetaRedisKey,
5
+ type PrototypeChangelogMeta,
6
+ } from "@prototype/lib/prototypes/changelog-meta";
7
+
8
+ import { getRedis } from "./client";
9
+
10
+ export async function getPrototypeChangelogMeta(
11
+ slug: string,
12
+ ): Promise<PrototypeChangelogMeta> {
13
+ const redis = getRedis();
14
+ const key = prototypeChangelogMetaRedisKey(slug);
15
+ const stored = await redis.get<unknown>(key);
16
+ return parseChangelogMeta(stored);
17
+ }
18
+
19
+ export async function setPrototypeChangelogMeta(
20
+ slug: string,
21
+ meta: unknown,
22
+ ): Promise<void> {
23
+ const normalized = normalizeChangelogMetaPayload(meta);
24
+ if (!normalized) {
25
+ throw new Error("Invalid change log meta.");
26
+ }
27
+
28
+ const redis = getRedis();
29
+ const key = prototypeChangelogMetaRedisKey(slug);
30
+ await redis.set(key, normalized);
31
+ }
@@ -0,0 +1,92 @@
1
+ import { isAnnotationDeleted } from "@prototype/lib/prototype-comments/core/annotation-status";
2
+ import type { CommentAnnotationFields } from "@prototype/lib/prototype-comments/core/types";
3
+ import { isValidAnnotation } from "@prototype/lib/prototype-comments/core/validation";
4
+
5
+ import { getRedis, prototypeCommentsRedisKey } from "./client";
6
+
7
+ function asStoredComment(value: unknown): CommentAnnotationFields | null {
8
+ if (!isValidAnnotation(value)) return null;
9
+ return value as CommentAnnotationFields;
10
+ }
11
+
12
+ export function filterStoredAnnotations(
13
+ annotations: unknown[],
14
+ ): CommentAnnotationFields[] {
15
+ return annotations.flatMap((value) => {
16
+ const comment = asStoredComment(value);
17
+ if (!comment || isAnnotationDeleted(comment)) return [];
18
+ return [comment];
19
+ });
20
+ }
21
+
22
+ export async function listPrototypeComments(
23
+ slug: string,
24
+ ): Promise<CommentAnnotationFields[]> {
25
+ const redis = getRedis();
26
+ const stored = (await redis.get<unknown[]>(prototypeCommentsRedisKey(slug))) ?? [];
27
+ if (!Array.isArray(stored)) return [];
28
+ return filterStoredAnnotations(stored);
29
+ }
30
+
31
+ export async function upsertPrototypeComment(
32
+ slug: string,
33
+ comment: unknown,
34
+ ): Promise<void> {
35
+ if (!isValidAnnotation(comment)) {
36
+ throw new Error("Invalid comment annotation.");
37
+ }
38
+
39
+ const incoming = comment as CommentAnnotationFields;
40
+ const redis = getRedis();
41
+ const key = prototypeCommentsRedisKey(slug);
42
+ const stored = (await redis.get<unknown[]>(key)) ?? [];
43
+ const annotations = Array.isArray(stored)
44
+ ? stored.flatMap((value) => {
45
+ const item = asStoredComment(value);
46
+ return item ? [item] : [];
47
+ })
48
+ : [];
49
+
50
+ const index = annotations.findIndex((item) => item.id === incoming.id);
51
+
52
+ if (index >= 0) {
53
+ annotations[index] = { ...annotations[index], ...incoming };
54
+ } else {
55
+ annotations.push(incoming);
56
+ }
57
+
58
+ await redis.set(key, annotations);
59
+ }
60
+
61
+ /** Merge incoming comments by id without removing stored comments missing from the payload. */
62
+ export async function mergePrototypeComments(
63
+ slug: string,
64
+ incoming: unknown[],
65
+ ): Promise<number> {
66
+ if (!Array.isArray(incoming)) {
67
+ throw new Error("Body must be an array.");
68
+ }
69
+
70
+ const redis = getRedis();
71
+ const key = prototypeCommentsRedisKey(slug);
72
+ const stored = (await redis.get<unknown[]>(key)) ?? [];
73
+ const annotations = Array.isArray(stored)
74
+ ? stored.flatMap((value) => {
75
+ const item = asStoredComment(value);
76
+ return item ? [item] : [];
77
+ })
78
+ : [];
79
+
80
+ const byId = new Map(annotations.map((item) => [item.id, item]));
81
+
82
+ for (const value of incoming) {
83
+ if (!isValidAnnotation(value)) continue;
84
+ const comment = value as CommentAnnotationFields;
85
+ const existing = byId.get(comment.id);
86
+ byId.set(comment.id, existing ? { ...existing, ...comment } : comment);
87
+ }
88
+
89
+ const merged = [...byId.values()];
90
+ await redis.set(key, merged);
91
+ return merged.filter((item) => !isAnnotationDeleted(item)).length;
92
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ normalizeDesignExplorationDefaultPayload,
3
+ parseDesignExplorationDefault,
4
+ prototypeDesignExplorationDefaultRedisKey,
5
+ type DesignExplorationDefaultRecord,
6
+ } from "@prototype/lib/prototypes/design-exploration-default";
7
+
8
+ import { getRedis } from "./client";
9
+
10
+ export async function getPrototypeDesignExplorationDefault(
11
+ storageKeyPrefix: string,
12
+ ): Promise<DesignExplorationDefaultRecord | null> {
13
+ const redis = getRedis();
14
+ const key = prototypeDesignExplorationDefaultRedisKey(storageKeyPrefix);
15
+ const stored = await redis.get<unknown>(key);
16
+ return parseDesignExplorationDefault(stored);
17
+ }
18
+
19
+ export async function setPrototypeDesignExplorationDefault(
20
+ storageKeyPrefix: string,
21
+ record: unknown,
22
+ validValues?: readonly string[],
23
+ ): Promise<void> {
24
+ const normalized = normalizeDesignExplorationDefaultPayload(record, validValues);
25
+ if (!normalized) {
26
+ throw new Error("Invalid design exploration default record.");
27
+ }
28
+
29
+ const redis = getRedis();
30
+ const key = prototypeDesignExplorationDefaultRedisKey(storageKeyPrefix);
31
+ await redis.set(key, normalized);
32
+ }