Added create site page

This commit is contained in:
2026-04-05 19:03:15 +02:00
parent a6a29461bc
commit a741d769bc
14 changed files with 870 additions and 19 deletions
+22 -2
View File
@@ -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();
};
+11
View File
@@ -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;
+3 -8
View File
@@ -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>
+4 -1
View File
@@ -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" />
+236
View File
@@ -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,
}
+22
View File
@@ -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 }
+190
View File
@@ -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,
}
+31
View File
@@ -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 }
+11
View File
@@ -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);
},
});
}
+3
View File
@@ -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);
+2
View File
@@ -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>
+10 -8
View File
@@ -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>
+283
View File
@@ -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;