@tscircuit/fake-snippets 0.0.100 → 0.0.102

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 (61) hide show
  1. package/api/generated-index.js +23 -1
  2. package/bun.lock +2 -2
  3. package/dist/bundle.js +620 -412
  4. package/dist/index.d.ts +33 -4
  5. package/dist/index.js +43 -1
  6. package/dist/schema.d.ts +94 -1
  7. package/dist/schema.js +17 -1
  8. package/fake-snippets-api/lib/db/db-client.ts +38 -1
  9. package/fake-snippets-api/lib/db/schema.ts +15 -0
  10. package/fake-snippets-api/lib/public-mapping/public-map-package.ts +2 -0
  11. package/fake-snippets-api/routes/api/accounts/search.ts +20 -0
  12. package/fake-snippets-api/routes/api/github/installations/create_new_installation_redirect.ts +75 -0
  13. package/fake-snippets-api/routes/api/github/repos/list_available.ts +91 -0
  14. package/fake-snippets-api/routes/api/packages/update.ts +4 -0
  15. package/package.json +2 -2
  16. package/src/App.tsx +10 -1
  17. package/src/components/CmdKMenu.tsx +154 -19
  18. package/src/components/CreateReleaseDialog.tsx +124 -0
  19. package/src/components/FileSidebar.tsx +128 -23
  20. package/src/components/Header2.tsx +106 -25
  21. package/src/components/PackageBuildsPage/package-build-header.tsx +28 -16
  22. package/src/components/PageSearchComponent.tsx +2 -2
  23. package/src/components/SearchComponent.tsx +2 -2
  24. package/src/components/SuspenseRunFrame.tsx +2 -2
  25. package/src/components/TrendingPackagesCarousel.tsx +2 -2
  26. package/src/components/ViewPackagePage/components/important-files-view.tsx +18 -13
  27. package/src/components/ViewPackagePage/components/mobile-sidebar.tsx +1 -0
  28. package/src/components/ViewPackagePage/components/sidebar-about-section.tsx +1 -0
  29. package/src/components/dialogs/GitHubRepositorySelector.tsx +123 -0
  30. package/src/components/dialogs/create-use-dialog.tsx +8 -2
  31. package/src/components/dialogs/edit-package-details-dialog.tsx +22 -3
  32. package/src/components/dialogs/view-ts-files-dialog.tsx +178 -33
  33. package/src/components/package-port/CodeAndPreview.tsx +4 -1
  34. package/src/components/package-port/CodeEditor.tsx +42 -35
  35. package/src/components/package-port/CodeEditorHeader.tsx +26 -20
  36. package/src/components/package-port/EditorNav.tsx +94 -37
  37. package/src/components/preview/BuildsList.tsx +238 -0
  38. package/src/components/preview/ConnectedRepoDashboard.tsx +258 -0
  39. package/src/components/preview/ConnectedRepoOverview.tsx +454 -0
  40. package/src/components/preview/ConnectedRepoSettings.tsx +343 -0
  41. package/src/components/preview/ConnectedReposCards.tsx +191 -0
  42. package/src/components/preview/index.tsx +207 -0
  43. package/src/components/ui/tree-view.tsx +23 -6
  44. package/src/hooks/use-axios.ts +2 -2
  45. package/src/hooks/use-create-release-dialog.ts +160 -0
  46. package/src/hooks/use-package-details-form.ts +7 -0
  47. package/src/hooks/use-packages-base-api-url.ts +1 -1
  48. package/src/hooks/use-sign-in.ts +2 -2
  49. package/src/hooks/useFileManagement.ts +22 -2
  50. package/src/index.css +4 -0
  51. package/src/lib/utils/formatTimeAgo.ts +10 -0
  52. package/src/lib/utils/isValidFileName.ts +15 -3
  53. package/src/pages/dashboard.tsx +2 -2
  54. package/src/pages/dev-login.tsx +2 -2
  55. package/src/pages/landing.tsx +1 -1
  56. package/src/pages/latest.tsx +2 -2
  57. package/src/pages/preview-build.tsx +380 -0
  58. package/src/pages/search.tsx +2 -2
  59. package/src/pages/trending.tsx +2 -2
  60. package/src/pages/user-profile.tsx +32 -24
  61. package/src/pages/view-connected-repo.tsx +24 -0
@@ -0,0 +1,91 @@
1
+ import { withRouteSpec } from "fake-snippets-api/lib/middleware/with-winter-spec"
2
+ import { z } from "zod"
3
+
4
+ export default withRouteSpec({
5
+ methods: ["GET"],
6
+ auth: "session",
7
+ jsonResponse: z.object({
8
+ repos: z.array(
9
+ z.object({
10
+ unscoped_name: z.string(),
11
+ full_name: z.string(),
12
+ private: z.boolean(),
13
+ owner: z.object({
14
+ login: z.string(),
15
+ }),
16
+ description: z.string().nullable(),
17
+ default_branch: z.string(),
18
+ }),
19
+ ),
20
+ }),
21
+ })(async (_req, ctx) => {
22
+ const account = ctx.db.getAccount(ctx.auth.account_id)
23
+
24
+ if (!account) {
25
+ return ctx.error(401, {
26
+ error_code: "account_not_found",
27
+ message: "Account not found",
28
+ })
29
+ }
30
+
31
+ // Check if user has GitHub account connected
32
+ if (!account.github_username) {
33
+ return ctx.error(400, {
34
+ error_code: "github_not_connected",
35
+ message:
36
+ "GitHub account not connected. Please connect your GitHub account first.",
37
+ })
38
+ }
39
+
40
+ // Check if user has a GitHub installation
41
+ const githubInstallation = ctx.db.githubInstallations.find(
42
+ (installation) =>
43
+ installation.account_id === ctx.auth.account_id && installation.is_active,
44
+ )
45
+
46
+ if (!githubInstallation) {
47
+ // Return empty array if no GitHub installation found
48
+ return ctx.json({
49
+ repos: [],
50
+ })
51
+ }
52
+
53
+ // Mock repositories for demonstration
54
+ // In a real implementation, this would fetch from GitHub API using the installation access token
55
+ const mockRepos = [
56
+ {
57
+ unscoped_name: "my-electronics-project",
58
+ full_name: `${account.github_username}/my-electronics-project`,
59
+ owner: {
60
+ login: account.github_username,
61
+ },
62
+ description: "Arduino-based sensor monitoring system",
63
+ private: false,
64
+ default_branch: "main",
65
+ },
66
+ {
67
+ unscoped_name: "pcb-designs",
68
+ full_name: `${account.github_username}/pcb-designs`,
69
+ owner: {
70
+ login: account.github_username,
71
+ },
72
+ description: "Collection of PCB designs for various projects",
73
+ private: true,
74
+ default_branch: "main",
75
+ },
76
+ {
77
+ unscoped_name: "tscircuit-examples",
78
+ full_name: `${account.github_username}/tscircuit-examples`,
79
+ owner: {
80
+ login: account.github_username,
81
+ },
82
+ description: "Examples and tutorials for tscircuit",
83
+ private: false,
84
+ default_branch: "main",
85
+ },
86
+ ]
87
+
88
+ return ctx.json({
89
+ repos: mockRepos,
90
+ })
91
+ })
@@ -18,6 +18,7 @@ export default withRouteSpec({
18
18
  .optional(),
19
19
  description: z.string().optional(),
20
20
  website: z.string().optional(),
21
+ github_repo_full_name: z.string().optional(),
21
22
  is_private: z.boolean().optional(),
22
23
  is_unlisted: z.boolean().optional(),
23
24
  default_view: z.enum(["files", "3d", "pcb", "schematic"]).optional(),
@@ -38,6 +39,7 @@ export default withRouteSpec({
38
39
  website,
39
40
  is_private,
40
41
  is_unlisted,
42
+ github_repo_full_name,
41
43
  default_view,
42
44
  } = req.jsonBody
43
45
 
@@ -81,6 +83,8 @@ export default withRouteSpec({
81
83
  description: description ?? existingPackage.description,
82
84
  unscoped_name: name ?? existingPackage.unscoped_name,
83
85
  website: website ?? existingPackage.website,
86
+ github_repo_full_name:
87
+ github_repo_full_name ?? existingPackage.github_repo_full_name,
84
88
  is_private: is_private ?? existingPackage.is_private,
85
89
  is_public:
86
90
  is_private !== undefined ? !is_private : existingPackage.is_public,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fake-snippets",
3
- "version": "0.0.100",
3
+ "version": "0.0.102",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -83,7 +83,7 @@
83
83
  "@tscircuit/mm": "^0.0.8",
84
84
  "@tscircuit/pcb-viewer": "^1.11.194",
85
85
  "@tscircuit/prompt-benchmarks": "^0.0.28",
86
- "@tscircuit/runframe": "0.0.725",
86
+ "@tscircuit/runframe": "0.0.764",
87
87
  "@tscircuit/schematic-viewer": "^2.0.21",
88
88
  "@types/babel__standalone": "^7.1.7",
89
89
  "@types/bun": "^1.1.10",
package/src/App.tsx CHANGED
@@ -79,6 +79,10 @@ const PackageEditorPage = lazyImport(async () => {
79
79
  ])
80
80
  return editorModule
81
81
  })
82
+ const ViewConnectedRepoPage = lazyImport(
83
+ () => import("@/pages/view-connected-repo"),
84
+ )
85
+ const PreviewBuildPage = lazyImport(() => import("@/pages/preview-build"))
82
86
 
83
87
  class ErrorBoundary extends React.Component<
84
88
  { children: React.ReactNode },
@@ -139,7 +143,7 @@ class ErrorBoundary extends React.Component<
139
143
  this.cleanup() // Clean up listeners before reload
140
144
  this.setState({ reloading: true })
141
145
  this.reloadTimeout = window.setTimeout(() => {
142
- window.location.reload()
146
+ // window.location.reload()
143
147
  }, 500)
144
148
  }
145
149
 
@@ -253,6 +257,11 @@ function App() {
253
257
  <Route path="/my-orders" component={MyOrdersPage} />
254
258
  <Route path="/dev-login" component={DevLoginPage} />
255
259
  <Route path="/:username" component={UserProfilePage} />
260
+ <Route path="/build/:buildId" component={ViewConnectedRepoPage} />
261
+ <Route
262
+ path="/build/:buildId/preview"
263
+ component={PreviewBuildPage}
264
+ />
256
265
  <Route path="/:author/:packageName" component={ViewPackagePage} />
257
266
  <Route
258
267
  path="/:author/:packageName/builds"
@@ -5,7 +5,7 @@ import { useHotkeyCombo } from "@/hooks/use-hotkey"
5
5
  import { useNotImplementedToast } from "@/hooks/use-toast"
6
6
  import { fuzzyMatch } from "@/components/ViewPackagePage/utils/fuzz-search"
7
7
  import { Command } from "cmdk"
8
- import { Package } from "fake-snippets-api/lib/db/schema"
8
+ import { Package, Account } from "fake-snippets-api/lib/db/schema"
9
9
  import React, { useCallback, useEffect, useMemo, useRef } from "react"
10
10
  import { useQuery } from "react-query"
11
11
  import {
@@ -16,6 +16,8 @@ import {
16
16
  Sparkles,
17
17
  Clock,
18
18
  ArrowRight,
19
+ Star,
20
+ User,
19
21
  } from "lucide-react"
20
22
  import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
21
23
 
@@ -40,6 +42,11 @@ interface ScoredPackage extends Package {
40
42
  matches: number[]
41
43
  }
42
44
 
45
+ interface ScoredAccount extends Account {
46
+ score: number
47
+ matches: number[]
48
+ }
49
+
43
50
  const CmdKMenu = () => {
44
51
  const [open, setOpen] = React.useState(false)
45
52
  const [searchQuery, setSearchQuery] = React.useState("")
@@ -113,13 +120,42 @@ const CmdKMenu = () => {
113
120
  ["packageSearch", searchQuery],
114
121
  async () => {
115
122
  if (!searchQuery) return []
116
- const { data } = await axios.post("/packages/search", {
117
- query: searchQuery,
118
- })
119
- return data.packages || []
123
+ try {
124
+ const { data } = await axios.post("/packages/search", {
125
+ query: searchQuery,
126
+ })
127
+ return data.packages || []
128
+ } catch (error) {
129
+ console.warn("Failed to fetch packages:", error)
130
+ return []
131
+ }
120
132
  },
121
133
  {
122
134
  enabled: Boolean(searchQuery),
135
+ retry: false,
136
+ refetchOnWindowFocus: false,
137
+ },
138
+ )
139
+
140
+ const { data: allAccounts = [], isLoading: isSearchingAccounts } = useQuery(
141
+ ["accountSearch", searchQuery],
142
+ async () => {
143
+ if (!searchQuery) return []
144
+ try {
145
+ const { data } = await axios.post("/accounts/search", {
146
+ query: searchQuery,
147
+ limit: 5,
148
+ })
149
+ return data.accounts || []
150
+ } catch (error) {
151
+ console.warn("Failed to fetch accounts:", error)
152
+ return []
153
+ }
154
+ },
155
+ {
156
+ enabled: Boolean(searchQuery) && Boolean(currentUser),
157
+ retry: false,
158
+ refetchOnWindowFocus: false,
123
159
  },
124
160
  )
125
161
 
@@ -133,20 +169,43 @@ const CmdKMenu = () => {
133
169
  })
134
170
  .filter((pkg: ScoredPackage) => pkg.score >= 0)
135
171
  .sort((a: ScoredPackage, b: ScoredPackage) => b.score - a.score)
136
- .slice(0, 8)
172
+ .slice(0, 6)
137
173
  }, [allPackages, searchQuery])
138
174
 
175
+ const accountSearchResults = useMemo((): ScoredAccount[] => {
176
+ if (!searchQuery || !allAccounts.length) return []
177
+
178
+ return allAccounts
179
+ .map((account: Account) => {
180
+ const { score, matches } = fuzzyMatch(
181
+ searchQuery,
182
+ account.github_username,
183
+ )
184
+ return { ...account, score, matches }
185
+ })
186
+ .filter((account: ScoredAccount) => account.score >= 0)
187
+ .sort((a: ScoredAccount, b: ScoredAccount) => b.score - a.score)
188
+ .slice(0, 5)
189
+ }, [allAccounts, searchQuery])
190
+
139
191
  const { data: recentPackages = [] } = useQuery<Package[]>(
140
192
  ["userPackages", currentUser],
141
193
  async () => {
142
194
  if (!currentUser) return []
143
- const response = await axios.post(`/packages/list`, {
144
- owner_github_username: currentUser,
145
- })
146
- return response.data.packages || []
195
+ try {
196
+ const response = await axios.post(`/packages/list`, {
197
+ owner_github_username: currentUser,
198
+ })
199
+ return response.data.packages || []
200
+ } catch (error) {
201
+ console.warn("Failed to fetch recent packages:", error)
202
+ return []
203
+ }
147
204
  },
148
205
  {
149
206
  enabled: !!currentUser && !searchQuery,
207
+ retry: false,
208
+ refetchOnWindowFocus: false,
150
209
  },
151
210
  )
152
211
 
@@ -195,7 +254,7 @@ const CmdKMenu = () => {
195
254
 
196
255
  const allItems = useMemo(() => {
197
256
  const items: Array<{
198
- type: "package" | "recent" | "template" | "blank" | "import"
257
+ type: "package" | "account" | "recent" | "template" | "blank" | "import"
199
258
  item: any
200
259
  disabled?: boolean
201
260
  }> = []
@@ -206,6 +265,12 @@ const CmdKMenu = () => {
206
265
  })
207
266
  }
208
267
 
268
+ if (searchQuery && accountSearchResults.length > 0) {
269
+ accountSearchResults.forEach((account) => {
270
+ items.push({ type: "account", item: account })
271
+ })
272
+ }
273
+
209
274
  if (!searchQuery && recentPackages.length > 0) {
210
275
  recentPackages.slice(0, 6).forEach((pkg) => {
211
276
  items.push({ type: "recent", item: pkg })
@@ -225,7 +290,13 @@ const CmdKMenu = () => {
225
290
  })
226
291
 
227
292
  return items
228
- }, [searchQuery, searchResults, recentPackages, filteredStaticOptions])
293
+ }, [
294
+ searchQuery,
295
+ searchResults,
296
+ accountSearchResults,
297
+ recentPackages,
298
+ filteredStaticOptions,
299
+ ])
229
300
 
230
301
  useHotkeyCombo("cmd+k", () => {
231
302
  setOpen((prev) => !prev)
@@ -285,6 +356,10 @@ const CmdKMenu = () => {
285
356
  window.location.href = `/${item.owner_github_username}/${item.unscoped_name}`
286
357
  setOpen(false)
287
358
  break
359
+ case "account":
360
+ window.location.href = `/${item.github_username}`
361
+ setOpen(false)
362
+ break
288
363
  case "blank":
289
364
  case "template":
290
365
  if (!item.disabled) {
@@ -362,7 +437,11 @@ const CmdKMenu = () => {
362
437
  )}
363
438
  </div>
364
439
  </div>
365
- <div className="flex items-center gap-1 flex-shrink-0">
440
+ <div className="flex items-center gap-2 flex-shrink-0">
441
+ <div className="flex items-center gap-1 text-gray-500">
442
+ <Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
443
+ <span className="text-xs">{data.star_count ?? 0}</span>
444
+ </div>
366
445
  <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
367
446
  package
368
447
  </span>
@@ -371,6 +450,41 @@ const CmdKMenu = () => {
371
450
  </div>
372
451
  )
373
452
 
453
+ case "account":
454
+ return (
455
+ <div
456
+ key={`account-${data.account_id}`}
457
+ ref={isSelected ? selectedItemRef : null}
458
+ className={baseClasses}
459
+ onClick={() => !disabled && handleItemSelect(item)}
460
+ >
461
+ <div className="flex items-center gap-2 min-w-0">
462
+ <img
463
+ src={`https://github.com/${data.github_username}.png`}
464
+ alt={`${data.github_username} avatar`}
465
+ className="w-6 h-6 rounded-full flex-shrink-0"
466
+ onError={(e) => {
467
+ const target = e.target as HTMLImageElement
468
+ target.style.display = "none"
469
+ target.nextElementSibling?.classList.remove("hidden")
470
+ }}
471
+ />
472
+ <User className="w-6 h-6 text-gray-400 flex-shrink-0 hidden" />
473
+ <div className="flex flex-col min-w-0">
474
+ <span className="font-medium text-gray-900 truncate">
475
+ {renderHighlighted(data, data.github_username)}
476
+ </span>
477
+ </div>
478
+ </div>
479
+ <div className="flex items-center gap-1 flex-shrink-0">
480
+ <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
481
+ user
482
+ </span>
483
+ {isSelected && <ArrowRight className="w-3 h-3 text-gray-400" />}
484
+ </div>
485
+ </div>
486
+ )
487
+
374
488
  case "blank":
375
489
  case "template":
376
490
  return (
@@ -464,7 +578,7 @@ const CmdKMenu = () => {
464
578
  </div>
465
579
 
466
580
  <Command.List className="max-h-80 overflow-y-auto p-2 space-y-4">
467
- {isSearching ? (
581
+ {isSearching || isSearchingAccounts ? (
468
582
  <Command.Loading className="p-6 text-center text-gray-500">
469
583
  <div className="flex items-center justify-center gap-2">
470
584
  <div className="w-3 h-3 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin"></div>
@@ -476,7 +590,7 @@ const CmdKMenu = () => {
476
590
  {searchQuery && searchResults.length > 0 && (
477
591
  <div>
478
592
  <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 px-2">
479
- Search Results
593
+ Packages
480
594
  </h3>
481
595
  <div className="space-y-1">
482
596
  {searchResults.slice(0, 8).map((pkg, localIndex) => {
@@ -490,6 +604,25 @@ const CmdKMenu = () => {
490
604
  </div>
491
605
  )}
492
606
 
607
+ {searchQuery && accountSearchResults.length > 0 && (
608
+ <div>
609
+ <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 px-2">
610
+ Users
611
+ </h3>
612
+ <div className="space-y-1">
613
+ {accountSearchResults
614
+ .slice(0, 5)
615
+ .map((account, localIndex) => {
616
+ const globalIndex = searchResults.length + localIndex
617
+ return renderItem(
618
+ { type: "account", item: account },
619
+ globalIndex,
620
+ )
621
+ })}
622
+ </div>
623
+ </div>
624
+ )}
625
+
493
626
  {!searchQuery && recentPackages.length > 0 && (
494
627
  <div>
495
628
  <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 px-2 flex items-center gap-1">
@@ -518,7 +651,7 @@ const CmdKMenu = () => {
518
651
  (template, localIndex) => {
519
652
  const globalIndex =
520
653
  (searchQuery
521
- ? searchResults.length
654
+ ? searchResults.length + accountSearchResults.length
522
655
  : recentPackages.length) + localIndex
523
656
  return renderItem(
524
657
  {
@@ -544,7 +677,7 @@ const CmdKMenu = () => {
544
677
  (template, localIndex) => {
545
678
  const globalIndex =
546
679
  (searchQuery
547
- ? searchResults.length
680
+ ? searchResults.length + accountSearchResults.length
548
681
  : recentPackages.length) +
549
682
  filteredStaticOptions.blankTemplates.length +
550
683
  localIndex
@@ -568,7 +701,7 @@ const CmdKMenu = () => {
568
701
  (option, localIndex) => {
569
702
  const globalIndex =
570
703
  (searchQuery
571
- ? searchResults.length
704
+ ? searchResults.length + accountSearchResults.length
572
705
  : recentPackages.length) +
573
706
  filteredStaticOptions.blankTemplates.length +
574
707
  filteredStaticOptions.templates.length +
@@ -585,10 +718,12 @@ const CmdKMenu = () => {
585
718
 
586
719
  {searchQuery &&
587
720
  !searchResults.length &&
721
+ !accountSearchResults.length &&
588
722
  !filteredStaticOptions.blankTemplates.length &&
589
723
  !filteredStaticOptions.templates.length &&
590
724
  !filteredStaticOptions.importOptions.length &&
591
- !isSearching && (
725
+ !isSearching &&
726
+ !isSearchingAccounts && (
592
727
  <Command.Empty className="py-8 text-center">
593
728
  <div className="text-gray-400 mb-1">No results found</div>
594
729
  <div className="text-gray-500 text-xs">
@@ -0,0 +1,124 @@
1
+ import { useState } from "react"
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from "@/components/ui/dialog"
10
+ import { Button } from "@/components/ui/button"
11
+ import { Input } from "@/components/ui/input"
12
+ import { Label } from "@/components/ui/label"
13
+ import { Alert, AlertDescription } from "@/components/ui/alert"
14
+ import { Loader2, Tag, AlertCircle } from "lucide-react"
15
+
16
+ interface CreateReleaseDialogProps {
17
+ isOpen: boolean
18
+ onClose: () => void
19
+ version: string
20
+ setVersion: (version: string) => void
21
+ currentVersion?: string
22
+ isLoading: boolean
23
+ error: string | null
24
+ onCreateRelease: () => Promise<void>
25
+ }
26
+
27
+ export function CreateReleaseDialog({
28
+ isOpen,
29
+ onClose,
30
+ version,
31
+ setVersion,
32
+ currentVersion,
33
+ isLoading,
34
+ error,
35
+ onCreateRelease,
36
+ }: CreateReleaseDialogProps) {
37
+ const handleSubmit = async (e: React.FormEvent) => {
38
+ e.preventDefault()
39
+ await onCreateRelease()
40
+ }
41
+
42
+ const handleClose = () => {
43
+ onClose()
44
+ }
45
+
46
+ return (
47
+ <Dialog open={isOpen} onOpenChange={handleClose}>
48
+ <DialogContent className="sm:max-w-[425px]">
49
+ <DialogHeader>
50
+ <DialogTitle className="flex items-center gap-2">
51
+ <Tag className="w-5 h-5" />
52
+ Create New Release
53
+ </DialogTitle>
54
+ <DialogDescription>
55
+ Create a new release. This will make your latest changes available
56
+ to users.
57
+ </DialogDescription>
58
+ </DialogHeader>
59
+
60
+ <form onSubmit={handleSubmit} className="space-y-4">
61
+ <div className="space-y-3">
62
+ <div className="flex justify-center">
63
+ <p className="text-sm text-muted-foreground text-center">
64
+ {currentVersion ? (
65
+ <span className="flex items-center justify-center gap-2">
66
+ <span>Current version:</span>
67
+ <span className="font-mono font-medium text-foreground bg-muted px-2 py-1 rounded-md text-xs border">
68
+ {currentVersion}
69
+ </span>
70
+ </span>
71
+ ) : (
72
+ "Follow semantic versioning (e.g., 1.0.0)"
73
+ )}
74
+ </p>
75
+ </div>{" "}
76
+ <Label htmlFor="version" className="text-sm font-medium">
77
+ Version
78
+ </Label>
79
+ <Input
80
+ id="version"
81
+ placeholder="e.g., 1.0.0"
82
+ value={version}
83
+ onChange={(e) => setVersion(e.target.value)}
84
+ disabled={isLoading}
85
+ className="h-10"
86
+ />
87
+ </div>
88
+
89
+ {error && (
90
+ <Alert variant="destructive">
91
+ <AlertCircle className="h-4 w-4" />
92
+ <AlertDescription>{error}</AlertDescription>
93
+ </Alert>
94
+ )}
95
+
96
+ <DialogFooter>
97
+ <Button
98
+ type="button"
99
+ variant="outline"
100
+ onClick={handleClose}
101
+ disabled={isLoading}
102
+ >
103
+ Cancel
104
+ </Button>
105
+ <Button
106
+ type="submit"
107
+ disabled={isLoading || !version.trim()}
108
+ className="min-w-[100px]"
109
+ >
110
+ {isLoading ? (
111
+ <>
112
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
113
+ Creating...
114
+ </>
115
+ ) : (
116
+ "Create Release"
117
+ )}
118
+ </Button>
119
+ </DialogFooter>
120
+ </form>
121
+ </DialogContent>
122
+ </Dialog>
123
+ )
124
+ }