Add frontend #1

Merged
KartoffelChipss merged 50 commits from feature/frontend into main 2026-05-06 20:16:59 +02:00
25 changed files with 1293 additions and 16 deletions
Showing only changes of commit 3a96717d02 - Show all commits
+1 -1
View File
@@ -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
+40
View File
@@ -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
+4
View File
@@ -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
+1
View File
@@ -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)
+38 -9
View File
@@ -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
+37
View File
@@ -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');
}
};
+5
View File
@@ -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;
}
+166
View File
@@ -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,
}
+114
View File
@@ -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,
}
+88
View File
@@ -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 }
+7 -1
View File
@@ -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'],
});
},
}); });
} }
+17
View File
@@ -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'],
});
},
});
}
+9
View File
@@ -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'],
});
},
});
}
+18
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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>
+5 -2
View File
@@ -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;
+11
View File
@@ -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',
});
};