Added site overview page
This commit is contained in:
Generated
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<driver-ref>sqlite.xerial</driver-ref>
|
<driver-ref>sqlite.xerial</driver-ref>
|
||||||
<synchronize>true</synchronize>
|
<synchronize>true</synchronize>
|
||||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
<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>
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
</data-source>
|
</data-source>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -119,6 +119,18 @@ func (c *CachedSiteRepository) UpdateSite(s *models.Site) error {
|
|||||||
return nil
|
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 {
|
func (c *CachedSiteRepository) DeleteSite(id string) error {
|
||||||
if err := c.inner.DeleteSite(id); err != nil {
|
if err := c.inner.DeleteSite(id); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -254,6 +254,46 @@ func (h *SiteHandler) PutSite(c fiber.Ctx) error {
|
|||||||
return c.JSON(updatedSite)
|
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
|
// DeleteSite godoc
|
||||||
// @Summary Delete a site
|
// @Summary Delete a site
|
||||||
// @Description Delete a site by its ID
|
// @Description Delete a site by its ID
|
||||||
|
|||||||
@@ -46,3 +46,7 @@ type CreateSiteResponse struct {
|
|||||||
Site Site `json:"site"`
|
Site Site `json:"site"`
|
||||||
RawDeployToken string `json:"raw_deploy_token"`
|
RawDeployToken string `json:"raw_deploy_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToggleEnabledResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type SiteRepository interface {
|
|||||||
ListSites() ([]models.Site, error)
|
ListSites() ([]models.Site, error)
|
||||||
CreateSite(s *models.Site) error
|
CreateSite(s *models.Site) error
|
||||||
UpdateSite(s *models.Site) error
|
UpdateSite(s *models.Site) error
|
||||||
|
ToggleEnabled(id string) (enabled bool, err error)
|
||||||
DeleteSite(id string) error
|
DeleteSite(id string) error
|
||||||
GetForwardRule(id string) (*models.ForwardRule, error)
|
GetForwardRule(id string) (*models.ForwardRule, error)
|
||||||
CreateForwardRule(siteID string, fr *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.Post("/sites", siteHandler.PostSite)
|
||||||
api.Put("/sites/:id", siteHandler.PutSite)
|
api.Put("/sites/:id", siteHandler.PutSite)
|
||||||
api.Delete("/sites/:id", siteHandler.DeleteSite)
|
api.Delete("/sites/:id", siteHandler.DeleteSite)
|
||||||
|
api.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled)
|
||||||
|
|
||||||
api.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
|
api.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
|
||||||
api.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule)
|
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) {
|
func (r *SQLiteSiteRepository) GetSite(id string) (*models.Site, error) {
|
||||||
row := r.db.QueryRow(`
|
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)
|
FROM sites WHERE id = ?`, id)
|
||||||
|
|
||||||
s, err := scanSite(row)
|
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) {
|
func (r *SQLiteSiteRepository) GetSiteByDomain(domain string) (*models.Site, error) {
|
||||||
row := r.db.QueryRow(`
|
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)
|
FROM sites WHERE domain = ?`, domain)
|
||||||
|
|
||||||
s, err := scanSite(row)
|
s, err := scanSite(row)
|
||||||
@@ -60,7 +60,7 @@ func (r *SQLiteSiteRepository) GetSiteByDomain(domain string) (*models.Site, err
|
|||||||
|
|
||||||
func (r *SQLiteSiteRepository) ListSites() ([]models.Site, error) {
|
func (r *SQLiteSiteRepository) ListSites() ([]models.Site, error) {
|
||||||
rows, err := r.db.Query(`
|
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`)
|
FROM sites`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list sites: %w", err)
|
return nil, fmt.Errorf("list sites: %w", err)
|
||||||
@@ -98,10 +98,10 @@ func (r *SQLiteSiteRepository) CreateSite(s *models.Site) error {
|
|||||||
s.ID = uuid.NewString()
|
s.ID = uuid.NewString()
|
||||||
|
|
||||||
_, err = tx.Exec(`
|
_, err = tx.Exec(`
|
||||||
INSERT INTO sites (id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file)
|
INSERT INTO sites (id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
s.ID, s.Name, s.GitServer, s.Owner, s.Repository, s.Branch,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("create site insert: %w", err)
|
return fmt.Errorf("create site insert: %w", err)
|
||||||
@@ -131,9 +131,9 @@ func (r *SQLiteSiteRepository) UpdateSite(s *models.Site) error {
|
|||||||
|
|
||||||
_, err = tx.Exec(`
|
_, err = tx.Exec(`
|
||||||
UPDATE sites SET name=?, git_server=?, owner=?, repository=?, branch=?, domain=?,
|
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.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 {
|
if err != nil {
|
||||||
return fmt.Errorf("update site: %w", err)
|
return fmt.Errorf("update site: %w", err)
|
||||||
@@ -166,6 +166,35 @@ func (r *SQLiteSiteRepository) UpdateSite(s *models.Site) error {
|
|||||||
return tx.Commit()
|
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 {
|
func (r *SQLiteSiteRepository) DeleteSite(id string) error {
|
||||||
_, err := r.db.Exec(`DELETE FROM sites WHERE id = ?`, id)
|
_, err := r.db.Exec(`DELETE FROM sites WHERE id = ?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -334,7 +363,7 @@ func scanSite(s scanner) (*models.Site, error) {
|
|||||||
var enabled int
|
var enabled int
|
||||||
err := s.Scan(
|
err := s.Scan(
|
||||||
&site.ID, &site.Name, &site.GitServer, &site.Owner, &site.Repository,
|
&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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CreateSiteResponse,
|
CreateSiteResponse,
|
||||||
GetAllSitesResponse,
|
GetAllSitesResponse,
|
||||||
Site,
|
Site,
|
||||||
|
ToggleSiteEnabledResponse,
|
||||||
} from './types/site';
|
} from './types/site';
|
||||||
|
|
||||||
export const getSites = async (): Promise<GetAllSitesResponse> => {
|
export const getSites = async (): Promise<GetAllSitesResponse> => {
|
||||||
@@ -42,3 +43,39 @@ export const createSite = async (data: CreateSiteRequest): Promise<CreateSiteRes
|
|||||||
}
|
}
|
||||||
return response.json();
|
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;
|
enabled: boolean;
|
||||||
spa: boolean;
|
spa: boolean;
|
||||||
not_found_file: string;
|
not_found_file: string;
|
||||||
|
last_deployed: string;
|
||||||
forward_rules: ForwardRule[];
|
forward_rules: ForwardRule[];
|
||||||
custom_headers: CustomHeaders[];
|
custom_headers: CustomHeaders[];
|
||||||
}
|
}
|
||||||
@@ -55,3 +56,7 @@ export interface CreateSiteResponse {
|
|||||||
site: Site;
|
site: Site;
|
||||||
raw_deploy_token: string;
|
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 type { CreateSiteRequest } from '../../api/types/site';
|
||||||
import { createSite } from '../../api/sites.api';
|
import { createSite } from '../../api/sites.api';
|
||||||
|
|
||||||
export function useCreateSite() {
|
export function useCreateSite() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data: CreateSiteRequest) => {
|
mutationFn: async (data: CreateSiteRequest) => {
|
||||||
return await createSite(data);
|
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';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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 {
|
:root {
|
||||||
|
--success: oklch(0.6235 0.1819 149.214);
|
||||||
|
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.3211 0 0);
|
--foreground: oklch(0.3211 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
@@ -58,6 +69,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
--success: oklch(0.5984 0.1615 149.214);
|
||||||
|
|
||||||
--background: oklch(0.2046 0 0);
|
--background: oklch(0.2046 0 0);
|
||||||
--foreground: oklch(0.9219 0 0);
|
--foreground: oklch(0.9219 0 0);
|
||||||
--card: oklch(0.2686 0 0);
|
--card: oklch(0.2686 0 0);
|
||||||
@@ -110,10 +123,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-success: var(--success);
|
||||||
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ThemeProvider } from './components/theme-provider';
|
|||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import NewSite from './pages/NewSite/NewSite';
|
import NewSite from './pages/NewSite/NewSite';
|
||||||
|
import SiteOverview from './pages/SiteOverview/SiteOverview';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<MainPage />} />
|
<Route path="/" element={<MainPage />} />
|
||||||
<Route path="/sites/new" element={<NewSite />} />
|
<Route path="/sites/new" element={<NewSite />} />
|
||||||
|
<Route path="/sites/:id" element={<SiteOverview />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import Navbar from '../components/navbar';
|
|||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page = ({ children, title }: PropsWithChildren<PageProps>) => {
|
const Page = ({ children, title, className }: PropsWithChildren<PageProps>) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (title) document.title = title;
|
if (title) document.title = 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"
|
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"
|
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>
|
</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