Add frontend #1
@@ -1,6 +1,12 @@
|
|||||||
import { makeApiUrl } from '.';
|
import { makeApiUrl } from '.';
|
||||||
|
import type {
|
||||||
|
CreateSiteRequest,
|
||||||
|
CreateSiteResponse,
|
||||||
|
GetAllSitesResponse,
|
||||||
|
Site,
|
||||||
|
} from './types/site';
|
||||||
|
|
||||||
export const getSites = async () => {
|
export const getSites = async (): Promise<GetAllSitesResponse> => {
|
||||||
const response = await fetch(makeApiUrl('/sites'), {
|
const response = await fetch(makeApiUrl('/sites'), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
@@ -13,7 +19,7 @@ export const getSites = async () => {
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSite = async (id: string) => {
|
export const getSite = async (id: string): Promise<Site> => {
|
||||||
const response = await fetch(makeApiUrl(`/sites/${id}`), {
|
const response = await fetch(makeApiUrl(`/sites/${id}`), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
@@ -22,3 +28,17 @@ export const getSite = async (id: string) => {
|
|||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createSite = async (data: CreateSiteRequest): Promise<CreateSiteResponse> => {
|
||||||
|
const response = await fetch(makeApiUrl('/sites'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to create site');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ export interface Site {
|
|||||||
custom_headers: CustomHeaders[];
|
custom_headers: CustomHeaders[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateSiteRequest {
|
||||||
|
name: string;
|
||||||
|
git_server: string;
|
||||||
|
owner: string;
|
||||||
|
repository: string;
|
||||||
|
branch: string;
|
||||||
|
domain: string;
|
||||||
|
enabled: boolean;
|
||||||
|
spa: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GetAllSitesResponse {
|
export interface GetAllSitesResponse {
|
||||||
sites: Site[];
|
sites: Site[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -13,22 +13,17 @@ export const NavMenu = (props: ComponentProps<typeof NavigationMenu>) => (
|
|||||||
<NavigationMenuList className="space-x-0 data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-start data-[orientation=vertical]:justify-start">
|
<NavigationMenuList className="space-x-0 data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-start data-[orientation=vertical]:justify-start">
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
<Link to="#">Home</Link>
|
<Link to="/">Home</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
<Link to="#">Blog</Link>
|
<Link to="/analytics">Analytics</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
<Link to="#">About</Link>
|
<Link to="/settings">Settings</Link>
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
|
||||||
<Link to="#">Contact Us</Link>
|
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Logo } from '@/components/logo';
|
|||||||
import { NavMenu } from '@/components/nav-menu';
|
import { NavMenu } from '@/components/nav-menu';
|
||||||
import { NavigationSheet } from '@/components/navigation-sheet';
|
import { NavigationSheet } from '@/components/navigation-sheet';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
@@ -12,7 +13,9 @@ const Navbar = ({ userName, profilePictureUrl }: NavbarProps) => {
|
|||||||
return (
|
return (
|
||||||
<nav className="inset-x-4 my-6 mx-auto h-16 max-w-(--breakpoint-xl) rounded-full border bg-background">
|
<nav className="inset-x-4 my-6 mx-auto h-16 max-w-(--breakpoint-xl) rounded-full border bg-background">
|
||||||
<div className="mx-auto flex h-full items-center justify-between px-4">
|
<div className="mx-auto flex h-full items-center justify-between px-4">
|
||||||
<Logo />
|
<Link to="/">
|
||||||
|
<Logo />
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Menu */}
|
{/* Desktop Menu */}
|
||||||
<NavMenu className="hidden md:block" />
|
<NavMenu className="hidden md:block" />
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid w-full gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="flex size-4 items-center justify-center"
|
||||||
|
>
|
||||||
|
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={position === "item-aligned"}
|
||||||
|
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
data-position={position}
|
||||||
|
className={cn(
|
||||||
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
|
position === "popper" && ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import type { CreateSiteRequest } from '../../api/types/site';
|
||||||
|
import { createSite } from '../../api/sites.api';
|
||||||
|
|
||||||
|
export function useCreateSite() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: CreateSiteRequest) => {
|
||||||
|
return await createSite(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -110,6 +110,9 @@
|
|||||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@custom-variant data-checked (&[data-state="checked"]);
|
||||||
|
@custom-variant data-unchecked (&[data-state="unchecked"]);
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import MainPage from './pages/Main/Main';
|
|||||||
import { ThemeProvider } from './components/theme-provider';
|
import { ThemeProvider } from './components/theme-provider';
|
||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import NewSite from './pages/NewSite/NewSite';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<MainPage />} />
|
<Route path="/" element={<MainPage />} />
|
||||||
|
<Route path="/sites/new" element={<NewSite />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -77,15 +77,15 @@ const SitesLoadingSkeleton = memo(() => {
|
|||||||
const MainPage = () => {
|
const MainPage = () => {
|
||||||
const { data: sites, isLoading, error } = useSites();
|
const { data: sites, isLoading, error } = useSites();
|
||||||
|
|
||||||
const handleAddSite = () => {};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title="Sites">
|
<Page title="Sites">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h1 className="text-2xl font-semibold">Sites</h1>
|
<h1 className="text-2xl font-semibold">Sites</h1>
|
||||||
<Button size="default" onClick={handleAddSite}>
|
<Button size="default" variant={'default'} asChild>
|
||||||
<Plus className="w-4 h-4" />
|
<Link to="/sites/new">
|
||||||
New site
|
<Plus className="w-4 h-4" />
|
||||||
|
New site
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,9 +123,11 @@ const MainPage = () => {
|
|||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
<EmptyContent>
|
<EmptyContent>
|
||||||
<Button variant={'secondary'} onClick={handleAddSite}>
|
<Button size={'default'} variant={'secondary'} asChild>
|
||||||
<Plus />
|
<Link to="/sites/new">
|
||||||
New site
|
<Plus className="w-4 h-4" />
|
||||||
|
New site
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</EmptyContent>
|
</EmptyContent>
|
||||||
</Empty>
|
</Empty>
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Page from '../Page';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Plus, Zap } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useCreateSite } from '../../hooks/api/useCreateSite';
|
||||||
|
|
||||||
|
const GIT_SERVERS = [
|
||||||
|
{
|
||||||
|
value: 'github',
|
||||||
|
label: 'GitHub',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" className="w-6 h-6 fill-current">
|
||||||
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gitlab',
|
||||||
|
label: 'GitLab',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" className="w-6 h-6 fill-current">
|
||||||
|
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 00-.867 0L16.418 9.45H7.582L4.918 1.263a.455.455 0 00-.867 0L1.387 9.452.045 13.587a.924.924 0 00.331 1.023L12 23.054l11.624-8.443a.92.92 0 00.331-1.024" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const parseRepoUrl = (url: string) => {
|
||||||
|
const cleaned = url
|
||||||
|
.trim()
|
||||||
|
.replace(/\.git$/, '')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
/^https?:\/\/(github\.com|gitlab\.com)\/([^/]+)\/([^/]+)/,
|
||||||
|
/^git@(github\.com|gitlab\.com):([^/]+)\/([^/]+)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = cleaned.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const host = match[1];
|
||||||
|
const server = host === 'github.com' ? 'github' : 'gitlab';
|
||||||
|
return { gitServer: server, owner: match[2], repository: match[3] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewSite = () => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [repoUrl, setRepoUrl] = useState('');
|
||||||
|
const [gitServer, setGitServer] = useState('');
|
||||||
|
const [owner, setOwner] = useState('');
|
||||||
|
const [repository, setRepository] = useState('');
|
||||||
|
const [branch, setBranch] = useState('');
|
||||||
|
const [domain, setDomain] = useState('');
|
||||||
|
const [spa, setSpa] = useState(false);
|
||||||
|
const [urlError, setUrlError] = useState('');
|
||||||
|
const createNewSite = useCreateSite();
|
||||||
|
|
||||||
|
const handleQuickImport = () => {
|
||||||
|
if (!repoUrl.trim()) return;
|
||||||
|
|
||||||
|
const parsed = parseRepoUrl(repoUrl);
|
||||||
|
if (parsed) {
|
||||||
|
setGitServer(parsed.gitServer);
|
||||||
|
setOwner(parsed.owner);
|
||||||
|
setRepository(parsed.repository);
|
||||||
|
setUrlError('');
|
||||||
|
} else {
|
||||||
|
setUrlError(
|
||||||
|
'Could not parse URL. Supported formats: https://github.com/owner/repo or git@github.com:owner/repo'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleQuickImport();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
console.log({ name, gitServer, owner, repository, branch, domain, spa });
|
||||||
|
const data = await createNewSite.mutateAsync({
|
||||||
|
name,
|
||||||
|
git_server: gitServer,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
branch,
|
||||||
|
domain,
|
||||||
|
spa,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
console.log('Created site:', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = name && gitServer && owner && repository && branch && domain;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="New Site">
|
||||||
|
<div className="">
|
||||||
|
<h1 className="text-2xl font-semibold mb-1">New Site</h1>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Deploy a static site from a Git repository.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Card className="mb-6 bg-muted/70">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
Quick Import
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Paste a GitHub or GitLab repository URL to auto-fill the fields below.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="https://github.com/owner/repository"
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRepoUrl(e.target.value);
|
||||||
|
setUrlError('');
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={handleQuickImport}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{urlError && <p className="text-sm text-destructive mt-2">{urlError}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator className="mb-6" />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Site Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="My awesome site"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
A display name for your site. This can be changed later and is not part
|
||||||
|
of the URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<h2 className="text-lg font-medium">Repository</h2>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Git Server</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{GIT_SERVERS.map((server) => (
|
||||||
|
<button
|
||||||
|
key={server.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGitServer(server.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg border-2 p-4 text-left transition-colors',
|
||||||
|
'hover:bg-accent/15',
|
||||||
|
gitServer === server.value
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-accent/35'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'shrink-0',
|
||||||
|
gitServer === server.value
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{server.icon}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{server.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="owner">Owner</Label>
|
||||||
|
<Input
|
||||||
|
id="owner"
|
||||||
|
placeholder="owner"
|
||||||
|
value={owner}
|
||||||
|
onChange={(e) => setOwner(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="repository">Repository</Label>
|
||||||
|
<Input
|
||||||
|
id="repository"
|
||||||
|
placeholder="repository"
|
||||||
|
value={repository}
|
||||||
|
onChange={(e) => setRepository(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="branch">Branch</Label>
|
||||||
|
<Input
|
||||||
|
id="branch"
|
||||||
|
placeholder="gh-pages"
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Assets from this branch will be deployed. Make sure it exists before
|
||||||
|
creating the site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<h2 className="text-lg font-medium">Configuration</h2>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="domain">Custom Domain</Label>
|
||||||
|
<Input
|
||||||
|
id="domain"
|
||||||
|
placeholder="example.com"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The domain you want to use for this site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="spa">Single Page Application (SPA)</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Redirect all paths to index.html for client-side routing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch id="spa" checked={spa} onCheckedChange={setSpa} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid}
|
||||||
|
title={!isValid ? 'Please fill in all fields' : ''}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
Create Site
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => window.history.back()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewSite;
|
||||||
Reference in New Issue
Block a user