@tscircuit/fake-snippets 0.0.109 → 0.0.111
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/.github/workflows/bun-formatcheck.yml +2 -2
- package/.github/workflows/bun-pver-release.yml +3 -3
- package/.github/workflows/bun-test.yml +1 -1
- package/.github/workflows/bun-typecheck.yml +2 -2
- package/.github/workflows/update-snapshots.yml +1 -1
- package/README.md +4 -0
- package/api/generated-index.js +37 -3
- package/biome.json +2 -1
- package/bun-tests/fake-snippets-api/fixtures/get-test-server.ts +32 -3
- package/bun-tests/fake-snippets-api/fixtures/preload.ts +18 -0
- package/bun-tests/fake-snippets-api/routes/orgs/add_member.test.ts +26 -0
- package/bun-tests/fake-snippets-api/routes/orgs/create.test.ts +37 -0
- package/bun-tests/fake-snippets-api/routes/orgs/get.test.ts +52 -0
- package/bun-tests/fake-snippets-api/routes/orgs/list.test.ts +17 -0
- package/bun-tests/fake-snippets-api/routes/orgs/list_members.test.ts +23 -0
- package/bun-tests/fake-snippets-api/routes/orgs/remove_member.test.ts +81 -0
- package/bun-tests/fake-snippets-api/routes/orgs/update.test.ts +151 -0
- package/bun-tests/fake-snippets-api/routes/package_builds/get.test.ts +1 -1
- package/bun-tests/fake-snippets-api/routes/package_files/create.test.ts +15 -13
- package/bun-tests/fake-snippets-api/routes/package_files/create_or_update.test.ts +26 -24
- package/bun-tests/fake-snippets-api/routes/package_files/delete.test.ts +9 -9
- package/bun-tests/fake-snippets-api/routes/package_files/download.test.ts +4 -4
- package/bun-tests/fake-snippets-api/routes/package_files/get.test.ts +38 -28
- package/bun-tests/fake-snippets-api/routes/package_files/list.test.ts +23 -15
- package/bun-tests/fake-snippets-api/routes/package_releases/create.test.ts +33 -0
- package/bun-tests/fake-snippets-api/routes/package_releases/get.test.ts +4 -4
- package/bun-tests/fake-snippets-api/routes/package_releases/get_image_generation_fields.test.ts +38 -0
- package/bun-tests/fake-snippets-api/routes/packages/create.test.ts +19 -0
- package/bun-tests/fake-snippets-api/routes/packages/fork.test.ts +3 -4
- package/bun-tests/fake-snippets-api/routes/packages/get.test.ts +30 -0
- package/bun-tests/fake-snippets-api/routes/packages/images.test.ts +4 -2
- package/bun-tests/fake-snippets-api/routes/packages/list-1.test.ts +34 -0
- package/bun.lock +361 -453
- package/bunfig.toml +2 -1
- package/dist/bundle.js +1313 -639
- package/dist/index.d.ts +313 -6
- package/dist/index.js +328 -24
- package/dist/schema.d.ts +290 -1
- package/dist/schema.js +54 -1
- package/fake-snippets-api/lib/db/autoload-dev-packages.ts +31 -20
- package/fake-snippets-api/lib/db/db-client.ts +219 -4
- package/fake-snippets-api/lib/db/schema.ts +63 -1
- package/fake-snippets-api/lib/db/seed.ts +100 -0
- package/fake-snippets-api/lib/middleware/with-session-auth.ts +60 -8
- package/fake-snippets-api/lib/package_file/get-package-file-id-from-file-descriptor.ts +2 -2
- package/fake-snippets-api/lib/public-mapping/public-map-org.ts +33 -0
- package/fake-snippets-api/lib/public-mapping/public-map-package-build.ts +10 -0
- package/fake-snippets-api/lib/public-mapping/public-map-package-release.ts +17 -0
- package/fake-snippets-api/routes/api/orgs/add_member.ts +52 -0
- package/fake-snippets-api/routes/api/orgs/create.ts +48 -0
- package/fake-snippets-api/routes/api/orgs/get.ts +39 -0
- package/fake-snippets-api/routes/api/orgs/list.ts +31 -0
- package/fake-snippets-api/routes/api/orgs/list_members.ts +60 -0
- package/fake-snippets-api/routes/api/orgs/remove_member.ts +46 -0
- package/fake-snippets-api/routes/api/orgs/update.ts +118 -0
- package/fake-snippets-api/routes/api/package_files/get.ts +3 -6
- package/fake-snippets-api/routes/api/package_files/list.ts +7 -4
- package/fake-snippets-api/routes/api/packages/create.ts +57 -10
- package/fake-snippets-api/routes/api/packages/get.ts +23 -0
- package/fake-snippets-api/routes/api/packages/images/[owner_github_username]/[unscoped_name]/[view_format].ts +13 -11
- package/fake-snippets-api/routes/api/packages/list.ts +29 -2
- package/fake-snippets-api/routes/api/packages/update_ai_description.ts +37 -0
- package/package.json +25 -19
- package/renovate.json +1 -1
- package/scripts/generate-sitemap.ts +1 -1
- package/src/App.tsx +27 -8
- package/src/ContextProviders.tsx +25 -2
- package/src/components/CircuitJsonImportDialog.tsx +1 -1
- package/src/components/CmdKMenu.tsx +281 -247
- package/src/components/DownloadButtonAndMenu.tsx +17 -5
- package/src/components/FileSidebar.tsx +11 -17
- package/src/components/Footer.tsx +8 -9
- package/src/components/Header.tsx +19 -32
- package/src/components/Header2.tsx +16 -32
- package/src/components/HeaderDropdown.tsx +13 -8
- package/src/components/HeaderLogin.tsx +43 -15
- package/src/components/NotFound.tsx +5 -5
- package/src/components/PackageBreadcrumb.tsx +6 -12
- package/src/components/PackageSearchResults.tsx +1 -1
- package/src/components/PrefetchPageLink.tsx +7 -1
- package/src/components/ProfileRouter.tsx +32 -0
- package/src/components/SearchComponent.tsx +12 -8
- package/src/components/SentryNotFoundReporter.tsx +44 -0
- package/src/components/UserCard.tsx +80 -0
- package/src/components/ViewPackagePage/components/build-status.tsx +1 -1
- package/src/components/ViewPackagePage/components/important-files-view.tsx +105 -34
- package/src/components/ViewPackagePage/components/main-content-header.tsx +10 -6
- package/src/components/ViewPackagePage/components/main-content-view-selector.tsx +1 -1
- package/src/components/ViewPackagePage/components/mobile-sidebar.tsx +54 -19
- package/src/components/ViewPackagePage/components/package-header.tsx +25 -33
- package/src/components/ViewPackagePage/components/preview-image-squares.tsx +11 -18
- package/src/components/ViewPackagePage/components/repo-page-content.tsx +12 -5
- package/src/components/ViewPackagePage/components/sidebar-about-section.tsx +16 -10
- package/src/components/ViewPackagePage/components/sidebar-releases-section.tsx +11 -11
- package/src/components/ViewPackagePage/components/tab-views/pcb-view.tsx +1 -2
- package/src/components/ViewPackagePage/components/tab-views/schematic-view.tsx +2 -1
- package/src/components/dialogs/GitHubRepositorySelector.tsx +56 -49
- package/src/components/dialogs/edit-package-details-dialog.tsx +5 -6
- package/src/components/dialogs/import-component-dialog.tsx +16 -9
- package/src/components/dialogs/import-package-dialog.tsx +3 -2
- package/src/components/dialogs/new-package-save-prompt-dialog.tsx +190 -0
- package/src/components/organization/OrganizationCard.tsx +206 -0
- package/src/components/organization/OrganizationCardSkeleton.tsx +55 -0
- package/src/components/organization/OrganizationHeader.tsx +154 -0
- package/src/components/organization/OrganizationMembers.tsx +146 -0
- package/src/components/package-port/CodeAndPreview.tsx +15 -12
- package/src/components/package-port/CodeEditor.tsx +4 -30
- package/src/components/package-port/CodeEditorHeader.tsx +123 -61
- package/src/components/package-port/EditorNav.tsx +32 -49
- package/src/components/preview/ConnectedPackagesList.tsx +8 -8
- package/src/components/preview/ConnectedRepoOverview.tsx +102 -2
- package/src/components/preview/PackageReleasesDashboard.tsx +23 -11
- package/src/components/ui/tree-view.tsx +6 -3
- package/src/hooks/use-add-org-member-mutation.ts +51 -0
- package/src/hooks/use-create-org-mutation.ts +38 -0
- package/src/hooks/use-create-package-mutation.ts +3 -0
- package/src/hooks/use-current-package-release.ts +4 -3
- package/src/hooks/use-download-zip.ts +2 -2
- package/src/hooks/use-global-store.ts +6 -4
- package/src/hooks/use-hydration.ts +30 -0
- package/src/hooks/use-jlcpcb-component-import.tsx +164 -0
- package/src/hooks/use-list-org-members.ts +27 -0
- package/src/hooks/use-list-user-orgs.ts +25 -0
- package/src/hooks/use-org-by-github-handle.ts +26 -0
- package/src/hooks/use-org.ts +24 -0
- package/src/hooks/use-organization.ts +42 -0
- package/src/hooks/use-package-as-snippet.ts +4 -2
- package/src/hooks/use-package-builds.ts +6 -2
- package/src/hooks/use-package-files.ts +5 -3
- package/src/hooks/use-package-release-by-id-or-version.ts +29 -20
- package/src/hooks/use-package-release-images.ts +105 -0
- package/src/hooks/use-package-release.ts +2 -2
- package/src/hooks/use-package-stars.ts +80 -4
- package/src/hooks/use-preview-images.ts +6 -3
- package/src/hooks/use-remove-org-member-mutation.ts +32 -0
- package/src/hooks/use-update-ai-description-mutation.ts +42 -0
- package/src/hooks/use-update-org-mutation.ts +41 -0
- package/src/hooks/use-warn-user-on-page-change.ts +71 -4
- package/src/hooks/useFileManagement.ts +51 -22
- package/src/hooks/useOptimizedPackageFilesLoader.ts +11 -24
- package/src/hooks/usePackageFilesLoader.ts +2 -2
- package/src/hooks/useUpdatePackageFilesMutation.ts +13 -1
- package/src/lib/download-fns/download-gltf-from-circuit-json.ts +1 -1
- package/src/lib/download-fns/download-kicad-files.ts +22 -11
- package/src/lib/download-fns/download-step.ts +12 -0
- package/src/lib/normalize-svg-for-tile.ts +50 -0
- package/src/lib/posthog.ts +11 -9
- package/src/lib/react-query-api-failure-tracking.ts +148 -0
- package/src/lib/sentry.ts +14 -0
- package/src/lib/templates/blank-circuit-board-template.ts +0 -4
- package/src/lib/ts-lib-cache.ts +122 -7
- package/src/lib/utils/checkIfManualEditsImported.ts +4 -4
- package/src/lib/utils/findTargetFile.ts +45 -10
- package/src/lib/utils/isComponentExported.ts +2 -1
- package/src/main.tsx +2 -1
- package/src/pages/create-organization.tsx +169 -0
- package/src/pages/dashboard.tsx +38 -6
- package/src/pages/datasheet.tsx +1 -1
- package/src/pages/datasheets.tsx +3 -3
- package/src/pages/editor.tsx +4 -6
- package/src/pages/landing.tsx +6 -6
- package/src/pages/latest.tsx +3 -0
- package/src/pages/organization-profile.tsx +199 -0
- package/src/pages/organization-settings.tsx +569 -0
- package/src/pages/package-editor.tsx +21 -21
- package/src/pages/preview-release.tsx +75 -145
- package/src/pages/quickstart.tsx +159 -123
- package/src/pages/release-detail.tsx +119 -31
- package/src/pages/search.tsx +197 -57
- package/src/pages/settings-redirect.tsx +44 -0
- package/src/pages/trending.tsx +29 -20
- package/src/pages/user-profile.tsx +58 -7
- package/src/pages/user-settings.tsx +161 -0
- package/src/pages/view-package.tsx +30 -16
- package/vite.config.ts +9 -0
- package/fake-snippets-api/routes/api/autocomplete/create_autocomplete.ts +0 -133
- package/src/components/JLCPCBImportDialog.tsx +0 -280
- package/src/components/PackageBuildsPage/LogContent.tsx +0 -72
- package/src/components/PackageBuildsPage/PackageBuildDetailsPage.tsx +0 -113
- package/src/components/PackageBuildsPage/build-preview-content.tsx +0 -56
- package/src/components/PackageBuildsPage/collapsible-section.tsx +0 -63
- package/src/components/PackageBuildsPage/package-build-details-panel.tsx +0 -166
- package/src/components/PackageBuildsPage/package-build-header.tsx +0 -79
- package/src/components/PageSearchComponent.tsx +0 -148
- package/src/pages/package-builds.tsx +0 -33
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React, { useState } from "react"
|
|
2
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
3
|
+
import { Button } from "@/components/ui/button"
|
|
4
|
+
import { Building2, Users, Package, Lock, Globe2, Settings } from "lucide-react"
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
import { PublicOrgSchema } from "fake-snippets-api/lib/db/schema"
|
|
7
|
+
import { useGlobalStore } from "@/hooks/use-global-store"
|
|
8
|
+
import { useLocation } from "wouter"
|
|
9
|
+
import { useOrganization } from "@/hooks/use-organization"
|
|
10
|
+
|
|
11
|
+
interface OrganizationHeaderProps {
|
|
12
|
+
organization: PublicOrgSchema
|
|
13
|
+
isCurrentUserOrganization?: boolean
|
|
14
|
+
className?: string
|
|
15
|
+
showActions?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const OrganizationHeader: React.FC<OrganizationHeaderProps> = ({
|
|
19
|
+
organization,
|
|
20
|
+
className,
|
|
21
|
+
showActions = true,
|
|
22
|
+
}) => {
|
|
23
|
+
const session = useGlobalStore((s) => s.session)
|
|
24
|
+
const [, navigate] = useLocation()
|
|
25
|
+
const canManageOrg =
|
|
26
|
+
organization.user_permissions?.can_manage_org ||
|
|
27
|
+
organization.owner_account_id === session?.account_id
|
|
28
|
+
|
|
29
|
+
const { membersCount, packagesCount, isLoading } = useOrganization({
|
|
30
|
+
orgId: organization.org_id,
|
|
31
|
+
orgName: organization.name!,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const handleSettingsClick = () => {
|
|
35
|
+
navigate(`/${organization.name}/settings`)
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn("bg-white border-b border-gray-200", className)}>
|
|
39
|
+
<div className="container mx-auto px-6 py-6">
|
|
40
|
+
{/* Mobile Layout */}
|
|
41
|
+
<div className="block sm:hidden">
|
|
42
|
+
<div className="flex flex-col items-center text-center space-y-4">
|
|
43
|
+
<Avatar className="border-4 border-gray-100 shadow-sm size-16 md:size-20 lg:size-24">
|
|
44
|
+
<AvatarImage
|
|
45
|
+
src={`https://github.com/${organization.name}.png`}
|
|
46
|
+
alt={`${organization.name} avatar`}
|
|
47
|
+
className="object-cover"
|
|
48
|
+
/>
|
|
49
|
+
<AvatarFallback className="bg-blue-100 text-blue-600 font-bold text-xl md:text-2xl lg:text-3xl">
|
|
50
|
+
{(organization.name || "")
|
|
51
|
+
.split(" ")
|
|
52
|
+
.map((word) => word[0])
|
|
53
|
+
.join("")
|
|
54
|
+
.toUpperCase()
|
|
55
|
+
.slice(0, 2)}
|
|
56
|
+
</AvatarFallback>
|
|
57
|
+
</Avatar>
|
|
58
|
+
|
|
59
|
+
<div>
|
|
60
|
+
<div className="flex flex-col items-center gap-3 mb-3">
|
|
61
|
+
<h1 className="font-bold text-gray-900 text-xl">
|
|
62
|
+
{organization.name}
|
|
63
|
+
</h1>
|
|
64
|
+
{canManageOrg && showActions && (
|
|
65
|
+
<Button
|
|
66
|
+
variant="outline"
|
|
67
|
+
size="sm"
|
|
68
|
+
onClick={handleSettingsClick}
|
|
69
|
+
>
|
|
70
|
+
<Settings className="mr-2 h-4 w-4" />
|
|
71
|
+
Settings
|
|
72
|
+
</Button>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="grid grid-cols-2 md:flex flex-wrap justify-center gap-4 text-sm">
|
|
77
|
+
<div className="flex items-center gap-1.5 text-gray-600">
|
|
78
|
+
<Users className="h-3.5 w-3.5" />
|
|
79
|
+
<span className="font-medium">
|
|
80
|
+
{isLoading ? "..." : membersCount}
|
|
81
|
+
</span>
|
|
82
|
+
<span>members</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="flex items-center gap-1.5 text-gray-600">
|
|
85
|
+
<Package className="h-3.5 w-3.5" />
|
|
86
|
+
<span className="font-medium">
|
|
87
|
+
{isLoading ? "..." : packagesCount}
|
|
88
|
+
</span>
|
|
89
|
+
<span>packages</span>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{/* Desktop Layout */}
|
|
97
|
+
<div className="hidden sm:block">
|
|
98
|
+
<div className="flex items-center gap-6">
|
|
99
|
+
<Avatar className="border-4 border-gray-100 shadow-sm size-16 md:size-20 lg:size-24 flex-shrink-0">
|
|
100
|
+
<AvatarImage
|
|
101
|
+
src={`https://github.com/${organization.name}.png`}
|
|
102
|
+
alt={`${organization.name} avatar`}
|
|
103
|
+
className="object-cover"
|
|
104
|
+
/>
|
|
105
|
+
<AvatarFallback className="bg-blue-100 text-blue-600 font-bold text-xl md:text-2xl lg:text-3xl">
|
|
106
|
+
{(organization.name || "")
|
|
107
|
+
.split(" ")
|
|
108
|
+
.map((word) => word[0])
|
|
109
|
+
.join("")
|
|
110
|
+
.toUpperCase()
|
|
111
|
+
.slice(0, 2)}
|
|
112
|
+
</AvatarFallback>
|
|
113
|
+
</Avatar>
|
|
114
|
+
|
|
115
|
+
<div className="flex-1 min-w-0">
|
|
116
|
+
<div className="flex items-center justify-between mb-3">
|
|
117
|
+
<h1 className="font-bold text-gray-900 text-2xl md:text-3xl truncate">
|
|
118
|
+
{organization.name}
|
|
119
|
+
</h1>
|
|
120
|
+
{canManageOrg && showActions && (
|
|
121
|
+
<Button variant="outline" onClick={handleSettingsClick}>
|
|
122
|
+
<Settings className="mr-2 h-4 w-4" />
|
|
123
|
+
Settings
|
|
124
|
+
</Button>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-600">
|
|
129
|
+
<div className="flex items-center gap-2">
|
|
130
|
+
<Users className="h-4 w-4" />
|
|
131
|
+
<span className="font-medium text-gray-900">
|
|
132
|
+
{isLoading ? "..." : membersCount}
|
|
133
|
+
</span>
|
|
134
|
+
<span>members</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div className="flex items-center gap-2">
|
|
137
|
+
<Package className="h-4 w-4" />
|
|
138
|
+
<span className="font-medium text-gray-900">
|
|
139
|
+
{isLoading ? "..." : packagesCount}
|
|
140
|
+
</span>
|
|
141
|
+
<span>packages</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div className="flex items-center gap-2">
|
|
144
|
+
<Building2 className="h-4 w-4" />
|
|
145
|
+
<span>Organization</span>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { Link } from "wouter"
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
4
|
+
import { Badge } from "@/components/ui/badge"
|
|
5
|
+
import { Button } from "@/components/ui/button"
|
|
6
|
+
import { Users, Crown, Shield, User, Loader2 } from "lucide-react"
|
|
7
|
+
import { timeAgo } from "@/lib/utils/timeAgo"
|
|
8
|
+
import { cn } from "@/lib/utils"
|
|
9
|
+
import { Account } from "fake-snippets-api/lib/db/schema"
|
|
10
|
+
import { useListOrgMembers } from "@/hooks/use-list-org-members"
|
|
11
|
+
|
|
12
|
+
interface OrganizationMembersProps {
|
|
13
|
+
orgId: string
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
type MemberRole = "owner" | "admin" | "member" //todo
|
|
17
|
+
const getRoleIcon = (role: MemberRole) => {
|
|
18
|
+
switch (role) {
|
|
19
|
+
case "owner":
|
|
20
|
+
return <Crown className="h-3 w-3" />
|
|
21
|
+
case "admin":
|
|
22
|
+
return <Shield className="h-3 w-3" />
|
|
23
|
+
case "member":
|
|
24
|
+
return <User className="h-3 w-3" />
|
|
25
|
+
default:
|
|
26
|
+
return <User className="h-3 w-3" />
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const getRoleColor = (role: MemberRole) => {
|
|
31
|
+
switch (role) {
|
|
32
|
+
case "owner":
|
|
33
|
+
return "bg-yellow-100 text-yellow-800 border-yellow-200"
|
|
34
|
+
case "admin":
|
|
35
|
+
return "bg-purple-100 text-purple-800 border-purple-200"
|
|
36
|
+
case "member":
|
|
37
|
+
return "bg-gray-100 text-gray-800 border-gray-200"
|
|
38
|
+
default:
|
|
39
|
+
return "bg-gray-100 text-gray-800 border-gray-200"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const OrganizationMembers: React.FC<OrganizationMembersProps> = ({
|
|
44
|
+
orgId,
|
|
45
|
+
className,
|
|
46
|
+
}) => {
|
|
47
|
+
const { data: members = [], isLoading } = useListOrgMembers({ orgId })
|
|
48
|
+
|
|
49
|
+
if (isLoading) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={cn(
|
|
53
|
+
"bg-white rounded-lg border border-gray-200 p-4 sm:p-6",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<div className="mb-4">
|
|
58
|
+
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
59
|
+
Members
|
|
60
|
+
</h2>
|
|
61
|
+
</div>
|
|
62
|
+
<div className="text-center py-20 text-gray-500 grid place-items-center">
|
|
63
|
+
<Loader2 className="w-8 h-8 text-blue-600 dark:text-blue-400 animate-spin" />
|
|
64
|
+
<p className="mt-2 text-sm select-none">Loading members...</p>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className={cn(
|
|
72
|
+
"bg-white rounded-lg border border-gray-200 p-4 py-20 sm:p-6",
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
<div className="mb-4">
|
|
77
|
+
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
78
|
+
Members ({members.length})
|
|
79
|
+
</h2>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="space-y-2 sm:space-y-3">
|
|
83
|
+
{members.map((member) => (
|
|
84
|
+
<Link
|
|
85
|
+
key={member.account_id || member.github_username}
|
|
86
|
+
href={`/${member.github_username}`}
|
|
87
|
+
className="block"
|
|
88
|
+
>
|
|
89
|
+
<div className="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer">
|
|
90
|
+
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
91
|
+
<Avatar className="h-10 w-10 sm:h-12 sm:w-12 flex-shrink-0">
|
|
92
|
+
<AvatarImage
|
|
93
|
+
src={`https://github.com/${member.github_username}.png`}
|
|
94
|
+
alt={`${member.github_username} avatar`}
|
|
95
|
+
/>
|
|
96
|
+
<AvatarFallback className="text-sm font-medium">
|
|
97
|
+
{member.github_username
|
|
98
|
+
.split(" ")
|
|
99
|
+
.map((word) => word[0])
|
|
100
|
+
.join("")
|
|
101
|
+
.toUpperCase()
|
|
102
|
+
.slice(0, 2)}
|
|
103
|
+
</AvatarFallback>
|
|
104
|
+
</Avatar>
|
|
105
|
+
|
|
106
|
+
<div className="min-w-0 flex-1">
|
|
107
|
+
<div className="flex items-center gap-2 mb-1">
|
|
108
|
+
<h3 className="font-medium text-gray-900 truncate">
|
|
109
|
+
{member.github_username}
|
|
110
|
+
</h3>
|
|
111
|
+
<Badge
|
|
112
|
+
variant="outline"
|
|
113
|
+
className={cn(
|
|
114
|
+
"text-xs flex items-center gap-1 flex-shrink-0",
|
|
115
|
+
getRoleColor("admin"),
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
{getRoleIcon("admin")}
|
|
119
|
+
{"admin"}
|
|
120
|
+
</Badge>
|
|
121
|
+
</div>
|
|
122
|
+
<p className="text-sm text-gray-500 truncate">
|
|
123
|
+
@{member.github_username}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="text-right flex-shrink-0 hidden sm:block">
|
|
129
|
+
<p className="text-xs text-gray-500">
|
|
130
|
+
Joined {timeAgo(new Date())}
|
|
131
|
+
</p>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</Link>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{members.length === 0 && (
|
|
139
|
+
<div className="text-center py-6 sm:py-8 text-gray-500">
|
|
140
|
+
<Users className="h-10 w-10 sm:h-12 sm:w-12 mx-auto mb-3 text-gray-300" />
|
|
141
|
+
<p className="text-sm sm:text-base">No members found</p>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { CodeEditor } from "@/components/package-port/CodeEditor"
|
|
2
|
-
import { usePackageVisibilitySettingsDialog } from "@/components/dialogs/package-visibility-settings-dialog"
|
|
3
2
|
import { useConfirmDiscardChangesDialog } from "@/components/dialogs/confirm-discard-changes-dialog"
|
|
4
3
|
import { useToast } from "@/hooks/use-toast"
|
|
5
4
|
import { useUrlParams } from "@/hooks/use-url-params"
|
|
@@ -15,6 +14,7 @@ import { toastManualEditConflicts } from "@/lib/utils/toastManualEditConflicts"
|
|
|
15
14
|
import { ManualEditEvent } from "@tscircuit/props"
|
|
16
15
|
import { useFileManagement } from "@/hooks/useFileManagement"
|
|
17
16
|
import { isHiddenFile } from "../ViewPackagePage/utils/is-hidden-file"
|
|
17
|
+
import { useNewPackageSavePromptDialog } from "../dialogs/new-package-save-prompt-dialog"
|
|
18
18
|
|
|
19
19
|
interface Props {
|
|
20
20
|
pkg?: Package
|
|
@@ -57,7 +57,7 @@ export function CodeAndPreview({ pkg, projectUrl }: Props) {
|
|
|
57
57
|
pkg?.snippet_type ?? templateFromUrl?.type ?? urlParams.snippet_type
|
|
58
58
|
|
|
59
59
|
const { Dialog: NewPackageSaveDialog, openDialog: openNewPackageSaveDialog } =
|
|
60
|
-
|
|
60
|
+
useNewPackageSavePromptDialog()
|
|
61
61
|
|
|
62
62
|
const { Dialog: DiscardChangesDialog, openDialog: openDiscardChangesDialog } =
|
|
63
63
|
useConfirmDiscardChangesDialog()
|
|
@@ -106,8 +106,6 @@ export function CodeAndPreview({ pkg, projectUrl }: Props) {
|
|
|
106
106
|
[localFiles, initialFiles, isSaving, state.lastSavedAt],
|
|
107
107
|
)
|
|
108
108
|
|
|
109
|
-
useWarnUserOnPageChange({ hasUnsavedChanges })
|
|
110
|
-
|
|
111
109
|
const handleEditEvent = (event: ManualEditEvent) => {
|
|
112
110
|
const parsedManualEdits = JSON.parse(
|
|
113
111
|
localFiles.find((x) => x.path === "manual-edits.json")?.content || "{}",
|
|
@@ -153,12 +151,13 @@ export function CodeAndPreview({ pkg, projectUrl }: Props) {
|
|
|
153
151
|
})
|
|
154
152
|
}
|
|
155
153
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
)
|
|
154
|
+
useWarnUserOnPageChange({
|
|
155
|
+
hasUnsavedChanges: Boolean(hasUnsavedChanges),
|
|
156
|
+
isPackageThere: Boolean(pkg),
|
|
157
|
+
})
|
|
158
|
+
|
|
160
159
|
return (
|
|
161
|
-
<div className="flex flex-col
|
|
160
|
+
<div className="flex flex-col h-full">
|
|
162
161
|
<EditorNav
|
|
163
162
|
circuitJson={state.circuitJson}
|
|
164
163
|
pkg={pkg}
|
|
@@ -177,7 +176,9 @@ export function CodeAndPreview({ pkg, projectUrl }: Props) {
|
|
|
177
176
|
packageFilesMeta={packageFilesMeta}
|
|
178
177
|
/>
|
|
179
178
|
<div
|
|
180
|
-
className={`flex
|
|
179
|
+
className={`flex flex-1 min-h-0 ${
|
|
180
|
+
state.showPreview ? "flex-col md:flex-row" : ""
|
|
181
|
+
}`}
|
|
181
182
|
>
|
|
182
183
|
<div
|
|
183
184
|
className={cn(
|
|
@@ -215,7 +216,7 @@ export function CodeAndPreview({ pkg, projectUrl }: Props) {
|
|
|
215
216
|
</div>
|
|
216
217
|
<div
|
|
217
218
|
className={cn(
|
|
218
|
-
"flex p-0 flex-col
|
|
219
|
+
"flex p-0 flex-col overflow-y-hidden",
|
|
219
220
|
state.fullScreen
|
|
220
221
|
? "fixed inset-0 z-50 bg-white p-4 overflow-hidden"
|
|
221
222
|
: "w-full md:w-1/2",
|
|
@@ -223,8 +224,10 @@ export function CodeAndPreview({ pkg, projectUrl }: Props) {
|
|
|
223
224
|
)}
|
|
224
225
|
>
|
|
225
226
|
<SuspenseRunFrame
|
|
227
|
+
showFileMenu={false}
|
|
226
228
|
showRunButton
|
|
227
229
|
forceLatestEvalVersion
|
|
230
|
+
isLoadingFiles={isLoading}
|
|
228
231
|
onRenderStarted={() =>
|
|
229
232
|
setState((prev) => ({ ...prev, lastRunCode: currentFileCode }))
|
|
230
233
|
}
|
|
@@ -236,7 +239,7 @@ export function CodeAndPreview({ pkg, projectUrl }: Props) {
|
|
|
236
239
|
onEditEvent={(event) => {
|
|
237
240
|
handleEditEvent(event)
|
|
238
241
|
}}
|
|
239
|
-
fsMap={
|
|
242
|
+
fsMap={fsMap}
|
|
240
243
|
projectUrl={projectUrl}
|
|
241
244
|
/>
|
|
242
245
|
</div>
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
createSystem,
|
|
24
24
|
createVirtualTypeScriptEnvironment,
|
|
25
25
|
} from "@typescript/vfs"
|
|
26
|
-
import { loadDefaultLibMap } from "@/lib/ts-lib-cache"
|
|
26
|
+
import { loadDefaultLibMap, fetchWithPackageCaching } from "@/lib/ts-lib-cache"
|
|
27
27
|
import { tsAutocomplete, tsFacet, tsSync } from "@valtown/codemirror-ts"
|
|
28
28
|
import { getLints } from "@valtown/codemirror-ts"
|
|
29
29
|
import { EditorView } from "codemirror"
|
|
@@ -121,7 +121,7 @@ export const CodeEditor = ({
|
|
|
121
121
|
useViewTsFilesDialog()
|
|
122
122
|
|
|
123
123
|
const entryPointFileName = useMemo(() => {
|
|
124
|
-
const entryPointFile = findTargetFile(files, null)
|
|
124
|
+
const entryPointFile = findTargetFile({ files, filePathFromUrl: null })
|
|
125
125
|
if (entryPointFile?.path) return entryPointFile.path
|
|
126
126
|
return files.find((x) => x.path === "index.tsx")?.path || "index.tsx"
|
|
127
127
|
}, [files])
|
|
@@ -136,7 +136,7 @@ export const CodeEditor = ({
|
|
|
136
136
|
// Only run this if there's an explicit file_path in URL - don't auto-select files
|
|
137
137
|
if (!filePathFromUrl) return
|
|
138
138
|
|
|
139
|
-
const targetFile = findTargetFile(files, filePathFromUrl)
|
|
139
|
+
const targetFile = findTargetFile({ files, filePathFromUrl })
|
|
140
140
|
if (targetFile) {
|
|
141
141
|
const lineNumber = lineNumberFromUrl
|
|
142
142
|
? parseInt(lineNumberFromUrl, 10)
|
|
@@ -219,33 +219,7 @@ export const CodeEditor = ({
|
|
|
219
219
|
projectName: "my-project",
|
|
220
220
|
typescript: tsModule,
|
|
221
221
|
logger: console,
|
|
222
|
-
fetcher:
|
|
223
|
-
const registryPrefixes = [
|
|
224
|
-
"https://data.jsdelivr.com/v1/package/resolve/npm/@tsci/",
|
|
225
|
-
"https://data.jsdelivr.com/v1/package/npm/@tsci/",
|
|
226
|
-
"https://cdn.jsdelivr.net/npm/@tsci/",
|
|
227
|
-
]
|
|
228
|
-
if (
|
|
229
|
-
typeof input === "string" &&
|
|
230
|
-
registryPrefixes.some((prefix) => input.startsWith(prefix))
|
|
231
|
-
) {
|
|
232
|
-
const fullPackageName = input
|
|
233
|
-
.replace(registryPrefixes[0], "")
|
|
234
|
-
.replace(registryPrefixes[1], "")
|
|
235
|
-
.replace(registryPrefixes[2], "")
|
|
236
|
-
const packageName = fullPackageName.split("/")[0].replace(/\./, "/")
|
|
237
|
-
const pathInPackage = fullPackageName.split("/").slice(1).join("/")
|
|
238
|
-
const jsdelivrPath = `${packageName}${
|
|
239
|
-
pathInPackage ? `/${pathInPackage}` : ""
|
|
240
|
-
}`
|
|
241
|
-
return fetch(
|
|
242
|
-
`${apiUrl}/snippets/download?jsdelivr_resolve=${input.includes(
|
|
243
|
-
"/resolve/",
|
|
244
|
-
)}&jsdelivr_path=${encodeURIComponent(jsdelivrPath)}`,
|
|
245
|
-
)
|
|
246
|
-
}
|
|
247
|
-
return fetch(input, init)
|
|
248
|
-
}) as typeof fetch,
|
|
222
|
+
fetcher: fetchWithPackageCaching as typeof fetch,
|
|
249
223
|
delegate: {
|
|
250
224
|
started: () => {
|
|
251
225
|
const manualEditsTypeDeclaration = `
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useCallback } from "react"
|
|
1
|
+
import React, { useState, useCallback, useMemo } from "react"
|
|
2
2
|
import { Button } from "@/components/ui/button"
|
|
3
3
|
import { handleManualEditsImportWithSupportForMultipleFiles } from "@/lib/handleManualEditsImportWithSupportForMultipleFiles"
|
|
4
4
|
import { useImportComponentDialog } from "@/components/dialogs/import-component-dialog"
|
|
@@ -25,11 +25,14 @@ import {
|
|
|
25
25
|
TooltipProvider,
|
|
26
26
|
TooltipTrigger,
|
|
27
27
|
} from "@/components/ui/tooltip"
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
import {
|
|
29
|
+
JlcpcbComponentTsxLoadedPayload,
|
|
30
|
+
KicadStringSelectedPayload,
|
|
31
|
+
TscircuitPackageSelectedPayload,
|
|
32
|
+
} from "@tscircuit/runframe/runner"
|
|
31
33
|
import { ICreateFileProps, ICreateFileResult } from "@/hooks/useFileManagement"
|
|
32
34
|
import { useGlobalStore } from "@/hooks/use-global-store"
|
|
35
|
+
import { openJlcpcbImportIssue } from "@/hooks/use-jlcpcb-component-import"
|
|
33
36
|
|
|
34
37
|
export type FileName = string
|
|
35
38
|
|
|
@@ -59,12 +62,18 @@ export const CodeEditorHeader: React.FC<CodeEditorHeaderProps> = ({
|
|
|
59
62
|
}) => {
|
|
60
63
|
const { Dialog: ImportComponentDialog, openDialog: openImportDialog } =
|
|
61
64
|
useImportComponentDialog()
|
|
62
|
-
const { toast
|
|
65
|
+
const { toast } = useToast()
|
|
63
66
|
const [sidebarOpen, setSidebarOpen] = fileSidebarState
|
|
64
|
-
const API_BASE = useApiBaseUrl()
|
|
65
67
|
const [aiAutocompleteEnabled, setAiAutocompleteEnabled] = aiAutocompleteState
|
|
66
68
|
const session = useGlobalStore((s) => s.session)
|
|
67
69
|
|
|
70
|
+
const jlcpcbProxyRequestHeaders = useMemo(() => {
|
|
71
|
+
if (!session?.token) return undefined
|
|
72
|
+
return {
|
|
73
|
+
Authorization: `Bearer ${session.token}`,
|
|
74
|
+
}
|
|
75
|
+
}, [session?.token])
|
|
76
|
+
|
|
68
77
|
const handleFormatFile = useCallback(() => {
|
|
69
78
|
if (!window.prettier || !window.prettierPlugins) return
|
|
70
79
|
if (!currentFile) return
|
|
@@ -154,54 +163,114 @@ export const CodeEditorHeader: React.FC<CodeEditorHeaderProps> = ({
|
|
|
154
163
|
}
|
|
155
164
|
}, [currentFile, files, toast, updateFileContent])
|
|
156
165
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
const handleTscircuitPackageSelected = useCallback(
|
|
167
|
+
async ({ fullPackageName }: TscircuitPackageSelectedPayload) => {
|
|
168
|
+
if (!currentFile) {
|
|
169
|
+
const message = "Select a file before importing a component."
|
|
170
|
+
toast({
|
|
171
|
+
title: "No file selected",
|
|
172
|
+
description: message,
|
|
173
|
+
variant: "destructive",
|
|
174
|
+
})
|
|
175
|
+
throw new Error(message)
|
|
165
176
|
}
|
|
166
177
|
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
method: options.method,
|
|
174
|
-
headers: {
|
|
175
|
-
authority: options.headers.authority,
|
|
176
|
-
Authorization: `Bearer ${session?.token}`,
|
|
177
|
-
"X-Target-Url": url.toString(),
|
|
178
|
-
"X-Sender-Host": options.headers.origin,
|
|
179
|
-
"X-Sender-Origin": options.headers.origin,
|
|
180
|
-
"content-type": options.headers["content-type"],
|
|
181
|
-
},
|
|
182
|
-
})
|
|
183
|
-
}) as typeof fetch,
|
|
184
|
-
},
|
|
185
|
-
)
|
|
186
|
-
const tsxComponent = await convertRawEasyToTsx(jlcpcbComponent)
|
|
187
|
-
let componentName = component.name.replace(/ /g, "-")
|
|
188
|
-
let componentPath = `imports/${componentName}.tsx`
|
|
189
|
-
if (files[componentPath] || files[`./${componentPath}`]) {
|
|
190
|
-
componentName = `${componentName}-1`
|
|
191
|
-
componentPath = `imports/${componentName}.tsx`
|
|
192
|
-
}
|
|
193
|
-
const createFileResult = createFile({
|
|
194
|
-
newFileName: componentPath,
|
|
195
|
-
content: tsxComponent,
|
|
196
|
-
onError: (error) => {
|
|
197
|
-
throw error
|
|
198
|
-
},
|
|
178
|
+
const existingContent = files[currentFile] ?? ""
|
|
179
|
+
const newContent = `import {} from "${fullPackageName}"\n${existingContent}`
|
|
180
|
+
updateFileContent(currentFile, newContent)
|
|
181
|
+
toast({
|
|
182
|
+
title: "Component imported",
|
|
183
|
+
description: `Added ${fullPackageName} to ${currentFile}.`,
|
|
199
184
|
})
|
|
200
|
-
|
|
201
|
-
|
|
185
|
+
},
|
|
186
|
+
[currentFile, files, toast, updateFileContent],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const handleJlcpcbComponentTsxLoaded = useCallback(
|
|
190
|
+
async ({ result, tsx }: JlcpcbComponentTsxLoadedPayload) => {
|
|
191
|
+
const partNumber = result.component.partNumber || "component"
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const sanitizedBaseName = partNumber
|
|
195
|
+
.toLowerCase()
|
|
196
|
+
.replace(/[^a-z0-9_-]/gi, "-")
|
|
197
|
+
let componentPath = `imports/${sanitizedBaseName}.tsx`
|
|
198
|
+
let suffix = 1
|
|
199
|
+
while (files[componentPath] || files[`./${componentPath}`]) {
|
|
200
|
+
componentPath = `imports/${sanitizedBaseName}-${suffix}.tsx`
|
|
201
|
+
suffix += 1
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const createFileResult = createFile({
|
|
205
|
+
newFileName: componentPath,
|
|
206
|
+
content: tsx,
|
|
207
|
+
onError: (error) => {
|
|
208
|
+
throw error
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
if (!createFileResult.newFileCreated) {
|
|
213
|
+
throw new Error("Failed to create component file")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
toast({
|
|
217
|
+
title: "Component imported",
|
|
218
|
+
description: `${partNumber} saved to ${componentPath}.`,
|
|
219
|
+
})
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const message =
|
|
222
|
+
error instanceof Error
|
|
223
|
+
? error.message
|
|
224
|
+
: "Failed to import component from JLCPCB"
|
|
225
|
+
|
|
226
|
+
toast({
|
|
227
|
+
title: "JLCPCB import failed",
|
|
228
|
+
description: (
|
|
229
|
+
<div className="space-y-2">
|
|
230
|
+
<p>{message}</p>
|
|
231
|
+
<button
|
|
232
|
+
className="text-sm text-blue-500 hover:underline"
|
|
233
|
+
onClick={(event) => {
|
|
234
|
+
event.preventDefault()
|
|
235
|
+
openJlcpcbImportIssue(partNumber, message)
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
File issue on GitHub
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
),
|
|
242
|
+
variant: "destructive",
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
throw new Error(message)
|
|
202
246
|
}
|
|
203
|
-
}
|
|
204
|
-
|
|
247
|
+
},
|
|
248
|
+
[createFile, files, toast],
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
const handleKicadStringSelected = useCallback(
|
|
252
|
+
async ({ footprint, result }: KicadStringSelectedPayload) => {
|
|
253
|
+
try {
|
|
254
|
+
await navigator.clipboard.writeText(footprint)
|
|
255
|
+
toast({
|
|
256
|
+
title: "KiCad footprint copied",
|
|
257
|
+
description: `${result.footprint.qualifiedName} copied to clipboard.`,
|
|
258
|
+
})
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const message =
|
|
261
|
+
error instanceof Error
|
|
262
|
+
? error.message
|
|
263
|
+
: "Failed to copy KiCad footprint to clipboard"
|
|
264
|
+
toast({
|
|
265
|
+
title: "KiCad import failed",
|
|
266
|
+
description: message,
|
|
267
|
+
variant: "destructive",
|
|
268
|
+
})
|
|
269
|
+
throw new Error(message)
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
[toast],
|
|
273
|
+
)
|
|
205
274
|
|
|
206
275
|
return (
|
|
207
276
|
<>
|
|
@@ -345,17 +414,10 @@ export const CodeEditorHeader: React.FC<CodeEditorHeaderProps> = ({
|
|
|
345
414
|
</Button>
|
|
346
415
|
</div>
|
|
347
416
|
<ImportComponentDialog
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
error: (error) => (
|
|
353
|
-
<p>
|
|
354
|
-
Error importing component: {error.message || String(error)}
|
|
355
|
-
</p>
|
|
356
|
-
),
|
|
357
|
-
})
|
|
358
|
-
}}
|
|
417
|
+
onTscircuitPackageSelected={handleTscircuitPackageSelected}
|
|
418
|
+
onJlcpcbComponentTsxLoaded={handleJlcpcbComponentTsxLoaded}
|
|
419
|
+
onKicadStringSelected={handleKicadStringSelected}
|
|
420
|
+
jlcpcbProxyRequestHeaders={jlcpcbProxyRequestHeaders}
|
|
359
421
|
/>
|
|
360
422
|
</div>
|
|
361
423
|
</>
|