diff --git a/packages/ui/app/src/sidebar/BuiltWithFern.tsx b/packages/ui/app/src/sidebar/BuiltWithFern.tsx index 5256918ecb..e820902ccf 100644 --- a/packages/ui/app/src/sidebar/BuiltWithFern.tsx +++ b/packages/ui/app/src/sidebar/BuiltWithFern.tsx @@ -1,11 +1,10 @@ -import { FernTooltip, FernTooltipProvider } from "@fern-ui/components"; +import { FernLogo, FernTooltip, FernTooltipProvider } from "@fern-ui/components"; import { useIsHovering } from "@fern-ui/react-commons"; import cn from "clsx"; import { useContext } from "react"; import { FernLink } from "../components/FernLink"; import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; import { useDocsContext } from "../contexts/docs-context/useDocsContext"; -import { FernLogo } from "./FernLogo"; const BUILT_WITH_FERN_TOOLTIP_CONTENT = "Handcrafted SDKs and Docs for your API"; diff --git a/packages/ui/app/tsconfig.json b/packages/ui/app/tsconfig.json index 99caaa26c6..f72a9e6e96 100644 --- a/packages/ui/app/tsconfig.json +++ b/packages/ui/app/tsconfig.json @@ -6,7 +6,7 @@ "outDir": "./dist", "rootDir": "." }, - "include": ["./src/**/*"], + "include": ["./src/**/*", "../components/src/FernLogo.tsx"], "references": [ { "path": "../../commons/core-utils" }, { "path": "../../commons/loadable" }, diff --git a/packages/ui/components/src/FernDropdown.tsx b/packages/ui/components/src/FernDropdown.tsx index 2717d4cfbd..61122af381 100644 --- a/packages/ui/components/src/FernDropdown.tsx +++ b/packages/ui/components/src/FernDropdown.tsx @@ -192,6 +192,18 @@ function FernDropdownItemValue({ }, renderButtonContent(), ) + ) : option.href != null ? ( + { + return () => { + window.open(option.href, "_blank", "noopener"); + }; + }} + > + {renderButtonContent()} + ) : ( .fern-button { - @apply rounded-lg shrink min-w-0; - } - } - - .fern-button { - @apply transition-[background]; - @apply inline-flex items-center justify-center; - @apply rounded-lg px-3 py-1 text-sm h-10 sm:h-8; - @apply cursor-default; - - &.multiline, - &.multiline > .fern-button-content { - height: auto !important; - } - - &.rounded { - @apply rounded-full; - } - - &:not(.square):has(> .fern-button-content > .fa-icon:first-child), - &:not(.square):has(> .fern-button-content > svg:first-child) { - @apply pl-2; - } - - &:not(.square):has(> .fern-button-content > .fa-icon:last-child), - &:not(.square):has(> .fern-button-content > svg:last-child) { - @apply pr-2; - } - - > .fern-button-content { - @apply items-center inline-flex shrink min-w-0; - @apply gap-1.5 h-6; - - > .fa-icon, - > svg { - @apply size-4 shrink-0; - } - - .fern-button-text { - @apply shrink min-w-0; - } - } - - &:not(.multiline) { - .fern-button-text { - @apply truncate; - } - } - - &:not(.text-left, .text-right) > .fern-button-content { - @apply text-center justify-center; - } - - &.text-left > .fern-button-content { - @apply justify-start flex-1; - - .fern-button-text { - @apply flex-1; - } - } - - &.square { - @apply px-1; - - > .fern-button-content { - @apply w-7 sm:w-6; - } - } - - &.small { - @apply px-2 py-1 text-xs h-7 sm:h-6; - - &:not(.square):has(> .fern-button-content > .fa-icon:first-child), - &:not(.square):has(> .fern-button-content > svg:first-child) { - @apply pl-1.5; - } - - &:not(.square):has(> .fern-button-content > .fa-icon:last-child), - &:not(.square):has(> .fern-button-content > svg:last-child) { - @apply pr-1.5; - } - - > .fern-button-content { - @apply gap-1.5 h-4; - - > .fa-icon, - > svg { - @apply size-3; - } - } - - &.square { - @apply px-1; - - > .fern-button-content { - @apply w-6 sm:w-4; - } - } - } - - &.large { - @apply px-3 lg:px-4 py-2 text-base h-11 sm:h-10; - - &:not(.square):has(> .fern-button-content > .fa-icon:first-child), - &:not(.square):has(> .fern-button-content > svg:first-child) { - @apply pl-3; - } - - &:not(.square):has(> .fern-button-content > .fa-icon:last-child), - &:not(.square):has(> .fern-button-content > svg:last-child) { - @apply pr-3; - } - - > .fern-button-content { - @apply gap-2 h-6; - - > .fern-button-icon { - @apply size-5; - } - } - - &.square { - @apply px-2; - - > .fern-button-content { - @apply w-7 sm:w-6; - } - } - } - - &.disabled { - @apply cursor-not-allowed bg-black/20 text-text-default/40 hover:text-text-default/40; - @apply dark:bg-white/10 dark:text-text-default/50 dark:hover:text-text-default/50; - } - - &:not(.disabled) { - &.minimal { - @apply bg-transparent; - @apply t-muted hover:t-default; - @apply hover:bg-tag-default data-[state=on]:bg-tag-default data-[state=checked]:bg-tag-default data-[state=open]:bg-tag-default data-[state=opening]:bg-tag-default data-[selected=true]:bg-tag-default; - - .fa-icon { - @apply bg-text-default/60; - } - - &:not(.primary, .success, .warning, .danger) { - .fa-icon { - @apply dark:bg-text-default/70; - } - } - - &.primary { - @apply t-accent hover:t-accent; - @apply hover:bg-tag-primary data-[state=on]:bg-tag-primary data-[state=checked]:bg-tag-primary data-[state=open]:bg-tag-primary data-[state=opening]:bg-tag-primary data-[selected=true]:bg-tag-primary; - - .fa-icon { - @apply bg-accent-aa; - } - } - - &.success { - @apply t-success hover:t-success; - @apply hover:bg-tag-success data-[state=on]:bg-tag-success data-[state=checked]:bg-tag-success data-[state=open]:bg-tag-success data-[state=opening]:bg-tag-success data-[selected=true]:bg-tag-success; - - .fa-icon { - @apply bg-intent-success; - } - } - - &.warning { - @apply t-warning hover:t-warning; - @apply hover:bg-tag-warning data-[state=on]:bg-tag-warning data-[state=checked]:bg-tag-warning data-[state=open]:bg-tag-warning data-[state=opening]:bg-tag-warning data-[selected=true]:bg-tag-warning; - - .fa-icon { - @apply bg-intent-warning; - } - } - - &.danger { - @apply t-danger hover:t-danger; - @apply hover:bg-tag-danger data-[state=on]:bg-tag-danger data-[state=checked]:bg-tag-danger data-[state=open]:bg-tag-danger data-[state=opening]:bg-tag-danger data-[selected=true]:bg-tag-danger; - - .fa-icon { - @apply bg-intent-danger; - } - } - } - - &.filled { - @apply bg-text-default/60 t-default; - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-text-default/60 t-default; - } - - .fa-icon { - @apply bg-text-default/60 t-default; - } - - &.primary { - @apply bg-accent hover:bg-accent t-accent-contrast; - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-accent-tinted t-accent-contrast; - } - - .fa-icon { - @apply bg-accent-contrast; - } - } - - &.success { - @apply bg-intent-success text-white dark:text-black; - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-intent-success-lightened text-white dark:text-black; - } - - .fa-icon { - @apply bg-white; - @apply dark:bg-black; - } - } - - &.warning { - @apply bg-intent-warning text-white dark:text-black; - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-intent-warning-lightened text-white dark:text-black; - } - - .fa-icon { - @apply bg-white; - @apply dark:bg-black; - } - } - - &.danger { - @apply bg-intent-danger text-white dark:text-black; - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-intent-danger-lightened text-white dark:text-black; - } - - .fa-icon { - @apply bg-white; - @apply dark:bg-black; - } - } - } - - &.outlined { - @apply ring-1 transition-shadow transition-[background] ring-inset; - @apply ring-default t-default; - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-tag-default t-default; - } - - .fa-icon { - @apply bg-text-default/60; - } - - &.primary { - @apply ring-border-primary t-accent; - - .fa-icon { - @apply bg-accent-aa; - } - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-tag-primary t-accent-aaa; - - .fa-icon { - @apply bg-accent-aaa; - } - } - } - - &.success { - @apply ring-border-success text-intent-success; - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-tag-success text-intent-success; - } - - .fa-icon { - @apply bg-intent-success; - } - } - - &.warning { - @apply ring-border-warning text-intent-warning; - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-tag-warning text-intent-warning; - } - - .fa-icon { - @apply bg-intent-warning; - } - } - - &.danger { - @apply ring-border-danger text-intent-danger; - - &:hover, - &[data-state="on"], - &[data-state="checked"], - &[data-state="open"], - &[data-state="opening"] { - @apply bg-tag-danger text-intent-danger; - } - - .fa-icon { - @apply bg-intent-danger; - } - } - } - } - } - - .fern-button-group { - @apply inline-flex items-stretch; - - > .fern-button { - + .fern-button.outlined, - + .fern-button.filled { - @apply ml-2; - } - } - - > .fern-button.small { - + .fern-button.small.outlined, - + .fern-button.small.filled { - @apply ml-1; - } - } - - > .fern-button.large { - + .fern-button.large.outlined, - + .fern-button.large.filled { - @apply ml-3; - } - } - - > .fern-button.outlined, - > .fern-button.filled { - + .fern-button.minimal { - @apply ml-2; - } - } - } - - .fern-card { - @apply border-default t-default hover:t-default border bg-card; - - &.interactive { - @apply cursor-pointer shadow-card; - @apply hover:shadow-card-elevated hover:border-accent transition-all; - @apply active:shadow-card-elevated active:border-primary; - - &.active { - @apply shadow-card-elevated border-primary; - } - } - - &.elevated { - @apply shadow-card-elevated border-primary transition-all; - } - } - - .fern-input-focus { - @apply ring-border-primary ring-2 outline-none; - } - - .fern-input { - @apply form-input w-full; - @apply border-none bg-transparent #{!important}; - @apply outline-none ring-0 #{!important}; - } - - .fern-textarea { - @apply form-textarea resize-none w-full; - @apply outline-none ring-0 bg-transparent border-none #{!important}; - } - - .fern-textarea-group, - .fern-input-group, - .fern-numeric-input-group { - @apply flex items-center overflow-hidden; - @apply focus-within:fern-input-focus; - @apply ring-default rounded-lg ring-1 ring-inset bg-white dark:bg-tag-default-soft shadow-inner; - } - - .fern-input-icon { - @apply flex items-center justify-center; - @apply w-8 h-full shrink-0; - } - - .fern-input-icon + .fern-input { - @apply pl-8 -ml-8; - } - - .fern-input-right-element { - @apply flex items-center justify-center; - @apply min-w-8 h-full shrink-0 px-0 -ml-2; - } - - .fern-input-group, - .fern-numeric-input-group { - @apply h-10 sm:h-8; - } - - .fern-numeric-input-group .fern-numeric-input-step { - @apply h-full rounded-none border-default; - } - - .fern-numeric-input-group:focus-within .fern-numeric-input-step { - @apply ring-accent hover:bg-tag-primary t-accent hover:t-accent border-primary; - } - - .fern-numeric-input-group .fern-numeric-input-step:first-child { - @apply rounded-l-md border-r; - } - - .fern-numeric-input-group .fern-numeric-input-step:last-child { - @apply rounded-r-md border-l; - } - - .fern-input, - .fern-textarea { - @apply caret-accent py-2 px-2.5 text-sm; - @apply max-sm:text-base; - } - - input.fern-input::-webkit-outer-spin-button, - input.fern-input::-webkit-inner-spin-button { - appearance: none; - margin: 0; - } - - input.fern-input[type="number"] { - appearance: textfield; - } - - .fern-mdx-link { - @apply hover:t-accent underline underline-offset-4 decoration-1 hover:decoration-2 decoration-accent-aa; - @apply font-semibold; - - h1 &, - h2 &, - h3 &, - h4 &, - h5 &, - h6 & { - font-weight: inherit; - } - - svg.external-link-icon { - @apply ml-0.5 inline-block t-muted; - } - - &:hover { - svg.external-link-icon { - @apply t-accent; - } - } - } - - .fern-api-property-key { - @apply font-mono t-default font-semibold text-sm; - } - - .custom-backdrop [data-rmiz-modal-overlay="visible"] { - @apply bg-text-default/20; - } -} diff --git a/packages/ui/fern-dashboard/package.json b/packages/ui/fern-dashboard/package.json index ce4fb7754a..a69a3d704f 100644 --- a/packages/ui/fern-dashboard/package.json +++ b/packages/ui/fern-dashboard/package.json @@ -11,13 +11,22 @@ "preview": "vite preview" }, "dependencies": { + "@devbookhq/splitter": "^1.4.2", "@fern-ui/components": "workspace:*", + "@fern-ui/react-commons": "workspace:*", + "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", "@tanstack/react-query": "^4.29.7", "@tanstack/react-router": "^1.32.13", "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", + "clsx": "^2.1.1", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", "lucide-react": "^0.378.0", + "pluralize": "^8.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "tailwind-merge": "^2.3.0", @@ -26,7 +35,9 @@ "devDependencies": { "@tanstack/router-devtools": "^1.32.13", "@tanstack/router-vite-plugin": "^1.32.10", + "@types/lodash": "^4.17.4", "@types/node": "^20.12.12", + "@types/pluralize": "^0.0.33", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@typescript-eslint/eslint-plugin": "^7.2.0", diff --git a/packages/ui/fern-dashboard/src/components/nav/sdk-navbar.tsx b/packages/ui/fern-dashboard/src/components/nav/sdk-navbar.tsx deleted file mode 100644 index e2d49f9cea..0000000000 --- a/packages/ui/fern-dashboard/src/components/nav/sdk-navbar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { - NavigationMenu, - NavigationMenuContent, - NavigationMenuItem, - NavigationMenuLink, - NavigationMenuList, - NavigationMenuTrigger, -} from "@/components/ui/navigation-menu"; - -export const SdkNavigationMenu = () => { - - - - Item One - - Link - - - - ; -}; diff --git a/packages/ui/fern-dashboard/src/components/sdks/ActivityLog.tsx b/packages/ui/fern-dashboard/src/components/sdks/ActivityLog.tsx new file mode 100644 index 0000000000..e9fe2664de --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/sdks/ActivityLog.tsx @@ -0,0 +1,131 @@ +import { format, isToday } from "date-fns"; +import { SdkActivityStack } from "./SdkActivityStack"; +import { ZeroState } from "./ZeroState"; +import { SdkLanguage } from "./mock-data/Sdk"; + +interface ActivityLogDataEntry { + id: string; + timestamp: number; + title: string; + author: string; + impactedSdks: SdkLanguage[]; +} + +interface ActivityLogData { + entries: ActivityLogDataEntry[]; + totalEntries: number; + pageNumber: number; +} + +export interface ActivityLogProps { + groupId: string; + repoURL: string; + activityFilter?: string; +} + +const ActivityLogEntry = (props: ActivityLogDataEntry) => { + return ( + + + + + + + + {props.title} + by {props.author} + + + + + ); +}; + +const GroupedActivitiyLog = (props: { activity: Record }) => { + return ( + + {Object.keys(props.activity).map((key) => { + let value = props.activity[key]; + + return ( + value && ( + + {key} + {value.map((entry) => ( + + ))} + + ) + ); + })} + + ); +}; + +const DummyActivityLog: ActivityLogData = { + totalEntries: 5, + pageNumber: 0, + entries: [ + { + id: "1", + timestamp: Date.now(), + title: "Introduce a new feature to the API spec, maybe it's pagination related or something else that's really cool.", + author: "armandobelardo", + impactedSdks: [SdkLanguage.RUBY], + }, + { + id: "2", + timestamp: Date.now() - 1 * 24 * 60 * 60 * 1000, + title: "Bump generator X", + author: "dsingvi", + impactedSdks: [SdkLanguage.PYTHON, SdkLanguage.JAVA, SdkLanguage.TYPESCRIPT], + }, + { + id: "3", + timestamp: Date.now() - 1 * 24 * 60 * 60 * 1000, + title: "Bump Fern CLI", + author: "armandobelardo", + impactedSdks: [], + }, + { + id: "4", + timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, + title: "Add OAuth configuration", + author: "dsingvi", + impactedSdks: [], + }, + { + id: "5", + timestamp: Date.now() - 5 * 24 * 60 * 60 * 1000, + title: "Initialize Fern", + author: "dsingvi", + impactedSdks: [SdkLanguage.SWIFT], + }, + ], +}; + +export const ActivityLog: React.FC = (props) => { + // TODO: get this activity log from Github + our backend, given we need to understand the impacted SDKs + const transformedActivity: Record = DummyActivityLog.entries.reduce( + (group, activity) => { + const { timestamp } = activity; + const stringifiedTimestamp = isToday(timestamp) ? "Today" : format(timestamp, "PPPP"); + group[stringifiedTimestamp] = group[stringifiedTimestamp] ?? []; + group[stringifiedTimestamp]?.push(activity); + return group; + }, + {} as Record, + ); + + console.log("printing activity", transformedActivity); + + return Object.keys(transformedActivity).length > 0 ? ( + + ) : ( + + ); +}; diff --git a/packages/ui/fern-dashboard/src/components/sdks/BreadcrumbHeader.tsx b/packages/ui/fern-dashboard/src/components/sdks/BreadcrumbHeader.tsx new file mode 100644 index 0000000000..fed4f23c47 --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/sdks/BreadcrumbHeader.tsx @@ -0,0 +1,136 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { FernButton, FernButtonGroup, FernDropdown, FernLogo, RemoteFontAwesomeIcon } from "@fern-ui/components"; +import { useIsHovering } from "@fern-ui/react-commons"; +import { Slash } from "lucide-react"; + +interface BreadcrumbDropdownProps { + name: string; + options: BreadcrumbLinkProps[]; + action?: { + name: string; + onClick: () => void; + }; +} + +// Note these are meant to be internal links to navigate to, at least to start +interface BreadcrumbLinkProps { + path: string; + name: string; +} + +export interface BreadcrumbHeaderProps { + entries: (BreadcrumbLinkProps | BreadcrumbDropdownProps)[]; +} + +const FernBreadcrumbSeparator = () => { + return ( + + + + ); +}; + +export const BreadcrumbHeader: React.FC = ({ entries }) => { + const { isHovering } = useIsHovering(); + + return ( + + + + + {/* TODO: implement breadcrumbs such that it's home/, should really take in some API that allows this config easily */} + + Home + + + {entries.map((entry, index) => { + let item: React.ReactNode; + if ("options" in entry) { + const dropdownOptions = entry.options.map( + (option): FernDropdown.Option => ({ + type: "value", + label: option.name, + value: option.path, + }), + ); + item = ( + + + + } + className="w-full text-left p-0 gap-x-4 text-bold !bg-white" + variant="outlined" + /> + + + ); + } else { + item = ( + + {entry.name} + + ); + } + + return ( + <> + {item} + {index < entries.length - 1 && } + > + ); + })} + + + + } + onClick={() => { + return () => { + window.open("#TODO: Link Fern config repo", "_blank", "noopener"); + }; + }} + disabled + /> + {/* TODO: Make this a dropdown to get support or feedback */} + } + onClick={() => { + return () => { + window.open("#", "_blank", "noopener"); + }; + }} + disabled + /> + } + onClick={() => { + return () => { + window.open("https://buildwithfern.com/learn/home", "_blank", "noopener"); + }; + }} + disabled + > + Documentation + + {/* TODO: Add in the avatar for log out and eventually managing profile */} + + + ); +}; diff --git a/packages/ui/fern-dashboard/src/components/sdks/SdkActivityStack.tsx b/packages/ui/fern-dashboard/src/components/sdks/SdkActivityStack.tsx new file mode 100644 index 0000000000..c4196296e0 --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/sdks/SdkActivityStack.tsx @@ -0,0 +1,43 @@ +import { cn } from "@/lib/utils"; +import { RemoteFontAwesomeIcon } from "@fern-ui/components"; +import { getIconForSdk } from "./SdkContextCard"; +import { SdkLanguage } from "./mock-data/Sdk"; + +interface SdkActivityStackProps { + sdks: SdkLanguage[]; +} + +const SdkStackIcon: React.FC<{ sdk: SdkLanguage; idx: number; total: number }> = ({ sdk, idx, total }) => { + return ( + = 3, + "z-20 group-hover:-translate-x-8 -left-2.5": + (idx === 1 && total >= 3) || (idx === 0 && total === 2), + }, + )} + > + + + ); +}; + +export const SdkActivityStack: React.FC = (props) => { + const numberSdks = props.sdks.length; + return ( + + {props.sdks.slice(0, 2).map((sdk, idx) => ( + + ))} + {numberSdks > 2 && ( + + + {numberSdks - 2} + + )} + + ); +}; diff --git a/packages/ui/fern-dashboard/src/components/sdks/SdkContextCard.tsx b/packages/ui/fern-dashboard/src/components/sdks/SdkContextCard.tsx new file mode 100644 index 0000000000..4efad57120 --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/sdks/SdkContextCard.tsx @@ -0,0 +1,212 @@ +import { cn } from "@/lib/utils"; +import { FernButton, RemoteFontAwesomeIcon } from "@fern-ui/components"; +import pluralize from "pluralize"; +import { Separator } from "../ui/separator"; +import { DummyGroupContext, DummySdkContext, SdkChecksStatus, SdkLanguage, SdkPublishStatus } from "./mock-data/Sdk"; + +export function getIconForSdk(language: SdkLanguage) { + switch (language) { + case SdkLanguage.PYTHON: + return "fa-brands fa-python"; + case SdkLanguage.TYPESCRIPT: + return "fa-brands fa-js"; + case SdkLanguage.GO: + return "fa-brands fa-golang"; + case SdkLanguage.RUBY: + return "fa-solid fa-gem"; + case SdkLanguage.JAVA: + return "fa-brands fa-java"; + case SdkLanguage.CSHARP: + return "fa-brands fa-microsoft"; // TODO: change to csharp icon + case SdkLanguage.SWIFT: + return "fa-brands fa-swift"; + default: + return "fa-solid fa-code"; + } +} + +// TODO(armandobelardo): This should effectively come from Github +interface CheckStatusContent { + icon: string; + color: string; + text: string; +} +function getCheckStatusContent(status: SdkChecksStatus): CheckStatusContent { + switch (status) { + case SdkChecksStatus.SUCCESSFUL: + return { + icon: "circle-check", + color: "text-green", + text: "Checks passing", + }; + case SdkChecksStatus.RUNNING: + return { + icon: "retweet", + color: "text-muted", + text: "Checks running", + }; + case SdkChecksStatus.FAILED: + return { + icon: "circle-exclamation", + color: "text-red", + text: "Checks failed", + }; + } +} + +// TODO(armandobelardo): This should effectively come from Fern +interface PublishStatusContent { + icon: string; + color: string; + text: string; + buttonStatus?: "PRIMARY" | "DISABLED"; +} +function getPublishStatusContent(status: SdkPublishStatus): PublishStatusContent { + switch (status) { + case SdkPublishStatus.UP_TO_DATE: + return { + icon: "circle-check", + color: "text-green", + text: "Up to Date", + buttonStatus: "DISABLED", + }; + case SdkPublishStatus.OUT_OF_DATE: + return { + icon: "circle-up", + color: "text-primary", + text: "Publish New Release", + buttonStatus: "PRIMARY", + }; + case SdkPublishStatus.PUBLISHING: + return { + icon: "fa-duotone fa-loader", + color: "text-amber", + text: "Publishing...", + }; + } +} + +export const SdkGroupGroup: React.FC<{ groups: DummyGroupContext[] }> = ({ groups }) => { + return ( + + {groups.map((group) => ( + + ))} + + New Group + + + ); +}; + +export const SdkGroupContextCard: React.FC<{ group: DummyGroupContext }> = ({ group }) => { + return ( + + + {group.name} + + Release All SDKs + + + + + ); +}; + +export const SdkCardGroup: React.FC<{ sdks: DummySdkContext[] }> = ({ sdks }) => { + return ( + + + {sdks.map((sdk) => ( + + ))} + + + New SDK + + + ); +}; + +export interface DummySdkContextCardProps { + sdk: DummySdkContext; +} + +export const SdkContextCard: React.FC = (props) => { + const icon = ; + + // TODO(armandobelardo): Check status should come from Github + const checkValues = [SdkChecksStatus.FAILED, SdkChecksStatus.SUCCESSFUL, SdkChecksStatus.RUNNING]; + const randomCheck = checkValues[Math.floor(Math.random() * checkValues.length)]; + const checkContent = getCheckStatusContent(randomCheck); + + // TODO(armandobelardo): Issues should come from Github + const issuesIconColor = props.sdk.issues.length > 0 ? "text-red" : "text-green"; + const issuesText = props.sdk.issues.length > 0 ? pluralize("issues", props.sdk.issues.length, true) : "No issues"; + + // TODO(armandobelardo): Publish status should come from fern maybe + const publishStatusValues = [ + SdkPublishStatus.OUT_OF_DATE, + SdkPublishStatus.PUBLISHING, + SdkPublishStatus.UP_TO_DATE, + ]; + const randomStatus = publishStatusValues[Math.floor(Math.random() * publishStatusValues.length)]; + const publishStatusContent = getPublishStatusContent(randomStatus); + + return ( + + + + {icon} + + + {props.sdk.name} + {props.sdk.packageVersion} + + {"out of date"} + + + {/* */} + + {publishStatusContent.text} + + {/* */} + + + + + Generator version + {props.sdk.generatorVersion} + + + + + {checkContent.text} + + + + + {issuesText} + + + } + onClick={() => { + return () => { + window.open(props.sdk.githubUrl, "_blank", "noopener"); + }; + }} + disabled + /> + + + ); +}; diff --git a/packages/ui/fern-dashboard/src/components/sdks/ZeroState.tsx b/packages/ui/fern-dashboard/src/components/sdks/ZeroState.tsx new file mode 100644 index 0000000000..1673603638 --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/sdks/ZeroState.tsx @@ -0,0 +1,18 @@ +import { RemoteFontAwesomeIcon } from "@fern-ui/components"; +import { ReactNode } from "react"; + +export interface ZeroStateProps { + icon?: string | ReactNode; + title: string; + description: string; +} + +export const ZeroState: React.FC = (props) => { + return ( + + {typeof props.icon === "string" ? : props.icon} + {props.title} + {props.description} + + ); +}; diff --git a/packages/ui/fern-dashboard/src/components/sdks/mock-data/Api.tsx b/packages/ui/fern-dashboard/src/components/sdks/mock-data/Api.tsx new file mode 100644 index 0000000000..0063a5b570 --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/sdks/mock-data/Api.tsx @@ -0,0 +1,20 @@ +export interface Api { + id: string; + name: string; +} + +// [Data] TODO: fetch APIs and redirect to the first one +export const DummyApis: Api[] = [ + { + id: "TODO", + name: "Fern API", + }, + { + id: "TwoDo", + name: "Merge API", + }, + { + id: "3", + name: "Cohere", + }, +]; diff --git a/packages/ui/fern-dashboard/src/components/sdks/mock-data/Sdk.tsx b/packages/ui/fern-dashboard/src/components/sdks/mock-data/Sdk.tsx new file mode 100644 index 0000000000..90043a0864 --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/sdks/mock-data/Sdk.tsx @@ -0,0 +1,115 @@ +export enum SdkPublishStatus { + OUT_OF_DATE, + PUBLISHING, + UP_TO_DATE, +} + +export enum SdkChecksStatus { + SUCCESSFUL, + RUNNING, + FAILED, +} + +export enum SdkLanguage { + JAVA = "Java", + SWIFT = "Swift", + TYPESCRIPT = "TypeScript", + PYTHON = "Python", + RUBY = "Ruby", + GO = "Go", + CSHARP = "C#", +} + +export interface DummyGroupContext { + id: string; + name: string; + sdks: DummySdkContext[]; +} + +export interface DummySdkContext { + id: string; + name: string; + packageVersion: string; + generatorVersion: string; + issues: string[]; + githubUrl?: string; + language: SdkLanguage; +} + +export const DummyGroups = [ + { + id: "group-1", + name: "External SDKs", + sdks: [ + { + id: "merge-python", + name: "merge", + packageVersion: "0.2.0", + generatorVersion: "2.2.0", + issues: ["Issue 1", "Issue 2"], + githubUrl: "https://github.com/merge-api/merge-python-client", + language: SdkLanguage.PYTHON, + }, + { + id: "merge-java", + name: "com.merge:merge-client", + packageVersion: "0.1.0", + generatorVersion: "0.2.3", + issues: [], + githubUrl: "https://github.com/merge-api/merge-java-client", + language: SdkLanguage.JAVA, + }, + { + id: "merge-ruby", + name: "merge", + packageVersion: "0.2.0", + generatorVersion: "2.2.0", + issues: ["Issue 1"], + githubUrl: "https://github.com/merge-api/merge-ruby-client", + language: SdkLanguage.RUBY, + }, + ], + }, + { + id: "group-2", + name: "Internal", + sdks: [ + { + id: "merge-java", + name: "com.merge:merge-client", + packageVersion: "0.1.0", + generatorVersion: "0.2.3", + issues: [], + githubUrl: "https://github.com/merge-api/merge-java-client", + language: SdkLanguage.JAVA, + }, + { + id: "merge-ruby", + name: "merge", + packageVersion: "0.2.0", + generatorVersion: "2.2.0", + issues: ["Issue 1"], + githubUrl: "https://github.com/merge-api/merge-ruby-client", + language: SdkLanguage.RUBY, + }, + { + id: "merge-java", + name: "com.merge:merge-client", + packageVersion: "0.1.0", + generatorVersion: "0.2.3", + issues: [], + githubUrl: "https://github.com/merge-api/merge-java-client", + language: SdkLanguage.JAVA, + }, + { + id: "merge-ruby", + name: "merge", + packageVersion: "0.2.0", + generatorVersion: "2.2.0", + issues: ["Issue 1"], + githubUrl: "https://github.com/merge-api/merge-ruby-client", + language: SdkLanguage.RUBY, + }, + ], + }, +]; diff --git a/packages/ui/fern-dashboard/src/components/ui/breadcrumb.tsx b/packages/ui/fern-dashboard/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000000..71a5c325cd --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) => ) +Breadcrumb.displayName = "Breadcrumb" + +const BreadcrumbList = React.forwardRef< + HTMLOListElement, + React.ComponentPropsWithoutRef<"ol"> +>(({ className, ...props }, ref) => ( + +)) +BreadcrumbList.displayName = "BreadcrumbList" + +const BreadcrumbItem = React.forwardRef< + HTMLLIElement, + React.ComponentPropsWithoutRef<"li"> +>(({ className, ...props }, ref) => ( + +)) +BreadcrumbItem.displayName = "BreadcrumbItem" + +const BreadcrumbLink = React.forwardRef< + HTMLAnchorElement, + React.ComponentPropsWithoutRef<"a"> & { + asChild?: boolean + } +>(({ asChild, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a" + + return ( + + ) +}) +BreadcrumbLink.displayName = "BreadcrumbLink" + +const BreadcrumbPage = React.forwardRef< + HTMLSpanElement, + React.ComponentPropsWithoutRef<"span"> +>(({ className, ...props }, ref) => ( + +)) +BreadcrumbPage.displayName = "BreadcrumbPage" + +const BreadcrumbSeparator = ({ + children, + className, + ...props +}: React.ComponentProps<"li">) => ( + svg]:size-3.5", className)} + {...props} + > + {children ?? } + +) +BreadcrumbSeparator.displayName = "BreadcrumbSeparator" + +const BreadcrumbEllipsis = ({ + className, + ...props +}: React.ComponentProps<"span">) => ( + + + More + +) +BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" + +export { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, + BreadcrumbEllipsis, +} diff --git a/packages/ui/fern-dashboard/src/components/ui/navigation-menu.tsx b/packages/ui/fern-dashboard/src/components/ui/navigation-menu.tsx deleted file mode 100644 index 1419f56695..0000000000 --- a/packages/ui/fern-dashboard/src/components/ui/navigation-menu.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import * as React from "react" -import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" -import { cva } from "class-variance-authority" -import { ChevronDown } from "lucide-react" - -import { cn } from "@/lib/utils" - -const NavigationMenu = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - {children} - - -)) -NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName - -const NavigationMenuList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName - -const NavigationMenuItem = NavigationMenuPrimitive.Item - -const navigationMenuTriggerStyle = cva( - "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" -) - -const NavigationMenuTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - {children}{" "} - - -)) -NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName - -const NavigationMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName - -const NavigationMenuLink = NavigationMenuPrimitive.Link - -const NavigationMenuViewport = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -NavigationMenuViewport.displayName = - NavigationMenuPrimitive.Viewport.displayName - -const NavigationMenuIndicator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -NavigationMenuIndicator.displayName = - NavigationMenuPrimitive.Indicator.displayName - -export { - navigationMenuTriggerStyle, - NavigationMenu, - NavigationMenuList, - NavigationMenuItem, - NavigationMenuContent, - NavigationMenuTrigger, - NavigationMenuLink, - NavigationMenuIndicator, - NavigationMenuViewport, -} diff --git a/packages/ui/fern-dashboard/src/components/ui/scroll-area.tsx b/packages/ui/fern-dashboard/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000000..cf253cf170 --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/packages/ui/fern-dashboard/src/components/ui/separator.tsx b/packages/ui/fern-dashboard/src/components/ui/separator.tsx new file mode 100644 index 0000000000..6d7f12265b --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/packages/ui/fern-dashboard/src/components/ui/textfield.tsx b/packages/ui/fern-dashboard/src/components/ui/textfield.tsx new file mode 100644 index 0000000000..2a9b30904b --- /dev/null +++ b/packages/ui/fern-dashboard/src/components/ui/textfield.tsx @@ -0,0 +1 @@ +TextField; diff --git a/packages/ui/fern-dashboard/src/index.css b/packages/ui/fern-dashboard/src/index.css index b0e6fff596..4b5cca155b 100644 --- a/packages/ui/fern-dashboard/src/index.css +++ b/packages/ui/fern-dashboard/src/index.css @@ -1,76 +1,136 @@ @tailwind base; - @tailwind components; - @tailwind utilities; - - @layer base { +@tailwind components; +@tailwind utilities; + +html, +body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; +} + +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + margin: 0 auto; + height: 100%; + width: 100%; +} + +@layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + --typography-code-font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, liberation mono, + courier new, monospace; + + --typography-body-font-family: "InterVariable", "Inter", sans-serif; + + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; - --radius: 0.5rem; + --radius: 0.5rem; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; } - } +} - @layer base { +@layer base { * { - @apply border-border; + @apply border-border; + } + html, + body { + @apply t-default; + + font-family: var(--typography-body-font-family, var(--body-font-fallback)) !important; + font-size: 16px !important; + } + + code, + pre, + .font-mono { + font-family: var(--typography-code-font-family, var(--code-font-fallback)) !important; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground; + } + h1 { + @apply text-[2.25rem] font-extrabold leading-tight; + } + + h2 { + @apply text-[1.5rem] font-bold leading-tight; + } + + h3 { + @apply text-[1.25rem] font-semibold leading-tight; + } + + h4 { + @apply text-[1.125rem] font-semibold leading-tight; + } + + h5 { + @apply text-[1rem] font-semibold; + } + + h6 { + @apply text-[0.875rem] font-semibold; } - } \ No newline at end of file +} diff --git a/packages/ui/fern-dashboard/src/routeTree.gen.ts b/packages/ui/fern-dashboard/src/routeTree.gen.ts index 663289e631..b812ee4c18 100644 --- a/packages/ui/fern-dashboard/src/routeTree.gen.ts +++ b/packages/ui/fern-dashboard/src/routeTree.gen.ts @@ -17,8 +17,9 @@ import { Route as rootRoute } from './routes/__root' // Create Virtual Routes const IndexLazyImport = createFileRoute('/')() -const SdkSdkIdLazyImport = createFileRoute('/sdk/$sdkId')() -const ApiApiNameLazyImport = createFileRoute('/api/$apiName')() +const ApiApiIdLazyImport = createFileRoute('/api/$apiId')() +const SdkSdkIdIndexLazyImport = createFileRoute('/sdk/$sdkId/')() +const SdkSdkIdJobJobIdLazyImport = createFileRoute('/sdk/$sdkId/job/$jobId')() // Create/Update Routes @@ -27,15 +28,24 @@ const IndexLazyRoute = IndexLazyImport.update({ getParentRoute: () => rootRoute, } as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) -const SdkSdkIdLazyRoute = SdkSdkIdLazyImport.update({ - path: '/sdk/$sdkId', +const ApiApiIdLazyRoute = ApiApiIdLazyImport.update({ + path: '/api/$apiId', getParentRoute: () => rootRoute, -} as any).lazy(() => import('./routes/sdk.$sdkId.lazy').then((d) => d.Route)) +} as any).lazy(() => import('./routes/api.$apiId.lazy').then((d) => d.Route)) -const ApiApiNameLazyRoute = ApiApiNameLazyImport.update({ - path: '/api/$apiName', +const SdkSdkIdIndexLazyRoute = SdkSdkIdIndexLazyImport.update({ + path: '/sdk/$sdkId/', getParentRoute: () => rootRoute, -} as any).lazy(() => import('./routes/api.$apiName.lazy').then((d) => d.Route)) +} as any).lazy(() => + import('./routes/sdk/$sdkId/index.lazy').then((d) => d.Route), +) + +const SdkSdkIdJobJobIdLazyRoute = SdkSdkIdJobJobIdLazyImport.update({ + path: '/sdk/$sdkId/job/$jobId', + getParentRoute: () => rootRoute, +} as any).lazy(() => + import('./routes/sdk/$sdkId/job.$jobId.lazy').then((d) => d.Route), +) // Populate the FileRoutesByPath interface @@ -48,18 +58,25 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexLazyImport parentRoute: typeof rootRoute } - '/api/$apiName': { - id: '/api/$apiName' - path: '/api/$apiName' - fullPath: '/api/$apiName' - preLoaderRoute: typeof ApiApiNameLazyImport + '/api/$apiId': { + id: '/api/$apiId' + path: '/api/$apiId' + fullPath: '/api/$apiId' + preLoaderRoute: typeof ApiApiIdLazyImport parentRoute: typeof rootRoute } - '/sdk/$sdkId': { - id: '/sdk/$sdkId' + '/sdk/$sdkId/': { + id: '/sdk/$sdkId/' path: '/sdk/$sdkId' fullPath: '/sdk/$sdkId' - preLoaderRoute: typeof SdkSdkIdLazyImport + preLoaderRoute: typeof SdkSdkIdIndexLazyImport + parentRoute: typeof rootRoute + } + '/sdk/$sdkId/job/$jobId': { + id: '/sdk/$sdkId/job/$jobId' + path: '/sdk/$sdkId/job/$jobId' + fullPath: '/sdk/$sdkId/job/$jobId' + preLoaderRoute: typeof SdkSdkIdJobJobIdLazyImport parentRoute: typeof rootRoute } } @@ -69,8 +86,38 @@ declare module '@tanstack/react-router' { export const routeTree = rootRoute.addChildren({ IndexLazyRoute, - ApiApiNameLazyRoute, - SdkSdkIdLazyRoute, + ApiApiIdLazyRoute, + SdkSdkIdIndexLazyRoute, + SdkSdkIdJobJobIdLazyRoute, }) /* prettier-ignore-end */ + + +/* ROUTE_MANIFEST_START +{ + "routes": { + "__root__": { + "filePath": "__root.tsx", + "children": [ + "/", + "/api/$apiId", + "/sdk/$sdkId/", + "/sdk/$sdkId/job/$jobId" + ] + }, + "/": { + "filePath": "index.lazy.tsx" + }, + "/api/$apiId": { + "filePath": "api.$apiId.lazy.tsx" + }, + "/sdk/$sdkId/": { + "filePath": "sdk/$sdkId/index.lazy.tsx" + }, + "/sdk/$sdkId/job/$jobId": { + "filePath": "sdk/$sdkId/job.$jobId.lazy.tsx" + } + } +} +ROUTE_MANIFEST_END */ \ No newline at end of file diff --git a/packages/ui/fern-dashboard/src/routes/api.$apiId.lazy.tsx b/packages/ui/fern-dashboard/src/routes/api.$apiId.lazy.tsx new file mode 100644 index 0000000000..4e66566921 --- /dev/null +++ b/packages/ui/fern-dashboard/src/routes/api.$apiId.lazy.tsx @@ -0,0 +1,149 @@ +import { ActivityLog } from "@/components/sdks/ActivityLog"; +import { BreadcrumbHeader } from "@/components/sdks/BreadcrumbHeader"; +import { SdkCardGroup, SdkGroupGroup } from "@/components/sdks/SdkContextCard"; +import { ZeroState } from "@/components/sdks/ZeroState"; +import { Api, DummyApis } from "@/components/sdks/mock-data/Api"; +import { DummyGroups } from "@/components/sdks/mock-data/Sdk"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import ReactSplit, { GutterTheme, SplitDirection } from "@devbookhq/splitter"; +import { FernButton, FernButtonGroup, FernDropdown, FernInput, FernTooltip } from "@fern-ui/components"; +import { Cross1Icon, ExternalLinkIcon, InfoCircledIcon, MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { createLazyFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; + +export const Route = createLazyFileRoute("/api/$apiId")({ + component: () => , +}); + +const ApiContent = () => { + const { apiId } = Route.useParams(); + + // [Data] TODO: Fetch the SDKs for the current API + const api = DummyApis.find((a: Api) => a.id === apiId); + return api != null ? ( + <> + ({ name: api.name, path: `/api/${api.id}` })) }, + ]} + /> + + + + + + + > + ) : ( + + ); +}; + +const SdkPane = ({ apiName }: { apiName: string }): JSX.Element => { + // TODO: add groups into here, new SDK button becomes New Group, and we keep the "New SDK" button + const linkIcon = ; + const manageApiOptions: FernDropdown.Option[] = [ + { + type: "value", + label: "Edit API Spec", + icon: linkIcon, + value: "edit", + href: "[Data] TODO: Link to github API spec", + }, + { + type: "value", + label: "Edit Overrides", + icon: linkIcon, + value: "edit-overrides", + href: "[Data] TODO: Link to github overrides file", + }, + { + type: "value", + label: "Edit Settings", + icon: linkIcon, + value: "edit-settings", + href: "[Data] TODO: Link to github generators.yml", + }, + ]; + return ( + + + + Your SDKs + For API {apiName} + + + + + Manage API + + + + + + + {/* Only show the groups segmentation if there are multiple groups + [Data] TODO: fetch data of the form `DummyGroupContext` + */} + + {DummyGroups.length > 1 ? ( + + ) : ( + + )} + + + + ); +}; + +const ActivityPane = (): JSX.Element => { + // [Data] TODO: Fetch the activity log for the API + + // TODO: see what search filters GitHub exposes on a get commits endpoint + // if nothing we will likely have to move this over to the backend. + // This would also let us enrich it with metadata about impacted SDKs, etc. + const [search, setSearch] = useState(); + + return ( + + + Activity + } + value={search} + onValueChange={setSearch} + rightElement={ + (search || "").length > 0 ? ( + setSearch(undefined)}> + + + ) : ( + + + + ) + } + placeholder="Search activity" + className="border" + /> + + + + + + + + + ); +}; diff --git a/packages/ui/fern-dashboard/src/routes/api.$apiName.lazy.tsx b/packages/ui/fern-dashboard/src/routes/api.$apiName.lazy.tsx deleted file mode 100644 index bfbd316196..0000000000 --- a/packages/ui/fern-dashboard/src/routes/api.$apiName.lazy.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createLazyFileRoute } from "@tanstack/react-router"; - -export const Route = createLazyFileRoute("/api/$apiName")({ - component: () => , -}); - -const SdkContent = () => { - const { apiName } = Route.useParams(); - - return ( - - {/* TODO: create the top breadcrumb bar */} - - Your SDKs - - - ); -}; diff --git a/packages/ui/fern-dashboard/src/routes/index.lazy.tsx b/packages/ui/fern-dashboard/src/routes/index.lazy.tsx index 15a1611fe7..95c340f7b6 100644 --- a/packages/ui/fern-dashboard/src/routes/index.lazy.tsx +++ b/packages/ui/fern-dashboard/src/routes/index.lazy.tsx @@ -1,14 +1,15 @@ -import { createLazyFileRoute } from "@tanstack/react-router"; +import { DummyApis } from "@/components/sdks/mock-data/Api"; +import { createLazyFileRoute, useNavigate } from "@tanstack/react-router"; export const Route = createLazyFileRoute("/")({ component: Index, }); function Index() { - return ( - - - Welcome Home! - - ); + // For now we want to always redirect users to their API, prioritizing + // single API management + const navigate = useNavigate(); + // [Data] TODO: fetch APIs and redirect to the first one + navigate({ to: "/api/$apiId", params: { apiId: DummyApis[0].id } }); + return; } diff --git a/packages/ui/fern-dashboard/src/routes/sdk.$sdkId.lazy.tsx b/packages/ui/fern-dashboard/src/routes/sdk.$sdkId.lazy.tsx deleted file mode 100644 index d321a40f34..0000000000 --- a/packages/ui/fern-dashboard/src/routes/sdk.$sdkId.lazy.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createLazyFileRoute } from "@tanstack/react-router"; - -export const Route = createLazyFileRoute("/sdk/$sdkId")({ - component: () => , -}); - -const SdkContent = () => { - const { sdkId } = Route.useParams(); - - return ( - - {/* TODO: create the top breadcrumb bar */} - - Your SDKs - Your SDKs - - - ); -}; diff --git a/packages/ui/fern-dashboard/src/routes/sdk/$sdkId/index.lazy.tsx b/packages/ui/fern-dashboard/src/routes/sdk/$sdkId/index.lazy.tsx new file mode 100644 index 0000000000..164522aa8a --- /dev/null +++ b/packages/ui/fern-dashboard/src/routes/sdk/$sdkId/index.lazy.tsx @@ -0,0 +1,11 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; + +export const Route = createLazyFileRoute("/sdk/$sdkId/")({ + component: () => , +}); + +const SdkContent = () => { + const { sdkId } = Route.useParams(); + // TODO create a wrapping header + return <>About to edit an SDK with ID: {sdkId}>; +}; diff --git a/packages/ui/fern-dashboard/src/routes/sdk/$sdkId/job.$jobId.lazy.tsx b/packages/ui/fern-dashboard/src/routes/sdk/$sdkId/job.$jobId.lazy.tsx new file mode 100644 index 0000000000..5acf477a06 --- /dev/null +++ b/packages/ui/fern-dashboard/src/routes/sdk/$sdkId/job.$jobId.lazy.tsx @@ -0,0 +1,215 @@ +import { BreadcrumbHeader } from "@/components/sdks/BreadcrumbHeader"; +import { getIconForSdk } from "@/components/sdks/SdkContextCard"; +import { DummyApis } from "@/components/sdks/mock-data/Api"; +import { DummyGroups } from "@/components/sdks/mock-data/Sdk"; +import { FernButton, RemoteFontAwesomeIcon } from "@fern-ui/components"; +import { Navigate, createLazyFileRoute } from "@tanstack/react-router"; + +export const Route = createLazyFileRoute("/sdk/$sdkId/job/$jobId")({ + component: () => , +}); + +enum JobStatus { + SUCCESS, + FAILURE, + WARNING, + IN_PROGRESS, + PENDING, +} + +interface JobStep { + id: string; + name: string; + status: JobStatus; + startTime?: number; + endTime?: number; +} + +interface Job { + id: string; + status: JobStatus; + fromVersion: string; + toVersion: string; + steps: JobStep[]; + startTime?: number; + endTime?: number; +} + +function getIconForJobStatus(status: JobStatus): JSX.Element { + switch (status) { + case JobStatus.SUCCESS: + return ; + case JobStatus.FAILURE: + return ; + case JobStatus.WARNING: + return ; + case JobStatus.IN_PROGRESS: + return ; + case JobStatus.PENDING: + return ; + } +} + +const DummyJob: Job = { + id: "job-1", + fromVersion: "1.0.0", + toVersion: "1.0.1", + status: JobStatus.IN_PROGRESS, + startTime: new Date().getTime() - 60000, + steps: [ + { + id: "step-1", + name: "Validate OpenAPI Spec", + status: JobStatus.SUCCESS, + startTime: new Date().getTime() - 60000, + endTime: new Date().getTime(), + }, + { + id: "step-2", + name: "Building SDK", + status: JobStatus.IN_PROGRESS, + }, + { + id: "step-3", + name: "Publishing to NPM", + status: JobStatus.PENDING, + }, + ], +}; + +const getTimeDiff = (start: number, end: number = new Date().getTime()): string => { + const diffMs = end - start; + const diffDays = Math.floor(diffMs / 86400000); + const diffHrs = Math.floor((diffMs % 86400000) / 3600000); + const diffMins = Math.round(((diffMs % 86400000) % 3600000) / 60000); + const diffSec = Math.round((((diffMs % 86400000) % 3600000) % 60000) / 60000); + + let diffStr = `${diffSec}s`; + if (diffMins > 0) { + diffStr = `${diffMins}m ${diffStr}`; + } + if (diffHrs > 0) { + diffStr = `${diffHrs}h ${diffStr}`; + } + if (diffDays > 0) { + diffStr = `${diffDays}d ${diffStr}`; + } + + return diffStr; +}; + +const getTimePercentage = ( + job: Job, + end: number = new Date().getTime(), + start?: number, +): { percentStepStart?: number; percentStepTotal?: number; percentPostStep?: number } => { + if (start == null || job.startTime == null) { + return {}; + } + const jobEnd = job.endTime ?? new Date().getTime(); + const totalJobTime = jobEnd - job.startTime; + const startStepDiff = start - job.startTime; + const stepTime = end - start; + + // Idle time before the job started + const percentStartTime = startStepDiff / totalJobTime; + const percentStepTime = stepTime / totalJobTime; + + return { + percentStepStart: percentStartTime * 100, + percentStepTotal: percentStepTime * 100, + percentPostStep: (1 - (percentStartTime + percentStepTime)) * 100, + }; +}; + +const JobTracker = () => { + const { sdkId, jobId } = Route.useParams(); + // [Data] TODO: get the SDK and job data + const api = DummyApis[0]; + const sdk = DummyGroups[0].sdks.find((sdk) => sdk.id === sdkId); + + return api != null && sdk != null ? ( + <> + + + + + + + Publish {sdk.name} + + + Return to API Overview + + + + + + + {getIconForJobStatus(DummyJob.status)} + + Publishing {sdk.name} @ {DummyJob.toVersion} + + + } + onClick={() => { + return () => { + window.open("#TODO: Link Fern config repo", "_blank", "noopener"); + }; + }} + disabled + /> + + + + + {DummyJob.steps.map((step) => ( + + {step.name} + + + + + + + + + {step.startTime == null ? "--" : getTimeDiff(step.startTime, step.endTime)} + + + ))} + + + + + + > + ) : ( + + ); +}; diff --git a/packages/ui/fern-dashboard/tailwind.config.cjs b/packages/ui/fern-dashboard/tailwind.config.cjs index 7cb7e37ab7..b9f9532a7b 100644 --- a/packages/ui/fern-dashboard/tailwind.config.cjs +++ b/packages/ui/fern-dashboard/tailwind.config.cjs @@ -1,77 +1,90 @@ +const baseConfig = require("../tailwind.config.js"); +const path = require("path"); + /** @type {import('tailwindcss').Config} */ module.exports = { - darkMode: ["class"], - content: [ - './pages/**/*.{ts,tsx}', - './components/**/*.{ts,tsx}', - './app/**/*.{ts,tsx}', - './src/**/*.{ts,tsx}', - ], - prefix: "", - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, + ...baseConfig, + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + "../tailwind.config.cjs", + path.join(path.dirname(require.resolve("@fern-ui/components")), "**/*.{ts,tsx}") + ], + prefix: "", + theme: { + ...baseConfig.theme, + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px" + } }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, + extend: { + ...baseConfig.theme.extend, + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))" + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))" + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))" + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))" + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))" + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))" + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))" + }, + ...baseConfig.theme.extend.colors + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + ...baseConfig.theme.extend.borderRadius + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" } + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" } + }, + ...baseConfig.theme.extend.keyframes + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + "spin-slow": "spin 3s linear infinite", + ...baseConfig.theme.extend.animation + } + } }, - }, - plugins: [require("tailwindcss-animate")], -} \ No newline at end of file + plugins: [...baseConfig.plugins, require("tailwindcss-animate")] +}; diff --git a/packages/ui/fern-dashboard/tsconfig.json b/packages/ui/fern-dashboard/tsconfig.json index 04eb5481d4..4cde1ab7e8 100644 --- a/packages/ui/fern-dashboard/tsconfig.json +++ b/packages/ui/fern-dashboard/tsconfig.json @@ -25,5 +25,5 @@ } }, "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "references": [{ "path": "./tsconfig.node.json" }, { "path": "../../commons/react/react-commons" }] } diff --git a/packages/ui/fern-dashboard/vite.config.ts b/packages/ui/fern-dashboard/vite.config.ts index 2c97f8f26f..60a7d25adb 100644 --- a/packages/ui/fern-dashboard/vite.config.ts +++ b/packages/ui/fern-dashboard/vite.config.ts @@ -11,4 +11,7 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + define: { + process: {}, + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2fd7986d9..3bd55d8ddd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1515,12 +1515,30 @@ importers: packages/ui/fern-dashboard: dependencies: + '@devbookhq/splitter': + specifier: ^1.4.2 + version: 1.4.2 '@fern-ui/components': specifier: workspace:* version: link:../components + '@fern-ui/react-commons': + specifier: workspace:* + version: link:../../commons/react/react-commons + '@radix-ui/react-icons': + specifier: ^1.3.0 + version: 1.3.0(react@18.3.1) '@radix-ui/react-navigation-menu': specifier: ^1.1.4 version: 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.0.5 + version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-separator': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.0.2 + version: 1.0.2(@types/react@18.3.1)(react@18.3.1) '@tanstack/react-query': specifier: ^4.29.7 version: 4.36.1(react-dom@18.3.1)(react@18.3.1) @@ -1531,11 +1549,20 @@ importers: specifier: ^0.7.0 version: 0.7.0 clsx: - specifier: ^2.1.0 + specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^2.30.0 + version: 2.30.0 + lodash: + specifier: ^4.17.21 + version: 4.17.21 lucide-react: specifier: ^0.378.0 version: 0.378.0(react@18.3.1) + pluralize: + specifier: ^8.0.0 + version: 8.0.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -1555,9 +1582,15 @@ importers: '@tanstack/router-vite-plugin': specifier: ^1.32.10 version: 1.33.8(vite@5.2.11) + '@types/lodash': + specifier: ^4.17.4 + version: 4.17.4 '@types/node': specifier: ^20.12.12 version: 20.12.12 + '@types/pluralize': + specifier: ^0.0.33 + version: 0.0.33 '@types/react': specifier: ^18.2.66 version: 18.3.1 @@ -4413,6 +4446,12 @@ packages: '@datadog/browser-core': 5.17.1 dev: false + /@devbookhq/splitter@1.4.2: + resolution: {integrity: sha512-DqJXsL7WNeDn/DyCeyoeeSpFHHoYBXscYlKNd3cJQ5d1xur73MPezHpyR2OID6Kh40TZ4KAb4hYjl5nL2+5M1g==} + dependencies: + react-is: 17.0.2 + dev: false + /@discoveryjs/json-ext@0.5.7: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -7002,6 +7041,27 @@ packages: react-remove-scroll: 2.5.5(@types/react@18.3.1)(react@18.3.1) dev: false + /@radix-ui/react-separator@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.1 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@radix-ui/react-slot@1.0.1(react@18.3.1): resolution: {integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==} peerDependencies: @@ -10862,6 +10922,10 @@ packages: resolution: {integrity: sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==} dev: true + /@types/lodash@4.17.4: + resolution: {integrity: sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==} + dev: true + /@types/marked@5.0.2: resolution: {integrity: sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==} dev: true @@ -10937,6 +11001,10 @@ packages: resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==} dev: false + /@types/pluralize@0.0.33: + resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + dev: true + /@types/postcss-modules-local-by-default@4.0.2: resolution: {integrity: sha512-CtYCcD+L+trB3reJPny+bKWKMzPfxEyQpKIwit7kErnOexf5/faaGpkFy4I5AwbV4hp1sk7/aTg0tt0B67VkLQ==} dependencies: