From 3a96717d02b27763430f8cb998371bfc4459723f Mon Sep 17 00:00:00 2001 From: KartoffelChips <104089082+KartoffelChipss@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:05:14 +0200 Subject: [PATCH] Added site overview page --- .idea/dataSources.xml | 2 +- .../app/cachedrepo/cached_site_repository.go | 12 + backend/app/handlers/site.go | 40 +++ backend/app/models/site.go | 4 + backend/app/repository/site_repository.go | 1 + backend/app/routes/routes.go | 1 + backend/internal/database/site_sqlite.go | 47 +++- frontend/src/api/sites.api.ts | 37 +++ frontend/src/api/types/site.ts | 5 + frontend/src/components/ui/dialog.tsx | 166 ++++++++++++ frontend/src/components/ui/table.tsx | 114 ++++++++ frontend/src/components/ui/tabs.tsx | 88 ++++++ frontend/src/hooks/api/useCreateSite.ts | 8 +- frontend/src/hooks/api/useDeleteSite.ts | 17 ++ frontend/src/hooks/api/useSite.ts | 9 + .../src/hooks/api/useToggleSiteEnabled.ts | 17 ++ frontend/src/hooks/api/useUpdateSite.ts | 18 ++ frontend/src/index.css | 18 +- frontend/src/main.tsx | 2 + frontend/src/pages/Page.tsx | 7 +- .../src/pages/SiteOverview/DeploymentsTab.tsx | 91 +++++++ .../src/pages/SiteOverview/OverviewTab.tsx | 124 +++++++++ .../src/pages/SiteOverview/SettingsTab.tsx | 216 +++++++++++++++ .../src/pages/SiteOverview/SiteOverview.tsx | 254 ++++++++++++++++++ frontend/src/utils/formatDate.ts | 11 + 25 files changed, 1293 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/hooks/api/useDeleteSite.ts create mode 100644 frontend/src/hooks/api/useSite.ts create mode 100644 frontend/src/hooks/api/useToggleSiteEnabled.ts create mode 100644 frontend/src/hooks/api/useUpdateSite.ts create mode 100644 frontend/src/pages/SiteOverview/DeploymentsTab.tsx create mode 100644 frontend/src/pages/SiteOverview/OverviewTab.tsx create mode 100644 frontend/src/pages/SiteOverview/SettingsTab.tsx create mode 100644 frontend/src/pages/SiteOverview/SiteOverview.tsx create mode 100644 frontend/src/utils/formatDate.ts diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 0ebf462..9e8fa74 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -5,7 +5,7 @@ sqlite.xerial true org.sqlite.JDBC - jdbc:sqlite:$PROJECT_DIR$/config/db.sqlite + jdbc:sqlite:$PROJECT_DIR$/backend/config/db.sqlite $ProjectFileDir$ diff --git a/backend/app/cachedrepo/cached_site_repository.go b/backend/app/cachedrepo/cached_site_repository.go index 86822e6..7e01c6a 100644 --- a/backend/app/cachedrepo/cached_site_repository.go +++ b/backend/app/cachedrepo/cached_site_repository.go @@ -119,6 +119,18 @@ func (c *CachedSiteRepository) UpdateSite(s *models.Site) error { return nil } +func (c *CachedSiteRepository) ToggleEnabled(id string) (enabledReturn bool, err error) { + enabledReturn, err = c.inner.ToggleEnabled(id) + if err != nil { + return false, err + } + c.mu.Lock() + delete(c.sites, id) + c.siteListValid = false + c.mu.Unlock() + return enabledReturn, nil +} + func (c *CachedSiteRepository) DeleteSite(id string) error { if err := c.inner.DeleteSite(id); err != nil { return err diff --git a/backend/app/handlers/site.go b/backend/app/handlers/site.go index 7338055..1472381 100644 --- a/backend/app/handlers/site.go +++ b/backend/app/handlers/site.go @@ -254,6 +254,46 @@ func (h *SiteHandler) PutSite(c fiber.Ctx) error { return c.JSON(updatedSite) } +// ToggleEnabled godoc +// @Summary Toggle site enabled status +// @Description Enable or disable a site by its ID +// @Tags Sites +// @Accept json +// @Produce json +// @Param id path string true "Site ID" +// @Success 200 {object} models.ToggleEnabledResponse +// @Failure 404 {object} models.APIError +// @Failure 500 {object} models.APIError +// @Router /sites/{id}/enabled [patch] +func (h *SiteHandler) ToggleEnabled(c fiber.Ctx) error { + id := c.Params("id") + + if _, err := h.Repo.GetSite(id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.Status(fiber.StatusNotFound).JSON(&models.APIError{ + Message: "Site not found", + }) + } + log.Println("Error checking site before update: ", err) + return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{ + Message: "Unexpected error while updating site", + }) + } + + enabled, err := h.Repo.ToggleEnabled(id) + + if err != nil { + log.Println("Error while toggling enabled: ", err) + return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{ + Message: "Unexpected error while toggling enabled", + }) + } + + return c.Status(fiber.StatusOK).JSON(&models.ToggleEnabledResponse{ + Enabled: enabled, + }) +} + // DeleteSite godoc // @Summary Delete a site // @Description Delete a site by its ID diff --git a/backend/app/models/site.go b/backend/app/models/site.go index dfd1de9..aa27893 100644 --- a/backend/app/models/site.go +++ b/backend/app/models/site.go @@ -46,3 +46,7 @@ type CreateSiteResponse struct { Site Site `json:"site"` RawDeployToken string `json:"raw_deploy_token"` } + +type ToggleEnabledResponse struct { + Enabled bool `json:"enabled"` +} diff --git a/backend/app/repository/site_repository.go b/backend/app/repository/site_repository.go index 4ec2ff1..0d3d8be 100644 --- a/backend/app/repository/site_repository.go +++ b/backend/app/repository/site_repository.go @@ -8,6 +8,7 @@ type SiteRepository interface { ListSites() ([]models.Site, error) CreateSite(s *models.Site) error UpdateSite(s *models.Site) error + ToggleEnabled(id string) (enabled bool, err error) DeleteSite(id string) error GetForwardRule(id string) (*models.ForwardRule, error) CreateForwardRule(siteID string, fr *models.ForwardRule) error diff --git a/backend/app/routes/routes.go b/backend/app/routes/routes.go index c2632f0..d52ce31 100644 --- a/backend/app/routes/routes.go +++ b/backend/app/routes/routes.go @@ -31,6 +31,7 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d api.Post("/sites", siteHandler.PostSite) api.Put("/sites/:id", siteHandler.PutSite) api.Delete("/sites/:id", siteHandler.DeleteSite) + api.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled) api.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules) api.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule) diff --git a/backend/internal/database/site_sqlite.go b/backend/internal/database/site_sqlite.go index 9ff03a6..49c44d4 100644 --- a/backend/internal/database/site_sqlite.go +++ b/backend/internal/database/site_sqlite.go @@ -25,7 +25,7 @@ var _ repository.SiteRepository = (*SQLiteSiteRepository)(nil) func (r *SQLiteSiteRepository) GetSite(id string) (*models.Site, error) { row := r.db.QueryRow(` - SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file + SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file FROM sites WHERE id = ?`, id) s, err := scanSite(row) @@ -41,7 +41,7 @@ func (r *SQLiteSiteRepository) GetSite(id string) (*models.Site, error) { func (r *SQLiteSiteRepository) GetSiteByDomain(domain string) (*models.Site, error) { row := r.db.QueryRow(` - SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file + SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file FROM sites WHERE domain = ?`, domain) s, err := scanSite(row) @@ -60,7 +60,7 @@ func (r *SQLiteSiteRepository) GetSiteByDomain(domain string) (*models.Site, err func (r *SQLiteSiteRepository) ListSites() ([]models.Site, error) { rows, err := r.db.Query(` - SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file + SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file FROM sites`) if err != nil { return nil, fmt.Errorf("list sites: %w", err) @@ -98,10 +98,10 @@ func (r *SQLiteSiteRepository) CreateSite(s *models.Site) error { s.ID = uuid.NewString() _, err = tx.Exec(` - INSERT INTO sites (id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO sites (id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, s.ID, s.Name, s.GitServer, s.Owner, s.Repository, s.Branch, - s.Domain, s.DeployToken, s.Enabled, s.NotFoundFile, + s.Domain, s.DeployToken, s.Spa, s.Enabled, s.NotFoundFile, ) if err != nil { return fmt.Errorf("create site insert: %w", err) @@ -131,9 +131,9 @@ func (r *SQLiteSiteRepository) UpdateSite(s *models.Site) error { _, err = tx.Exec(` UPDATE sites SET name=?, git_server=?, owner=?, repository=?, branch=?, domain=?, - deploy_token=?, enabled=?, not_found_file=? WHERE id=?`, + deploy_token=?, spa=?, enabled=?, not_found_file=? WHERE id=?`, s.Name, s.GitServer, s.Owner, s.Repository, s.Branch, s.Domain, - s.DeployToken, s.Enabled, s.NotFoundFile, s.ID, + s.DeployToken, s.Spa, s.Enabled, s.NotFoundFile, s.ID, ) if err != nil { return fmt.Errorf("update site: %w", err) @@ -166,6 +166,35 @@ func (r *SQLiteSiteRepository) UpdateSite(s *models.Site) error { return tx.Commit() } +func (r *SQLiteSiteRepository) ToggleEnabled(id string) (enabledReturn bool, err error) { + tx, err := r.db.Begin() + if err != nil { + return false, fmt.Errorf("toggle enabled begin tx: %w", err) + } + defer tx.Rollback() + + var enabled int + err = tx.QueryRow(`SELECT enabled FROM sites WHERE id = ?`, id).Scan(&enabled) + if err != nil { + return false, fmt.Errorf("toggle enabled select: %w", err) + } + + newEnabled := 0 + if enabled == 0 { + newEnabled = 1 + } + + _, err = tx.Exec(`UPDATE sites SET enabled = ? WHERE id = ?`, newEnabled, id) + if err != nil { + return false, fmt.Errorf("toggle enabled update: %w", err) + } + + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("toggle enabled commit: %w", err) + } + return newEnabled != 0, nil +} + func (r *SQLiteSiteRepository) DeleteSite(id string) error { _, err := r.db.Exec(`DELETE FROM sites WHERE id = ?`, id) if err != nil { @@ -334,7 +363,7 @@ func scanSite(s scanner) (*models.Site, error) { var enabled int err := s.Scan( &site.ID, &site.Name, &site.GitServer, &site.Owner, &site.Repository, - &site.Branch, &site.Domain, &site.DeployToken, &enabled, &site.NotFoundFile, + &site.Branch, &site.Domain, &site.DeployToken, &site.Spa, &enabled, &site.NotFoundFile, ) if err != nil { return nil, err diff --git a/frontend/src/api/sites.api.ts b/frontend/src/api/sites.api.ts index 86fee0c..e3489e5 100644 --- a/frontend/src/api/sites.api.ts +++ b/frontend/src/api/sites.api.ts @@ -4,6 +4,7 @@ import type { CreateSiteResponse, GetAllSitesResponse, Site, + ToggleSiteEnabledResponse, } from './types/site'; export const getSites = async (): Promise => { @@ -42,3 +43,39 @@ export const createSite = async (data: CreateSiteRequest): Promise): Promise => { + 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 => { + 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 => { + const response = await fetch(makeApiUrl(`/sites/${id}`), { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Failed to delete site'); + } +}; diff --git a/frontend/src/api/types/site.ts b/frontend/src/api/types/site.ts index 46750ac..4c440be 100644 --- a/frontend/src/api/types/site.ts +++ b/frontend/src/api/types/site.ts @@ -31,6 +31,7 @@ export interface Site { enabled: boolean; spa: boolean; not_found_file: string; + last_deployed: string; forward_rules: ForwardRule[]; custom_headers: CustomHeaders[]; } @@ -55,3 +56,7 @@ export interface CreateSiteResponse { site: Site; raw_deploy_token: string; } + +export interface ToggleSiteEnabledResponse { + enabled: boolean; +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c44b1db --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -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) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..ac9585e --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..72465b2 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -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) { + return ( + + ) +} + +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 & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/frontend/src/hooks/api/useCreateSite.ts b/frontend/src/hooks/api/useCreateSite.ts index 64d8862..020f646 100644 --- a/frontend/src/hooks/api/useCreateSite.ts +++ b/frontend/src/hooks/api/useCreateSite.ts @@ -1,11 +1,17 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { CreateSiteRequest } from '../../api/types/site'; import { createSite } from '../../api/sites.api'; export function useCreateSite() { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (data: CreateSiteRequest) => { return await createSite(data); }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['sites'], + }); + }, }); } diff --git a/frontend/src/hooks/api/useDeleteSite.ts b/frontend/src/hooks/api/useDeleteSite.ts new file mode 100644 index 0000000..ab3d35f --- /dev/null +++ b/frontend/src/hooks/api/useDeleteSite.ts @@ -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'], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/useSite.ts b/frontend/src/hooks/api/useSite.ts new file mode 100644 index 0000000..bfb09cc --- /dev/null +++ b/frontend/src/hooks/api/useSite.ts @@ -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), + }); +} diff --git a/frontend/src/hooks/api/useToggleSiteEnabled.ts b/frontend/src/hooks/api/useToggleSiteEnabled.ts new file mode 100644 index 0000000..81a5964 --- /dev/null +++ b/frontend/src/hooks/api/useToggleSiteEnabled.ts @@ -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'], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/useUpdateSite.ts b/frontend/src/hooks/api/useUpdateSite.ts new file mode 100644 index 0000000..508863f --- /dev/null +++ b/frontend/src/hooks/api/useUpdateSite.ts @@ -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'], + }); + }, + }); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index b6e253a..e542eee 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,8 +1,19 @@ @import 'tailwindcss'; @custom-variant dark (&:is(.dark *)); +@custom-variant data-checked (&[data-state="checked"]); +@custom-variant data-unchecked (&[data-state="unchecked"]); +@custom-variant data-active (&[data-state="active"]); +@custom-variant data-inactive (&[data-state="inactive"]); +@custom-variant data-open (&[data-state="open"]); +@custom-variant data-closed (&[data-state="closed"]); +@custom-variant data-disabled (&[data-disabled]); +@custom-variant data-horizontal (&[data-orientation="horizontal"]); +@custom-variant data-vertical (&[data-orientation="vertical"]); :root { + --success: oklch(0.6235 0.1819 149.214); + --background: oklch(1 0 0); --foreground: oklch(0.3211 0 0); --card: oklch(1 0 0); @@ -58,6 +69,8 @@ } .dark { + --success: oklch(0.5984 0.1615 149.214); + --background: oklch(0.2046 0 0); --foreground: oklch(0.9219 0 0); --card: oklch(0.2686 0 0); @@ -110,10 +123,9 @@ --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); } -@custom-variant data-checked (&[data-state="checked"]); -@custom-variant data-unchecked (&[data-state="unchecked"]); - @theme inline { + --color-success: var(--success); + --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c1d8daf..e483f2a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,6 +6,7 @@ import { ThemeProvider } from './components/theme-provider'; import './index.css'; import NewSite from './pages/NewSite/NewSite'; +import SiteOverview from './pages/SiteOverview/SiteOverview'; const queryClient = new QueryClient(); @@ -16,6 +17,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> + } /> diff --git a/frontend/src/pages/Page.tsx b/frontend/src/pages/Page.tsx index 1162311..a0acf93 100644 --- a/frontend/src/pages/Page.tsx +++ b/frontend/src/pages/Page.tsx @@ -3,9 +3,10 @@ import Navbar from '../components/navbar'; interface PageProps { title: string; + className?: string; } -const Page = ({ children, title }: PropsWithChildren) => { +const Page = ({ children, title, className }: PropsWithChildren) => { useEffect(() => { if (title) document.title = title; }, [title]); @@ -16,7 +17,9 @@ const Page = ({ children, title }: PropsWithChildren) => { 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" /> -
{children}
+
+ {children} +
); }; diff --git a/frontend/src/pages/SiteOverview/DeploymentsTab.tsx b/frontend/src/pages/SiteOverview/DeploymentsTab.tsx new file mode 100644 index 0000000..6bc7275 --- /dev/null +++ b/frontend/src/pages/SiteOverview/DeploymentsTab.tsx @@ -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 ; + case 'failed': + return ; + case 'building': + return ; + case 'queued': + return ; + } +}; + +const DeploymentsTab = ({ site }: { site: MockSite }) => ( + + +
+ Deployment History + All deployments for this site. +
+ +
+ + + + + Status + Commit + Message + Time + + + + {site.deployments.map((d) => ( + + +
+ + +
+
+ {d.commit} + + {d.message} + + + {formatDate(d.timestamp)} + +
+ ))} +
+
+
+
+); + +export default DeploymentsTab; diff --git a/frontend/src/pages/SiteOverview/OverviewTab.tsx b/frontend/src/pages/SiteOverview/OverviewTab.tsx new file mode 100644 index 0000000..3eb7487 --- /dev/null +++ b/frontend/src/pages/SiteOverview/OverviewTab.tsx @@ -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 }) => ( +
+ + + Repository + + +
+ Provider + {site.git_server} +
+ + + +
+ Branch + + + {site.branch} + +
+
+
+ + + + Configuration + + + + +
+ SPA Mode +
+ {site.spa ? : } + {site.spa ? 'Enabled' : 'Disabled'} +
+
+ +
+ Last Deployed + {formatDate(site.last_deployed)} +
+
+
+ + + + Recent Deployments + Last 3 deployments for this site. + + + + + + Status + Commit + Message + Time + + + + {/* {site.deployments.slice(0, 3).map((d) => ( + + + + + {d.commit} + + {d.message} + + + {formatDate(d.timestamp)} + + + ))} */} + +
+
+
+
+); + +export default OverviewTab; diff --git a/frontend/src/pages/SiteOverview/SettingsTab.tsx b/frontend/src/pages/SiteOverview/SettingsTab.tsx new file mode 100644 index 0000000..c0d796a --- /dev/null +++ b/frontend/src/pages/SiteOverview/SettingsTab.tsx @@ -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 ( +
+ + + General + Basic site configuration. + + +
+ + setName(e.target.value)} + /> +
+
+ + setDomain(e.target.value)} + /> +
+
+ + setBranch(e.target.value)} + /> +
+
+
+ +

+ Redirect all paths to index.html for client-side routing. +

+
+ +
+
+ + {updateSite.isError && ( +

+ Failed to save changes. Please try again. +

+ )} + {updateSite.isSuccess && ( +

Changes saved successfully!

+ )} +
+
+
+ + + + Danger Zone + + These actions are irreversible. Proceed with caution. + + + +
+
+

Delete Site

+

+ Permanently remove this site. +

+
+ + + + + + + + + Delete Site + + + This will permanently delete{' '} + + {site.name} + {' '} + and all associated data. This action cannot be undone. + + +
+ + setConfirmName(e.target.value)} + autoComplete="off" + /> +
+ + + + + + + {deleteSite.isError && ( +

+ Failed to delete site. Please try again. +

+ )} +
+
+
+
+
+
+ ); +}; + +export default SettingsTab; diff --git a/frontend/src/pages/SiteOverview/SiteOverview.tsx b/frontend/src/pages/SiteOverview/SiteOverview.tsx new file mode 100644 index 0000000..b263117 --- /dev/null +++ b/frontend/src/pages/SiteOverview/SiteOverview.tsx @@ -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(() => ( + <> +
+
+ + +
+
+ +
+ + +
+ +
+ {[0, 1].map((i) => ( + + + + + + {[0, 1, 2].map((j) => ( +
+ + +
+ ))} +
+
+ ))} + + + + + + + + {[0, 1, 2].map((i) => ( +
+ + + + + +
+ ))} +
+
+
+ +)); + +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 ( + + {isLoadingSite && } + + {siteError && !isLoadingSite && ( + + + + + + Failed to load site + + Something went wrong while fetching the site data. Please try again. + + + + )} + + {site && !isLoadingSite && !siteError && ( + <> +
+
+
+

{site.name}

+
+

+ + + {site.domain} + +

+
+
+ + +
+ + + + Overview + + + + Deployments + + + + Forward Rules + + + + Custom Headers + + + + Settings + + + + + +
+ + + + + + + + + + + + +
+ + )} +
+ ); +}; + +export default SiteOverview; diff --git a/frontend/src/utils/formatDate.ts b/frontend/src/utils/formatDate.ts new file mode 100644 index 0000000..e98c7c6 --- /dev/null +++ b/frontend/src/utils/formatDate.ts @@ -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', + }); +};