Added site overview page
This commit is contained in:
Generated
+1
-1
@@ -5,7 +5,7 @@
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/config/db.sqlite</jdbc-url>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/backend/config/db.sqlite</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
|
||||
@@ -119,6 +119,18 @@ func (c *CachedSiteRepository) UpdateSite(s *models.Site) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) ToggleEnabled(id string) (enabledReturn bool, err error) {
|
||||
enabledReturn, err = c.inner.ToggleEnabled(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
c.mu.Lock()
|
||||
delete(c.sites, id)
|
||||
c.siteListValid = false
|
||||
c.mu.Unlock()
|
||||
return enabledReturn, nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) DeleteSite(id string) error {
|
||||
if err := c.inner.DeleteSite(id); err != nil {
|
||||
return err
|
||||
|
||||
@@ -254,6 +254,46 @@ func (h *SiteHandler) PutSite(c fiber.Ctx) error {
|
||||
return c.JSON(updatedSite)
|
||||
}
|
||||
|
||||
// ToggleEnabled godoc
|
||||
// @Summary Toggle site enabled status
|
||||
// @Description Enable or disable a site by its ID
|
||||
// @Tags Sites
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Site ID"
|
||||
// @Success 200 {object} models.ToggleEnabledResponse
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
// @Router /sites/{id}/enabled [patch]
|
||||
func (h *SiteHandler) ToggleEnabled(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
if _, err := h.Repo.GetSite(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Site not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error checking site before update: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while updating site",
|
||||
})
|
||||
}
|
||||
|
||||
enabled, err := h.Repo.ToggleEnabled(id)
|
||||
|
||||
if err != nil {
|
||||
log.Println("Error while toggling enabled: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while toggling enabled",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(&models.ToggleEnabledResponse{
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteSite godoc
|
||||
// @Summary Delete a site
|
||||
// @Description Delete a site by its ID
|
||||
|
||||
@@ -46,3 +46,7 @@ type CreateSiteResponse struct {
|
||||
Site Site `json:"site"`
|
||||
RawDeployToken string `json:"raw_deploy_token"`
|
||||
}
|
||||
|
||||
type ToggleEnabledResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type SiteRepository interface {
|
||||
ListSites() ([]models.Site, error)
|
||||
CreateSite(s *models.Site) error
|
||||
UpdateSite(s *models.Site) error
|
||||
ToggleEnabled(id string) (enabled bool, err error)
|
||||
DeleteSite(id string) error
|
||||
GetForwardRule(id string) (*models.ForwardRule, error)
|
||||
CreateForwardRule(siteID string, fr *models.ForwardRule) error
|
||||
|
||||
@@ -31,6 +31,7 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d
|
||||
api.Post("/sites", siteHandler.PostSite)
|
||||
api.Put("/sites/:id", siteHandler.PutSite)
|
||||
api.Delete("/sites/:id", siteHandler.DeleteSite)
|
||||
api.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled)
|
||||
|
||||
api.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
|
||||
api.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule)
|
||||
|
||||
@@ -25,7 +25,7 @@ var _ repository.SiteRepository = (*SQLiteSiteRepository)(nil)
|
||||
|
||||
func (r *SQLiteSiteRepository) GetSite(id string) (*models.Site, error) {
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
|
||||
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file
|
||||
FROM sites WHERE id = ?`, id)
|
||||
|
||||
s, err := scanSite(row)
|
||||
@@ -41,7 +41,7 @@ func (r *SQLiteSiteRepository) GetSite(id string) (*models.Site, error) {
|
||||
|
||||
func (r *SQLiteSiteRepository) GetSiteByDomain(domain string) (*models.Site, error) {
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
|
||||
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file
|
||||
FROM sites WHERE domain = ?`, domain)
|
||||
|
||||
s, err := scanSite(row)
|
||||
@@ -60,7 +60,7 @@ func (r *SQLiteSiteRepository) GetSiteByDomain(domain string) (*models.Site, err
|
||||
|
||||
func (r *SQLiteSiteRepository) ListSites() ([]models.Site, error) {
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
|
||||
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file
|
||||
FROM sites`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sites: %w", err)
|
||||
@@ -98,10 +98,10 @@ func (r *SQLiteSiteRepository) CreateSite(s *models.Site) error {
|
||||
s.ID = uuid.NewString()
|
||||
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO sites (id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
INSERT INTO sites (id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.ID, s.Name, s.GitServer, s.Owner, s.Repository, s.Branch,
|
||||
s.Domain, s.DeployToken, s.Enabled, s.NotFoundFile,
|
||||
s.Domain, s.DeployToken, s.Spa, s.Enabled, s.NotFoundFile,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create site insert: %w", err)
|
||||
@@ -131,9 +131,9 @@ func (r *SQLiteSiteRepository) UpdateSite(s *models.Site) error {
|
||||
|
||||
_, err = tx.Exec(`
|
||||
UPDATE sites SET name=?, git_server=?, owner=?, repository=?, branch=?, domain=?,
|
||||
deploy_token=?, enabled=?, not_found_file=? WHERE id=?`,
|
||||
deploy_token=?, spa=?, enabled=?, not_found_file=? WHERE id=?`,
|
||||
s.Name, s.GitServer, s.Owner, s.Repository, s.Branch, s.Domain,
|
||||
s.DeployToken, s.Enabled, s.NotFoundFile, s.ID,
|
||||
s.DeployToken, s.Spa, s.Enabled, s.NotFoundFile, s.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update site: %w", err)
|
||||
@@ -166,6 +166,35 @@ func (r *SQLiteSiteRepository) UpdateSite(s *models.Site) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *SQLiteSiteRepository) ToggleEnabled(id string) (enabledReturn bool, err error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("toggle enabled begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var enabled int
|
||||
err = tx.QueryRow(`SELECT enabled FROM sites WHERE id = ?`, id).Scan(&enabled)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("toggle enabled select: %w", err)
|
||||
}
|
||||
|
||||
newEnabled := 0
|
||||
if enabled == 0 {
|
||||
newEnabled = 1
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`UPDATE sites SET enabled = ? WHERE id = ?`, newEnabled, id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("toggle enabled update: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, fmt.Errorf("toggle enabled commit: %w", err)
|
||||
}
|
||||
return newEnabled != 0, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteSiteRepository) DeleteSite(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM sites WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
@@ -334,7 +363,7 @@ func scanSite(s scanner) (*models.Site, error) {
|
||||
var enabled int
|
||||
err := s.Scan(
|
||||
&site.ID, &site.Name, &site.GitServer, &site.Owner, &site.Repository,
|
||||
&site.Branch, &site.Domain, &site.DeployToken, &enabled, &site.NotFoundFile,
|
||||
&site.Branch, &site.Domain, &site.DeployToken, &site.Spa, &enabled, &site.NotFoundFile,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CreateSiteResponse,
|
||||
GetAllSitesResponse,
|
||||
Site,
|
||||
ToggleSiteEnabledResponse,
|
||||
} from './types/site';
|
||||
|
||||
export const getSites = async (): Promise<GetAllSitesResponse> => {
|
||||
@@ -42,3 +43,39 @@ export const createSite = async (data: CreateSiteRequest): Promise<CreateSiteRes
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateSite = async (id: string, data: Partial<CreateSiteRequest>): Promise<Site> => {
|
||||
const response = await fetch(makeApiUrl(`/sites/${id}`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update site');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const toggleSiteEnabled = async (id: string): Promise<ToggleSiteEnabledResponse> => {
|
||||
const response = await fetch(makeApiUrl(`/sites/${id}/enabled`), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to toggle site enabled state');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteSite = async (id: string): Promise<void> => {
|
||||
const response = await fetch(makeApiUrl(`/sites/${id}`), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete site');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Site {
|
||||
enabled: boolean;
|
||||
spa: boolean;
|
||||
not_found_file: string;
|
||||
last_deployed: string;
|
||||
forward_rules: ForwardRule[];
|
||||
custom_headers: CustomHeaders[];
|
||||
}
|
||||
@@ -55,3 +56,7 @@ export interface CreateSiteResponse {
|
||||
site: Site;
|
||||
raw_deploy_token: string;
|
||||
}
|
||||
|
||||
export interface ToggleSiteEnabledResponse {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { CreateSiteRequest } from '../../api/types/site';
|
||||
import { createSite } from '../../api/sites.api';
|
||||
|
||||
export function useCreateSite() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreateSiteRequest) => {
|
||||
return await createSite(data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['sites'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { deleteSite } from '../../api/sites.api';
|
||||
|
||||
export function useDeleteSite(siteId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => deleteSite(siteId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['site', siteId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['sites'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getSite } from '../../api/sites.api';
|
||||
|
||||
export function useSite(siteId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['site', siteId],
|
||||
queryFn: () => getSite(siteId),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toggleSiteEnabled } from '../../api/sites.api';
|
||||
|
||||
export function useToggleSiteEnabled(siteId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => toggleSiteEnabled(siteId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['site', siteId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['sites'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { updateSite } from '../../api/sites.api';
|
||||
import type { CreateSiteRequest } from '../../api/types/site';
|
||||
|
||||
export function useUpdateSite(siteId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (updatedData: CreateSiteRequest) => updateSite(siteId, updatedData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['site', siteId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['sites'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
+15
-3
@@ -1,8 +1,19 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant data-checked (&[data-state="checked"]);
|
||||
@custom-variant data-unchecked (&[data-state="unchecked"]);
|
||||
@custom-variant data-active (&[data-state="active"]);
|
||||
@custom-variant data-inactive (&[data-state="inactive"]);
|
||||
@custom-variant data-open (&[data-state="open"]);
|
||||
@custom-variant data-closed (&[data-state="closed"]);
|
||||
@custom-variant data-disabled (&[data-disabled]);
|
||||
@custom-variant data-horizontal (&[data-orientation="horizontal"]);
|
||||
@custom-variant data-vertical (&[data-orientation="vertical"]);
|
||||
|
||||
:root {
|
||||
--success: oklch(0.6235 0.1819 149.214);
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.3211 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
@@ -58,6 +69,8 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--success: oklch(0.5984 0.1615 149.214);
|
||||
|
||||
--background: oklch(0.2046 0 0);
|
||||
--foreground: oklch(0.9219 0 0);
|
||||
--card: oklch(0.2686 0 0);
|
||||
@@ -110,10 +123,9 @@
|
||||
--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 {
|
||||
--color-success: var(--success);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ThemeProvider } from './components/theme-provider';
|
||||
|
||||
import './index.css';
|
||||
import NewSite from './pages/NewSite/NewSite';
|
||||
import SiteOverview from './pages/SiteOverview/SiteOverview';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -16,6 +17,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPage />} />
|
||||
<Route path="/sites/new" element={<NewSite />} />
|
||||
<Route path="/sites/:id" element={<SiteOverview />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -3,9 +3,10 @@ import Navbar from '../components/navbar';
|
||||
|
||||
interface PageProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Page = ({ children, title }: PropsWithChildren<PageProps>) => {
|
||||
const Page = ({ children, title, className }: PropsWithChildren<PageProps>) => {
|
||||
useEffect(() => {
|
||||
if (title) document.title = title;
|
||||
}, [title]);
|
||||
@@ -16,7 +17,9 @@ const Page = ({ children, title }: PropsWithChildren<PageProps>) => {
|
||||
profilePictureUrl="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.pinimg.com%2Foriginals%2Fca%2Fa9%2F89%2Fcaa98995f578f038373cb6874b68fdfb.jpg&f=1&nofb=1&ipt=0e7adaafec6ff834508c5ea329c5686552e35fe6bc06c299f38ebee88b22cf0a"
|
||||
userName="jan"
|
||||
/>
|
||||
<main className="max-w-(--breakpoint-xl) mx-auto px-4">{children}</main>
|
||||
<main className={`max-w-(--breakpoint-xl) mx-auto px-4 ${className || ''}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { CheckCircle2, Clock, Loader2, RotateCcw, XCircle } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '../../components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../../components/ui/table';
|
||||
import type { MockDeploymentStatus, MockSite } from './SiteOverview';
|
||||
import { formatDate } from '../../utils/formatDate';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const StatusIcon = ({ status }: { status: MockDeploymentStatus }) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle2 className="w-4 h-4 text-success" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||
case 'building':
|
||||
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
||||
case 'queued':
|
||||
return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const DeploymentsTab = ({ site }: { site: MockSite }) => (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Deployment History</CardTitle>
|
||||
<CardDescription>All deployments for this site.</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<RotateCcw className="w-4 h-4 mr-1.5" />
|
||||
Redeploy
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Commit</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Message</TableHead>
|
||||
<TableHead className="text-right">Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{site.deployments.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={d.status} />
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm capitalize hidden sm:inline',
|
||||
d.status === 'success' && 'text-success',
|
||||
d.status === 'failed' && 'text-destructive',
|
||||
d.status === 'building' && 'text-primary',
|
||||
d.status === 'queued' && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{d.status}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{d.commit}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||
{d.message}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{formatDate(d.timestamp)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default DeploymentsTab;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Check, ExternalLink, GitBranch, X } from 'lucide-react';
|
||||
import type { Site } from '../../api/types/site';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '../../components/ui/card';
|
||||
import { Separator } from '../../components/ui/separator';
|
||||
import { formatDate } from '../../utils/formatDate';
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from '../../components/ui/table';
|
||||
|
||||
const repoUrl = (site: Site) => {
|
||||
const host = site.git_server === 'github' ? 'github.com' : 'gitlab.com';
|
||||
return `https://${host}/${site.owner}/${site.repository}`;
|
||||
};
|
||||
|
||||
const OverviewTab = ({ site }: { site: Site }) => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Repository</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Provider</span>
|
||||
<span className="capitalize">{site.git_server}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Owner / Repo</span>
|
||||
<a
|
||||
href={repoUrl(site)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
{site.owner}/{site.repository}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Branch</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
{site.branch}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Domain</span>
|
||||
<a
|
||||
href={`https://${site.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
{site.domain}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">SPA Mode</span>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
{site.spa ? <Check className="w-4 h-4" /> : <X className="w-4 h-4" />}
|
||||
<span>{site.spa ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Last Deployed</span>
|
||||
<span>{formatDate(site.last_deployed)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent Deployments</CardTitle>
|
||||
<CardDescription>Last 3 deployments for this site.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Commit</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Message</TableHead>
|
||||
<TableHead className="text-right">Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* {site.deployments.slice(0, 3).map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell>
|
||||
<StatusIcon status={d.status} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{d.commit}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||
{d.message}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{formatDate(d.timestamp)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))} */}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default OverviewTab;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Loader2, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '../../components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../../components/ui/dialog';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Switch } from '../../components/ui/switch';
|
||||
import type { CreateSiteRequest, Site } from '../../api/types/site';
|
||||
import { useState } from 'react';
|
||||
import { useDeleteSite } from '../../hooks/api/useDeleteSite';
|
||||
import { useUpdateSite } from '../../hooks/api/useUpdateSite';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
const SettingsTab = ({ site }: { site: Site }) => {
|
||||
const navigate = useNavigate();
|
||||
const updateSite = useUpdateSite(site.id);
|
||||
const deleteSite = useDeleteSite(site.id);
|
||||
|
||||
const [name, setName] = useState(site.name);
|
||||
const [domain, setDomain] = useState(site.domain);
|
||||
const [branch, setBranch] = useState(site.branch);
|
||||
const [spa, setSpa] = useState(site.spa);
|
||||
const [confirmName, setConfirmName] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const handleSaveChanges = () => {
|
||||
const newSiteData: CreateSiteRequest = {
|
||||
name,
|
||||
domain,
|
||||
branch,
|
||||
spa,
|
||||
enabled: site.enabled,
|
||||
git_server: site.git_server,
|
||||
owner: site.owner,
|
||||
repository: site.repository,
|
||||
};
|
||||
updateSite.mutate(newSiteData);
|
||||
};
|
||||
|
||||
const handleDeleteSite = () => {
|
||||
deleteSite.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setDeleteDialogOpen(false);
|
||||
navigate('/');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">General</CardTitle>
|
||||
<CardDescription>Basic site configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-name">Site Name</Label>
|
||||
<Input
|
||||
id="settings-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-domain">Custom Domain</Label>
|
||||
<Input
|
||||
id="settings-domain"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-branch">Branch</Label>
|
||||
<Input
|
||||
id="settings-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="settings-spa">Single Page Application</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Redirect all paths to index.html for client-side routing.
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="settings-spa" checked={spa} onCheckedChange={setSpa} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button onClick={handleSaveChanges} disabled={updateSite.isPending}>
|
||||
{updateSite.isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</Button>
|
||||
{updateSite.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to save changes. Please try again.
|
||||
</p>
|
||||
)}
|
||||
{updateSite.isSuccess && (
|
||||
<p className="text-sm text-success">Changes saved successfully!</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
These actions are irreversible. Proceed with caution.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Delete Site</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently remove this site.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 />
|
||||
Delete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="w-5 h-5 text-destructive" />
|
||||
Delete Site
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-semibold text-foreground">
|
||||
{site.name}
|
||||
</span>{' '}
|
||||
and all associated data. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="confirm-name">
|
||||
Type{' '}
|
||||
<span className="font-mono font-semibold text-foreground">
|
||||
{site.name}
|
||||
</span>{' '}
|
||||
to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-name"
|
||||
placeholder={site.name}
|
||||
value={confirmName}
|
||||
onChange={(e) => setConfirmName(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={confirmName !== site.name || deleteSite.isPending}
|
||||
onClick={handleDeleteSite}
|
||||
>
|
||||
{deleteSite.isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 />
|
||||
Delete Site
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{deleteSite.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to delete site. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsTab;
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useParams } from 'react-router';
|
||||
import Page from '../Page';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
FileCode,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Rocket,
|
||||
Settings,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useSite } from '../../hooks/api/useSite';
|
||||
import { useToggleSiteEnabled } from '../../hooks/api/useToggleSiteEnabled';
|
||||
import SettingsTab from './SettingsTab';
|
||||
import DeploymentsTab from './DeploymentsTab';
|
||||
import OverviewTab from './OverviewTab';
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '../../components/ui/empty';
|
||||
|
||||
export type MockDeploymentStatus = 'success' | 'failed' | 'building' | 'queued';
|
||||
|
||||
export interface MockDeployment {
|
||||
id: string;
|
||||
commit: string;
|
||||
message: string;
|
||||
status: MockDeploymentStatus;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MockSite {
|
||||
id: string;
|
||||
name: string;
|
||||
gitServer: 'github' | 'gitlab';
|
||||
owner: string;
|
||||
repository: string;
|
||||
branch: string;
|
||||
domain: string;
|
||||
spa: boolean;
|
||||
enabled: boolean;
|
||||
lastDeployed: string;
|
||||
deployments: MockDeployment[];
|
||||
}
|
||||
|
||||
const MOCK_SITE: MockSite = {
|
||||
id: '1',
|
||||
name: 'My Portfolio',
|
||||
gitServer: 'github',
|
||||
owner: 'janedoe',
|
||||
repository: 'portfolio',
|
||||
branch: 'gh-pages',
|
||||
domain: 'janedoe.dev',
|
||||
spa: true,
|
||||
enabled: true,
|
||||
lastDeployed: '2026-04-06T10:32:00Z',
|
||||
deployments: [
|
||||
{
|
||||
id: 'd1',
|
||||
commit: 'a3f8c21',
|
||||
message: 'Update hero section copy',
|
||||
status: 'success',
|
||||
timestamp: '2026-04-06T10:32:00Z',
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
commit: 'b7e1d44',
|
||||
message: 'Add project cards component',
|
||||
status: 'success',
|
||||
timestamp: '2026-04-05T16:18:00Z',
|
||||
},
|
||||
{
|
||||
id: 'd3',
|
||||
commit: 'c92fa08',
|
||||
message: 'Fix broken image paths',
|
||||
status: 'failed',
|
||||
timestamp: '2026-04-05T14:05:00Z',
|
||||
},
|
||||
{
|
||||
id: 'd4',
|
||||
commit: 'de610bb',
|
||||
message: 'Initial commit',
|
||||
status: 'success',
|
||||
timestamp: '2026-04-04T09:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const SiteOverviewSkeleton = memo(() => (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-2">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Skeleton className="h-8 w-105 rounded-lg" />
|
||||
<Skeleton className="h-8 w-28 ml-auto rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{[0, 1].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[0, 1, 2].map((j) => (
|
||||
<div key={j} className="flex justify-between">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader className="space-y-2">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-40 hidden sm:block" />
|
||||
<Skeleton className="h-4 w-10 hidden md:block" />
|
||||
<Skeleton className="h-4 w-32 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
));
|
||||
|
||||
const SiteOverview = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: site, isLoading: isLoadingSite, error: siteError } = useSite(id!);
|
||||
const toggleSiteEnabled = useToggleSiteEnabled(id!);
|
||||
|
||||
const mockSite = MOCK_SITE;
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
toggleSiteEnabled.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title={site?.name ?? 'Loading...'}>
|
||||
{isLoadingSite && <SiteOverviewSkeleton />}
|
||||
|
||||
{siteError && !isLoadingSite && (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<X />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-xl">Failed to load site</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Something went wrong while fetching the site data. Please try again.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
{site && !isLoadingSite && !siteError && (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold">{site.name}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1.5">
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<a
|
||||
href={`https://${site.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{site.domain}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<div className="flex items-center">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">
|
||||
<LayoutDashboard />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="deployments">
|
||||
<Rocket />
|
||||
Deployments
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="forward-rules">
|
||||
<ArrowRightLeft />
|
||||
Forward Rules
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="custom-headers">
|
||||
<FileCode />
|
||||
Custom Headers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
<Settings />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Card className="px-3 py-2 border-muted bg-muted ml-auto">
|
||||
<Label>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={site.enabled}
|
||||
onCheckedChange={handleToggleEnabled}
|
||||
/>
|
||||
<span>{site.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</Label>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab site={site} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deployments">
|
||||
<DeploymentsTab site={mockSite} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<SettingsTab site={site} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiteOverview;
|
||||
@@ -0,0 +1,11 @@
|
||||
export const formatDate = (iso: string) => {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user