feat: add frontend

This commit is contained in:
Khairul Hidayat 2024-05-12 06:00:04 +07:00
parent 093b0056fb
commit 3d7508816f
72 changed files with 2558 additions and 212 deletions

View File

@ -1,4 +1,4 @@
import { STORAGE_DIR } from "@/consts"; import { STORAGE_DIR } from "../consts";
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({

View File

@ -1,8 +1,8 @@
import path from "path"; import path from "path";
import { drizzle } from "drizzle-orm/bun-sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { DATABASE_PATH } from "@/consts"; import { DATABASE_PATH } from "../consts";
import { mkdir } from "@/utility/utils"; import { mkdir } from "../utility/utils";
import schema from "./schema"; import schema from "./schema";
// Create database directory if not exists // Create database directory if not exists

View File

@ -1,6 +1,6 @@
import fs from "fs"; import fs from "fs";
import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { DATABASE_PATH } from "@/consts"; import { DATABASE_PATH } from "../consts";
import db, { sqlite } from "."; import db, { sqlite } from ".";
import { seed } from "./seed"; import { seed } from "./seed";

View File

@ -3,6 +3,7 @@ import type {
PostgresConfig, PostgresConfig,
} from "../../types/database.types"; } from "../../types/database.types";
import { exec } from "../../utility/process"; import { exec } from "../../utility/process";
import { urlencode } from "../../utility/utils";
import BaseDbms from "./base"; import BaseDbms from "./base";
class PostgresDbms extends BaseDbms { class PostgresDbms extends BaseDbms {
@ -18,7 +19,7 @@ class PostgresDbms extends BaseDbms {
} }
async dump(dbName: string, path: string) { async dump(dbName: string, path: string) {
return exec(["pg_dump", this.dbUrl + `/${dbName}`, "-Ftar", "-f", path]); return exec(["pg_dump", this.dbUrl + `/${dbName}`, "-Z9", "-f", path]);
} }
async restore(path: string) { async restore(path: string) {
@ -29,7 +30,7 @@ class PostgresDbms extends BaseDbms {
"-cC", "-cC",
"--if-exists", "--if-exists",
"--exit-on-error", "--exit-on-error",
"-Ftar", // "-Ftar",
path, path,
]); ]);
} }
@ -45,7 +46,7 @@ class PostgresDbms extends BaseDbms {
private get dbUrl() { private get dbUrl() {
const { user, pass, host } = this.config; const { user, pass, host } = this.config;
const port = this.config.port || 5432; const port = this.config.port || 5432;
return `postgresql://${user}:${pass}@${host}:${port}`; return `postgresql://${user}:${urlencode(pass)}@${host}:${port}`;
} }
} }

View File

@ -2,8 +2,8 @@ import {
createBackupSchema, createBackupSchema,
getAllBackupQuery, getAllBackupQuery,
restoreBackupSchema, restoreBackupSchema,
} from "@/schemas/backup.schema"; } from "../schemas/backup.schema";
import BackupService from "@/services/backup.service"; import BackupService from "../services/backup.service";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono"; import { Hono } from "hono";

View File

@ -1,11 +1,13 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { handleError } from "@/middlewares/error-handler";
import server from "./server.router"; import server from "./server.router";
import backup from "./backup.router"; import backup from "./backup.router";
import { cors } from "hono/cors";
import { handleError } from "../middlewares/error-handler";
const routers = new Hono() const routers = new Hono()
// Middlewares // Middlewares
.onError(handleError) .onError(handleError)
.use(cors())
// App health check // App health check
.get("/health-check", (c) => c.text("OK")) .get("/health-check", (c) => c.text("OK"))
@ -14,4 +16,6 @@ const routers = new Hono()
.route("/servers", server) .route("/servers", server)
.route("/backups", backup); .route("/backups", backup);
export type AppRouter = typeof routers;
export default routers; export default routers;

View File

@ -1,9 +1,12 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { checkServerSchema, createServerSchema } from "@/schemas/server.schema";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import DatabaseUtil from "@/lib/database-util"; import ServerService from "../services/server.service";
import ServerService from "@/services/server.service"; import {
checkServerSchema,
createServerSchema,
} from "../schemas/server.schema";
import DatabaseUtil from "../lib/database-util";
const serverService = new ServerService(); const serverService = new ServerService();
const router = new Hono() const router = new Hono()
@ -27,7 +30,7 @@ const router = new Hono()
return c.json({ success: true, databases }); return c.json({ success: true, databases });
} catch (err) { } catch (err) {
throw new HTTPException(400, { throw new HTTPException(400, {
message: "Cannot connect to the database.", message: (err as any).message || "Cannot connect to the database.",
}); });
} }
}) })
@ -49,7 +52,7 @@ const router = new Hono()
.get("/:id", async (c) => { .get("/:id", async (c) => {
const { id } = c.req.param(); const { id } = c.req.param();
const server = await serverService.getOrFail(id); const server = await serverService.getById(id);
return c.json(server); return c.json(server);
}); });

View File

@ -1,13 +1,13 @@
import db from "@/db";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { backupModel, databaseModel } from "@/db/models";
import DatabaseUtil from "@/lib/database-util";
import ServerService from "@/services/server.service";
import { and, asc, eq, sql } from "drizzle-orm"; import { and, asc, eq, sql } from "drizzle-orm";
import { BACKUP_DIR } from "@/consts"; import ServerService from "../services/server.service";
import { mkdir } from "@/utility/utils"; import db from "../db";
import { hashFile } from "@/utility/hash"; import { backupModel, databaseModel } from "../db/models";
import DatabaseUtil from "../lib/database-util";
import { BACKUP_DIR } from "../consts";
import { mkdir } from "../utility/utils";
import { hashFile } from "../utility/hash";
let isRunning = false; let isRunning = false;
const serverService = new ServerService(); const serverService = new ServerService();
@ -19,16 +19,12 @@ const runBackup = async (task: PendingTasks[number]) => {
.set({ status: "running" }) .set({ status: "running" })
.where(eq(backupModel.id, task.id)); .where(eq(backupModel.id, task.id));
const server = serverService.parse(task.server as never); const server = serverService.parse(task.server);
const dbName = task.database.name; const dbName = task.database.name;
const dbUtil = new DatabaseUtil(server.connection); const dbUtil = new DatabaseUtil(server.connection);
if (task.type === "backup") { if (task.type === "backup") {
const key = path.join( const key = path.join(server.connection.host, dbName, `${Date.now()}`);
server.connection.host,
dbName,
`${Date.now()}.tar`
);
const outFile = path.join(BACKUP_DIR, key); const outFile = path.join(BACKUP_DIR, key);
mkdir(path.dirname(outFile)); mkdir(path.dirname(outFile));

View File

@ -14,7 +14,7 @@ const sshSchema = z
const postgresSchema = z.object({ const postgresSchema = z.object({
type: z.literal("postgres"), type: z.literal("postgres"),
host: z.string(), host: z.string(),
port: z.number().optional(), port: z.coerce.number().int().optional(),
user: z.string(), user: z.string(),
pass: z.string(), pass: z.string(),
}); });

View File

@ -1,11 +1,11 @@
import db from "@/db"; import db from "../db";
import { backupModel, serverModel } from "@/db/models"; import { backupModel, serverModel } from "../db/models";
import type { import type {
CreateBackupSchema, CreateBackupSchema,
GetAllBackupQuery, GetAllBackupQuery,
RestoreBackupSchema, RestoreBackupSchema,
} from "@/schemas/backup.schema"; } from "../schemas/backup.schema";
import { and, desc, eq, inArray } from "drizzle-orm"; import { and, count, desc, eq, inArray } from "drizzle-orm";
import DatabaseService from "./database.service"; import DatabaseService from "./database.service";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
@ -20,18 +20,28 @@ export default class BackupService {
const page = query.page || 1; const page = query.page || 1;
const limit = query.limit || 10; const limit = query.limit || 10;
const where = and(
serverId ? eq(backupModel.serverId, serverId) : undefined,
databaseId ? eq(backupModel.databaseId, databaseId) : undefined
);
const [totalRows] = await db
.select({ count: count() })
.from(backupModel)
.where(where)
.limit(1);
const backups = await db.query.backup.findMany({ const backups = await db.query.backup.findMany({
where: (i) => where,
and( with: {
serverId ? eq(i.serverId, serverId) : undefined, database: { columns: { name: true } },
databaseId ? eq(i.databaseId, databaseId) : undefined },
),
orderBy: desc(serverModel.createdAt), orderBy: desc(serverModel.createdAt),
limit, limit,
offset: (page - 1) * limit, offset: (page - 1) * limit,
}); });
return backups; return { count: totalRows.count, rows: backups };
} }
async getOrFail(id: string) { async getOrFail(id: string) {

View File

@ -1,5 +1,5 @@
import db from "@/db"; import db from "../db";
import { databaseModel } from "@/db/models"; import { databaseModel } from "../db/models";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";

View File

@ -1,6 +1,6 @@
import db from "@/db"; import db from "../db";
import { databaseModel, serverModel, type ServerModel } from "@/db/models"; import { databaseModel, serverModel, type ServerModel } from "../db/models";
import type { CreateServerSchema } from "@/schemas/server.schema"; import type { CreateServerSchema } from "../schemas/server.schema";
import { asc, desc, eq } from "drizzle-orm"; import { asc, desc, eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
@ -36,7 +36,15 @@ export default class ServerService {
databases: true, databases: true,
}, },
}); });
return server;
if (!server) {
return null;
}
const result = this.parse(server);
delete result.connection.pass;
return result;
} }
async create(data: CreateServerSchema) { async create(data: CreateServerSchema) {
@ -73,7 +81,7 @@ export default class ServerService {
}); });
} }
parse(data: ServerModel) { parse<T extends Pick<ServerModel, "connection" | "ssh">>(data: T) {
const result = { const result = {
...data, ...data,
connection: data.connection ? JSON.parse(data.connection) : null, connection: data.connection ? JSON.parse(data.connection) : null,

View File

@ -1,6 +1,6 @@
import DatabaseUtil from "@/lib/database-util"; import DatabaseUtil from "../lib/database-util";
import { DOCKER_HOST, BACKUP_DIR } from "@/consts"; import { DOCKER_HOST, BACKUP_DIR } from "../consts";
import { mkdir } from "@/utility/utils"; import { mkdir } from "../utility/utils";
import path from "path"; import path from "path";
const main = async () => { const main = async () => {

View File

@ -5,3 +5,7 @@ export const mkdir = (dir: string) => {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
}; };
export const urlencode = (str: string) => {
return encodeURIComponent(str);
};

View File

@ -22,11 +22,6 @@
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
} }
} }

4
frontend/app/globals.css Normal file
View File

@ -0,0 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

BIN
frontend/bun.lockb Executable file

Binary file not shown.

17
frontend/components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@ -10,18 +10,48 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"hono": "^4.3.4",
"lucide-react": "^0.378.0",
"next-themes": "^0.3.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-data-table-component": "^7.6.2",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-hook-form": "^7.51.4",
"react-icons": "^5.2.1",
"react-query": "^3.39.3",
"react-router-dom": "^6.23.1",
"sonner": "^1.4.41",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8",
"zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/react-helmet": "^6.1.11",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0", "@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.2.0" "vite": "^5.2.0"
} }

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -1,35 +0,0 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

22
frontend/src/app/App.tsx Normal file
View File

@ -0,0 +1,22 @@
import { QueryClientProvider } from "react-query";
import Router from "./Router";
import { queryClient } from "@/lib/queryClient";
import { lazy } from "react";
import "./global.css";
const Toaster = lazy(() => import("@/components/ui/sonner"));
const ConfirmDialog = lazy(
() => import("@/components/containers/confirm-dialog")
);
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<Router />
<Toaster richColors />
<ConfirmDialog />
</QueryClientProvider>
);
};
export default App;

View File

@ -0,0 +1,43 @@
import DashboardLayout from "@/components/layouts/dashboard";
import { Suspense, lazy, useMemo } from "react";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
const HomePage = lazy(() => import("@/pages/home/page"));
const ServerPage = lazy(() => import("@/pages/servers/page"));
const ViewServerPage = lazy(() => import("@/pages/servers/view/page"));
const router = createBrowserRouter([
{
Component: DashboardLayout,
children: [
{ index: true, Component: HomePage },
{
path: "servers",
children: [
{
index: true,
Component: ServerPage,
},
{
path: ":id",
Component: ViewServerPage,
},
],
},
],
},
]);
const Router = () => {
const routerData = useMemo(() => {
return router;
}, []);
return (
<Suspense>
<RouterProvider router={routerData} />
</Suspense>
);
};
export default Router;

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,49 @@
import { createDisclosureStore } from "@/lib/disclosure";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "../ui/dialog";
import Button from "../ui/button";
type ConfirmDialogData = {
onConfirm: () => void;
title?: string;
description?: string;
};
// eslint-disable-next-line react-refresh/only-export-components
export const confirmDlg = createDisclosureStore<ConfirmDialogData>({
onConfirm: () => {},
});
const ConfirmDialog = () => {
const { isOpen, data } = confirmDlg.useState();
return (
<Dialog open={isOpen} onOpenChange={confirmDlg.setOpen}>
<DialogContent>
<DialogTitle>{data?.title || "Are you sure?"}</DialogTitle>
<DialogDescription>{data?.description}</DialogDescription>
<DialogFooter>
<Button variant="outline" onClick={confirmDlg.onClose}>
Cancel
</Button>
<Button
onClick={() => {
data?.onConfirm();
confirmDlg.onClose();
}}
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ConfirmDialog;

View File

@ -0,0 +1,40 @@
import {
IoCogOutline,
IoLogOutOutline,
IoPieChartOutline,
IoServerOutline,
} from "react-icons/io5";
import Nav from "../ui/nav-item";
import Button from "../ui/button";
import { cn } from "@/lib/utils";
const Sidebar = () => {
const isOpen = false;
return (
<div
className={cn(
"w-[220px] flex flex-col h-full bg-white overflow-hidden fixed md:static left-0 top-0 bottom-0 transition-all md:!translate-x-0 z-10",
!isOpen ? "-translate-x-full" : ""
)}
>
<div className="px-2 py-8 text-center">
<p className="text-3xl font-bold font-mono text-primary-500">Serep</p>
</div>
<nav className="flex-1 overflow-y-auto px-2 space-y-2">
<Nav path="/" exact title="Overview" icon={IoPieChartOutline} />
<Nav path="/servers" title="Servers" icon={IoServerOutline} />
<Nav path="/settings" title="Settings" icon={IoCogOutline} />
</nav>
<div className="mx-2 py-2 md:py-4 border-t border-gray-200">
<Button className="w-full justify-start" variant="ghost">
<IoLogOutOutline className="text-xl" /> Logout
</Button>
</div>
</div>
);
};
export default Sidebar;

View File

@ -0,0 +1,18 @@
import { Outlet } from "react-router-dom";
import Sidebar from "../containers/sidebar";
import { Suspense } from "react";
const DashboardLayout = () => {
return (
<div className="h-screen overflow-hidden flex items-stretch">
<Sidebar />
<div className="flex-1 overflow-y-auto bg-primary-50/50 p-4 sm:p-8">
<Suspense>
<Outlet />
</Suspense>
</div>
</div>
);
};
export default DashboardLayout;

View File

@ -0,0 +1,78 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border border-slate-200 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-slate-950 dark:border-slate-800 dark:[&>svg]:text-slate-50",
{
variants: {
variant: {
default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
destructive:
"border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900",
},
},
defaultVariants: {
variant: "default",
},
}
);
type AlertProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof alertVariants>;
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
);
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
const ErrorAlert = React.forwardRef<
HTMLDivElement,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Omit<AlertProps, "variant" | "children"> & { error?: any }
>(({ error, ...props }, ref) => {
if (!error?.message) {
return null;
}
return (
<Alert ref={ref} variant="destructive" {...props}>
<AlertTitle>{error?.message}</AlertTitle>
</Alert>
);
});
ErrorAlert.displayName = "ErrorAlert";
export { Alert, AlertTitle, AlertDescription, ErrorAlert };

View File

@ -0,0 +1,22 @@
import Button from "./button";
import { IoChevronBack } from "react-icons/io5";
type BackButtonProps = {
to: string;
};
const BackButton = ({ to }: BackButtonProps) => {
return (
<Button
href={to}
icon={IoChevronBack}
variant="ghost"
size="lg"
className="-ml-4 px-4"
>
Back
</Button>
);
};
export default BackButton;

View File

@ -0,0 +1,84 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { IconType } from "react-icons";
import { Link } from "react-router-dom";
const buttonVariants = cva(
"inline-flex gap-1 items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-primary-950 dark:focus-visible:ring-primary-300",
{
variants: {
variant: {
default:
"bg-primary-500 text-white hover:bg-primary-500/90 dark:bg-primary-50 dark:text-gray-900 dark:hover:bg-primary-50/90",
destructive:
"bg-red-500 text-primary-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-primary-50 dark:hover:bg-red-900/90",
outline:
"border border-primary-200 bg-white hover:bg-primary-100 hover:text-primary-900 dark:border-primary-800 dark:bg-primary-950 dark:hover:bg-primary-800 dark:hover:text-primary-50",
secondary:
"bg-primary-100 text-primary-900 hover:bg-primary-100/80 dark:bg-primary-800 dark:text-primary-50 dark:hover:bg-primary-800/80",
ghost:
"hover:bg-primary-100 hover:text-primary-900 dark:hover:bg-primary-800 dark:hover:text-primary-50",
link: "text-primary-900 underline-offset-4 hover:underline dark:text-primary-50",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
icon?: IconType;
isLoading?: boolean;
href?: string;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
type = "button",
href,
icon: Icon,
children,
isLoading,
disabled,
...props
},
ref
) => {
const Comp = href ? Link : "button";
return (
<Comp
type={type}
to={href as never}
className={cn(buttonVariants({ variant, size, className }))}
ref={ref as never}
disabled={isLoading || disabled}
{...(props as unknown as any)}
>
{Icon && !isLoading ? <Icon /> : null}
{isLoading && <Loader2 className="animate-spin size-4" />}
{children}
</Comp>
);
}
);
Button.displayName = "Button";
export default Button;

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
import React from "react";
const Card = ({
className,
...props
}: React.ComponentPropsWithoutRef<"div">) => {
return (
<div className={cn("border rounded-lg bg-white", className)} {...props} />
);
};
export default Card;

View File

@ -0,0 +1,28 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-slate-200 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-slate-900 data-[state=checked]:text-slate-50 dark:border-slate-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300 dark:data-[state=checked]:bg-slate-50 dark:data-[state=checked]:text-slate-900",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -0,0 +1,15 @@
import React from "react";
import DataTableComponent, { TableColumn } from "react-data-table-component";
type DataTableProps = React.ComponentPropsWithoutRef<typeof DataTableComponent>;
const DataTable = ({ ...props }: DataTableProps) => {
return (
<div className="w-full overflow-x-auto">
<DataTableComponent {...props} />
</div>
);
};
export type { TableColumn };
export default DataTable;

View File

@ -0,0 +1,132 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/30 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg dark:border-slate-800 dark:bg-slate-950",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[state=open]:bg-slate-800 dark:data-[state=open]:text-slate-400">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogBody = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("max-h-[calc(100vh-200px)] overflow-y-auto", className)}
{...props}
/>
);
DialogBody.displayName = "DialogBody";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogBody,
DialogDescription,
};

View File

@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100 dark:focus:bg-slate-800 dark:data-[state=open]:bg-slate-800",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-800", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,93 @@
import FormContext from "@/context/form-context";
import { cn } from "@/lib/utils";
import React, { useId } from "react";
import {
Controller,
ControllerFieldState,
ControllerRenderProps,
FieldPath,
FieldValues,
UseFormReturn,
UseFormStateReturn,
} from "react-hook-form";
type FormProps<T extends FieldValues> =
React.ComponentPropsWithoutRef<"form"> & {
form: UseFormReturn<T>;
};
const Form = <T extends FieldValues>({ form, ...props }: FormProps<T>) => {
return (
<FormContext.Provider value={form as never}>
<form {...props} />
</FormContext.Provider>
);
};
type FormControlRenderFn<
T extends FieldValues,
FieldName extends FieldPath<T>
> = ({
field,
fieldState,
formState,
}: {
field: ControllerRenderProps<T, FieldName>;
fieldState: ControllerFieldState;
formState: UseFormStateReturn<T>;
id: string;
}) => React.ReactElement;
export type FormControlProps<
TValues extends FieldValues,
TName extends FieldPath<TValues> = FieldPath<TValues>
> = {
form: UseFormReturn<TValues>;
name: TName;
render: FormControlRenderFn<TValues, TName>;
className?: string;
label?: string;
};
export const FormControl = <T extends FieldValues>({
form,
name,
render,
className,
label,
}: FormControlProps<T>) => {
const fieldId = useId();
return (
<Controller
control={form.control}
name={name}
render={(props) => {
const { fieldState } = props;
return (
<div className={cn("space-y-1", className)}>
{label != null && (
<label htmlFor={fieldId} className="text-sm">
{label}
</label>
)}
{render({ ...props, id: fieldId })}
{fieldState.error != null && (
<p
className="text-red-500 text-xs truncate"
title={fieldState.error.message}
>
{fieldState.error.message}
</p>
)}
</div>
);
}}
/>
);
};
export default Form;

View File

@ -0,0 +1,51 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form";
import { FormControl } from "./form";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
type InputFieldProps<TValues extends FieldValues> = Omit<InputProps, "form"> & {
form: UseFormReturn<TValues>;
name: FieldPath<TValues>;
label?: string;
};
const InputField = <T extends FieldValues>({
form,
name,
label,
className,
...props
}: InputFieldProps<T>) => (
<FormControl
form={form}
name={name}
label={label}
className={className}
render={({ field, id }) => <Input id={id} {...field} {...props} />}
/>
);
InputField.displayName = "InputField";
export { InputField };
export default Input;

View File

@ -0,0 +1,24 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
import type { IconType } from "react-icons";
import { Link, useLocation } from "react-router-dom";
type NavProps = {
path: string;
title: string;
exact?: boolean;
icon: IconType;
};
const Nav = ({ path, title, exact, icon: Icon }: NavProps) => {
const { pathname } = useLocation();
const isActive = exact ? pathname === path : pathname.startsWith(path);
return (
<Link
to={path}
className={cn(
"w-full flex items-center text-sm rounded-lg px-4 h-10 md:h-12 transition-colors",
isActive
? "bg-primary-500 text-white hover:bg-primary-500/90"
: "text-gray-500 hover:bg-primary-100/80 hover:text-primary-600"
)}
>
{Icon ? (
<div className="w-8 overflow-hidden">
<Icon className="text-xl" />
</div>
) : null}
{title}
</Link>
);
};
export default Nav;

View File

@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
import Helmet from "react-helmet";
type Props = {
children?: string;
setTitle?: boolean;
className?: string;
};
const PageTitle = ({ children, setTitle = true, className }: Props) => {
return (
<>
{setTitle && (
<Helmet>
<title>{children}</title>
</Helmet>
)}
<h2 className={cn("text-3xl font-medium text-gray-800", className)}>
{children}
</h2>
</>
);
};
export default PageTitle;

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-slate-200 bg-white p-4 text-slate-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
import React from "react";
const SectionTitle = ({
className,
...props
}: React.ComponentPropsWithoutRef<"p">) => {
return (
<h3 className={cn("text-xl font-medium mt-6", className)} {...props} />
);
};
export default SectionTitle;

View File

@ -0,0 +1,222 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form";
import { FormControl } from "./form";
const SelectRoot = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 items-center justify-between rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-800", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export type SelectOption = {
label: string;
value: string;
};
type SelectProps = {
id?: string;
value?: string;
placeholder?: string;
onChange?: (value: string) => void;
options?: SelectOption[] | null;
className?: string;
};
const Select = React.forwardRef<
React.ElementRef<typeof SelectTrigger>,
SelectProps
>(({ id, className, placeholder, value, options, onChange }, ref) => (
<SelectRoot value={value} onValueChange={onChange}>
<SelectTrigger id={id} ref={ref} className={className}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{/* {placeholder != null && <SelectItem value="">{placeholder}</SelectItem>} */}
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</SelectRoot>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
type SelectFieldProps<TValues extends FieldValues> = SelectProps & {
form: UseFormReturn<TValues>;
name: FieldPath<TValues>;
label?: string;
};
const SelectField = <T extends FieldValues>({
form,
name,
label,
className,
...props
}: SelectFieldProps<T>) => (
<FormControl
form={form}
name={name}
label={label}
className={className}
render={({ field, id }) => <Select id={id} {...field} {...props} />}
/>
);
SelectField.displayName = "SelectField";
export {
SelectRoot,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
SelectField,
};
export default Select;

View File

@ -0,0 +1,30 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-white group-[.toaster]:text-slate-950 group-[.toaster]:border-slate-200 group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-slate-950 dark:group-[.toaster]:text-slate-50 dark:group-[.toaster]:border-slate-800",
description:
"group-[.toast]:text-slate-500 dark:group-[.toast]:text-slate-400",
actionButton:
"group-[.toast]:bg-slate-900 group-[.toast]:text-slate-50 dark:group-[.toast]:bg-slate-50 dark:group-[.toast]:text-slate-900",
cancelButton:
"group-[.toast]:bg-slate-100 group-[.toast]:text-slate-500 dark:group-[.toast]:bg-slate-800 dark:group-[.toast]:text-slate-400",
},
}}
{...props}
/>
);
};
export default Toaster;

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,14 @@
import { createContext, useContext } from "react";
import { FieldValues, UseFormReturn } from "react-hook-form";
const FormContext = createContext<UseFormReturn>(null!);
export const useFormContext = <T extends FieldValues>() => {
const context = useContext(FormContext);
if (!context) {
throw new Error("useFormContext must be used within a FormProvider");
}
return context as UseFormReturn<T>;
};
export default FormContext;

View File

@ -0,0 +1,11 @@
import { useState } from "react";
export const usePartials = <T>(initialState: T) => {
const [state, replaceState] = useState<T>(initialState);
const setState = (newState: Partial<T>) => {
replaceState((curState) => ({ ...curState, ...newState }));
};
return [state, setState, replaceState] as const;
};

View File

@ -0,0 +1,32 @@
import { useSearchParams } from "react-router-dom";
import { usePartials } from "./usePartials";
const getDefaultState = <T extends object>(
initialState: T,
searchParams: URLSearchParams
) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state: any = {};
Object.keys(initialState).forEach((key) => {
state[key] = searchParams.get(key) || initialState[key as never];
});
return state as T;
};
export const useQueryParams = <T extends object>(initialState: T) => {
const [searchParams, setSearchParams] = useSearchParams();
const [state, setState] = usePartials(
getDefaultState(initialState, searchParams)
);
const set = (newState: Partial<T>) => {
setState(newState);
const newSearchParams = new URLSearchParams(searchParams);
Object.keys(newState).forEach((key) => {
newSearchParams.set(key, newState[key as never] as string);
});
setSearchParams(newSearchParams);
};
return [state, set] as const;
};

View File

@ -1,68 +0,0 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

15
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,15 @@
import { ClientResponse, hc } from "hono/client";
import type { AppRouter } from "../../../backend/src/routers";
const api = hc<AppRouter>("http://localhost:3000/");
export const parseJson = async <T>(res: ClientResponse<T>) => {
const json = await res.json();
if (!res.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
throw new Error((json as any)?.message || "An error occured.");
}
return json as T;
};
export default api;

View File

@ -0,0 +1,38 @@
import { createStore, useStore } from "zustand";
type DisclosureStoreType<T> = {
isOpen: boolean;
data?: T;
};
export const createDisclosureStore = <T>(initialData?: T) => {
const store = createStore<DisclosureStoreType<T>>(() => ({
isOpen: false,
data: initialData,
}));
const onOpen = (data?: T) => {
store.setState({
isOpen: true,
data,
});
};
const onClose = () => {
store.setState({ isOpen: false });
};
const setOpen = (isOpen: boolean, data?: T) => {
store.setState({ isOpen, data });
};
const useState = () => {
return useStore(store);
};
return { store, useState, onOpen, onClose, setOpen };
};
export type DisclosureType<T = unknown> = ReturnType<
typeof createDisclosureStore<T>
>;

View File

@ -0,0 +1,3 @@
import { QueryClient } from "react-query";
export const queryClient = new QueryClient();

30
frontend/src/lib/utils.ts Normal file
View File

@ -0,0 +1,30 @@
import { type ClassValue, clsx } from "clsx";
import { toast } from "sonner";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const getFilename = (path: string) => {
return path.split("/").pop();
};
export const formatBytes = (bytes: number, decimals = 0) => {
if (bytes == 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (
parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]
);
};
export const copyToClipboard = async (data: string) => {
try {
await navigator.clipboard.writeText(data);
toast.success("Copied to clipboard!");
} catch (err) {
toast.error("Failed to copy!");
}
};

View File

@ -1,10 +1,9 @@
import React from 'react' import React from "react";
import ReactDOM from 'react-dom/client' import ReactDOM from "react-dom/client";
import App from './App.tsx' import App from "./app/App.tsx";
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>
) );

View File

@ -0,0 +1,44 @@
import { ErrorAlert } from "@/components/ui/alert";
import Button from "@/components/ui/button";
import api, { parseJson } from "@/lib/api";
import { useQuery } from "react-query";
import ServerList from "../../servers/components/server-list";
import AddServerDialog from "../../servers/components/add-server-dialog";
import { initialServerData } from "@/pages/servers/schema";
import PageTitle from "@/components/ui/page-title";
import { addServerDlg } from "@/pages/servers/stores";
const ServerSection = () => {
const { data, isLoading, error } = useQuery({
queryKey: ["servers"],
queryFn: () => api.servers.$get().then(parseJson),
});
return (
<section>
<PageTitle setTitle={false}>Servers</PageTitle>
{isLoading ? (
<div>Loading...</div>
) : error ? (
<ErrorAlert error={error} />
) : !data?.length ? (
<div className="mt-4 min-h-60 md:min-h-80 flex flex-col items-center justify-center">
<p>No server added.</p>
<Button
className="mt-2"
onClick={() => addServerDlg.onOpen({ ...initialServerData })}
>
Add Server
</Button>
</div>
) : (
<ServerList items={data} />
)}
<AddServerDialog />
</section>
);
};
export default ServerSection;

View File

@ -0,0 +1,11 @@
import ServerSection from "./components/server-section";
const HomePage = () => {
return (
<div>
<ServerSection />
</div>
);
};
export default HomePage;

View File

@ -0,0 +1,214 @@
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import Button from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import {
CreateServerSchema,
createServerSchema,
} from "@backend/schemas/server.schema";
import Form from "@/components/ui/form";
import { InputField } from "@/components/ui/input";
import { SelectField } from "@/components/ui/select";
import { connectionTypes } from "../schema";
import { IoInformationCircleOutline } from "react-icons/io5";
import { useFormContext } from "@/context/form-context";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { useMutation } from "react-query";
import api, { parseJson } from "@/lib/api";
import { ErrorAlert } from "@/components/ui/alert";
import { queryClient } from "@/lib/queryClient";
import { useEffect } from "react";
import { addServerDlg } from "../stores";
const AddServerDialog = () => {
const { isOpen, data } = addServerDlg.useState();
const form = useForm({
resolver: zodResolver(createServerSchema),
defaultValues: data,
});
const type = useWatch({ control: form.control, name: "connection.type" });
const databases = useWatch({ control: form.control, name: "databases" });
useEffect(() => {
form.reset(data);
}, [form, data]);
const checkConnection = useMutation({
mutationFn: async () => {
const { connection, ssh } = form.getValues();
const res = await api.servers.check.$post({
json: { connection, ssh },
});
const { databases } = await parseJson(res);
return {
success: true,
databases: databases.map((i) => i.name),
};
},
});
const createServer = useMutation({
mutationFn: async (data: CreateServerSchema) => {
const res = await api.servers.$post({ json: data });
return parseJson(res);
},
onSuccess: () => {
addServerDlg.onClose();
queryClient.invalidateQueries("servers");
},
});
const onSubmit = form.handleSubmit((values) => {
createServer.mutate(values);
});
return (
<Dialog open={isOpen} onOpenChange={addServerDlg.setOpen}>
<DialogContent>
<Form form={form} onSubmit={onSubmit}>
<DialogTitle>Add Server</DialogTitle>
<DialogBody>
<div className="grid grid-cols-2 gap-3 mt-3">
<InputField form={form} name="name" label="Server Name" />
<SelectField
form={form}
name="connection.type"
options={connectionTypes}
label="Type"
/>
{type === "postgres" && (
<>
<InputField
form={form}
name="connection.host"
label="Hostname"
/>
<InputField
form={form}
type="number"
name="connection.port"
label="Port"
/>
<InputField
form={form}
name="connection.user"
label="Username"
/>
<InputField
form={form}
type="password"
name="connection.pass"
label="Password"
/>
</>
)}
</div>
<ErrorAlert error={checkConnection.error} className="mt-4" />
<Button
className="mt-4"
variant="outline"
size="sm"
icon={IoInformationCircleOutline}
isLoading={checkConnection.isLoading}
onClick={() => checkConnection.mutate()}
>
Check Connection
</Button>
<SelectDatabase databases={checkConnection.data?.databases} />
</DialogBody>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={addServerDlg.onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={!databases.length || !checkConnection.data?.success}
isLoading={createServer.isLoading}
>
Submit
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};
export const SelectDatabase = ({ databases }: { databases?: string[] }) => {
const form = useFormContext<CreateServerSchema>();
if (databases == null) {
return null;
}
return (
<div className="mt-4 border-t pt-4">
<div className="flex items-center gap-2">
<p className="font-medium text-sm flex-1">Database:</p>
<Checkbox
id="select-all-db"
onCheckedChange={(checked) => {
if (checked) {
form.setValue("databases", databases);
} else {
form.setValue("databases", []);
}
}}
/>
<Label htmlFor="select-all-db" className="cursor-pointer">
Select All
</Label>
</div>
{!databases.length && <p className="text-gray-500">No database exist.</p>}
<Controller
control={form.control}
name="databases"
render={({ field: { value, onChange } }) => (
<div className="grid sm:grid-cols-2 gap-2 mt-2 max-h-[260px] overflow-y-auto">
{databases.map((name) => (
<div
key={name}
className="flex gap-2 items-center border rounded px-3 hover:bg-gray-50 transition-colors"
>
<Checkbox
id={`db-${name}`}
checked={value.includes(name)}
onCheckedChange={(checked) => {
onChange(
checked
? [...value, name]
: value.filter((i) => i !== name)
);
}}
/>
<Label
htmlFor={`db-${name}`}
className="flex-1 py-3 block cursor-pointer"
>
{name}
</Label>
</div>
))}
</div>
)}
/>
</div>
);
};
export default AddServerDialog;

View File

@ -0,0 +1,46 @@
import Button from "@/components/ui/button";
import api, { parseJson } from "@/lib/api";
import { useState } from "react";
import { IoCloudDownloadOutline } from "react-icons/io5";
import { useMutation } from "react-query";
import { BackupType } from "../view/table";
import { toast } from "sonner";
type BackupButtonProps = {
databaseId: string;
onCreate?: (data: BackupType) => void;
};
const BackupButton = ({ databaseId, onCreate }: BackupButtonProps) => {
const [isPressed, setPressed] = useState(false);
const createBackup = useMutation({
mutationFn: async () => {
return api.backups.$post({ json: { databaseId } }).then(parseJson);
},
onSuccess: (data) => {
onCreate?.(data as never);
toast.success("Backup queued!");
},
});
const onPress = () => {
setPressed(true);
setTimeout(() => setPressed(false), 2000);
createBackup.mutate();
};
return (
<Button
icon={IoCloudDownloadOutline}
variant="outline"
size="sm"
onClick={onPress}
isLoading={isPressed || createBackup.isLoading}
>
Backup
</Button>
);
};
export default BackupButton;

View File

@ -0,0 +1,67 @@
import { IconType } from "react-icons";
import {
IoCheckmarkCircle,
IoCloseCircleOutline,
IoRemoveCircle,
IoSyncCircle,
} from "react-icons/io5";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
type Props = {
status: "pending" | "running" | "success" | "failed";
output?: string;
};
const labels: Record<Props["status"], string> = {
pending: "Pending",
running: "Running",
success: "Success",
failed: "Failed",
};
const colors: Record<Props["status"], string> = {
pending: "bg-gray-500",
running: "bg-blue-500",
success: "bg-green-600",
failed: "bg-red-500",
};
const icons: Record<Props["status"], IconType> = {
pending: IoRemoveCircle,
running: IoSyncCircle,
success: IoCheckmarkCircle,
failed: IoCloseCircleOutline,
};
const BackupStatus = ({ status, output }: Props) => {
const Icon = icons[status] || "div";
return (
<Popover>
<PopoverTrigger
disabled={!output}
title={output}
className={cn(
"flex items-center gap-2 px-2 py-1 rounded-lg text-white shrink-0",
colors[status]
)}
>
<Icon
className={cn("text-lg", status === "running" ? "animate-spin" : "")}
/>
<p className="text-sm">{labels[status]}</p>
</PopoverTrigger>
<PopoverContent className="max-w-lg w-screen">
<p className="font-mono text-sm">{output}</p>
</PopoverContent>
</Popover>
);
};
export default BackupStatus;

View File

@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
type ConnectionStatusProps = {
status?: boolean | null;
error?: unknown;
className?: string;
};
const ConnectionStatus = ({
status,
error,
className,
}: ConnectionStatusProps) => {
const statusColor = status
? "bg-green-600 animate-pulse"
: error
? "bg-red-500 animate-ping"
: "bg-gray-500";
const statusLabel = getConnectionLabel(status, error);
return (
<div
className={cn("size-3 rounded-full", statusColor, className)}
title={statusLabel}
/>
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const getConnectionLabel = (
status?: boolean | null,
error?: unknown
) => {
return status ? "Connected" : error ? "Error!" : "Unknown";
};
export default ConnectionStatus;

View File

@ -0,0 +1,68 @@
import api, { parseJson } from "@/lib/api";
import { InferResponseType } from "hono/client";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { useQuery } from "react-query";
import { IoCheckmarkCircle, IoServerOutline } from "react-icons/io5";
import { Link } from "react-router-dom";
import ConnectionStatus from "./connection-status";
dayjs.extend(relativeTime);
type ServerItemType = InferResponseType<typeof api.servers.$get>[number];
type ServerListProps = {
items: ServerItemType[];
};
const ServerList = ({ items }: ServerListProps) => {
return (
<div className="grid sm:grid-cols-2 gap-4 mt-4">
{items.map((item) => (
<ServerItem key={item.id} {...item} />
))}
</div>
);
};
const ServerItem = ({ id, name, databases }: ServerItemType) => {
const { data: check, error } = useQuery({
queryKey: ["server", id],
queryFn: async () => {
return api.servers.check[":id"].$get({ param: { id } }).then(parseJson);
},
refetchInterval: 30000,
});
return (
<Link
to={`/servers/${id}`}
className="border rounded-lg p-4 md:p-6 bg-white transition-colors hover:bg-gray-50"
>
<div className="flex items-start gap-2 md:gap-4">
<div className="relative">
<IoServerOutline className="text-gray-800 text-xl mt-1" />
<ConnectionStatus
status={check?.success}
error={error}
className="absolute -top-0.5 -right-0.5"
/>
</div>
<div className="flex-1">
<p className="font-medium text-xl">{name}</p>
<div className="mt-1 flex items-center">
<p>
{`${databases.length} database` +
(databases.length > 1 ? "s" : "")}
</p>
<p className="ml-8">0 errors</p>
<IoCheckmarkCircle className="text-green-600 ml-1 inline" />
</div>
</div>
</div>
</Link>
);
};
export default ServerList;

View File

@ -0,0 +1,50 @@
import { ErrorAlert } from "@/components/ui/alert";
import Button from "@/components/ui/button";
import api, { parseJson } from "@/lib/api";
import { useQuery } from "react-query";
import ServerList from "./components/server-list";
import AddServerDialog from "./components/add-server-dialog";
import { addServerDlg } from "./stores";
import { initialServerData } from "./schema";
import PageTitle from "@/components/ui/page-title";
const ServerPage = () => {
const { data, isLoading, error } = useQuery({
queryKey: ["servers"],
queryFn: () => api.servers.$get().then(parseJson),
});
return (
<main>
<div className="flex items-center gap-2 mt-2 md:mt-4">
<PageTitle className="flex-1">Servers</PageTitle>
<Button onClick={() => addServerDlg.onOpen({ ...initialServerData })}>
Add Server
</Button>
</div>
{isLoading ? (
<div>Loading...</div>
) : error ? (
<ErrorAlert error={error} />
) : !data?.length ? (
<div className="mt-4 min-h-60 md:min-h-80 flex flex-col items-center justify-center">
<p>No server added.</p>
<Button
className="mt-2"
onClick={() => addServerDlg.onOpen({ ...initialServerData })}
>
Add Server
</Button>
</div>
) : (
<ServerList items={data} />
)}
<AddServerDialog />
</main>
);
};
export default ServerPage;

View File

@ -0,0 +1,21 @@
import { SelectOption } from "@/components/ui/select";
import { CreateServerSchema } from "@backend/schemas/server.schema";
export const connectionTypes: SelectOption[] = [
{
label: "PostgreSQL",
value: "postgres",
},
];
export const initialServerData: CreateServerSchema = {
name: "",
connection: {
type: "postgres",
host: "localhost",
port: 5432,
user: "postgres",
pass: "",
},
databases: [],
};

View File

@ -0,0 +1,4 @@
import { createDisclosureStore } from "@/lib/disclosure";
import { initialServerData } from "../servers/schema";
export const addServerDlg = createDisclosureStore(initialServerData);

View File

@ -0,0 +1,85 @@
import Card from "@/components/ui/card";
import DataTable from "@/components/ui/data-table";
import SectionTitle from "@/components/ui/section-title";
import { DatabaseType, backupsColumns } from "../table";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import api, { parseJson } from "@/lib/api";
import { useQueryParams } from "@/hooks/useQueryParams";
import Button from "@/components/ui/button";
import { IoRefresh } from "react-icons/io5";
import Select from "@/components/ui/select";
type BackupSectionProps = {
databases: DatabaseType[];
};
type QueryParams = {
page: number;
limit: number;
databaseId?: string;
};
const BackupSection = ({ databases }: BackupSectionProps) => {
const id = useParams().id!;
const [query, setQuery] = useQueryParams<QueryParams>({
page: 1,
limit: 10,
});
const backups = useQuery({
queryKey: ["backups/by-server", id, query],
queryFn: async () => {
return api.backups
.$get({
query: {
serverId: id,
limit: String(query.limit),
page: String(query.page),
databaseId: query.databaseId,
},
})
.then(parseJson);
},
refetchInterval: 3000,
});
return (
<div className="flex flex-col">
<div className="flex items-end gap-2">
<SectionTitle className="mt-8">Backups</SectionTitle>
<Button
icon={IoRefresh}
variant="ghost"
size="icon"
className="text-2xl -mb-1"
onClick={() => backups.refetch()}
isLoading={backups.isRefetching}
/>
<div className="flex-1" />
<Select
options={databases.map((i) => ({ label: i.name, value: i.id }))}
onChange={(i) => setQuery({ databaseId: i })}
value={query.databaseId}
placeholder="Select Database"
className="min-w-[120px]"
/>
</div>
<Card className="mt-4 px-2 flex-1">
<DataTable
columns={backupsColumns as never}
data={backups?.data?.rows || []}
pagination
progressPending={backups.isLoading}
paginationServer
paginationPerPage={query.limit}
paginationTotalRows={backups?.data?.count}
onChangeRowsPerPage={(limit) => setQuery({ limit })}
onChangePage={(page) => setQuery({ page })}
/>
</Card>
</div>
);
};
export default BackupSection;

View File

@ -0,0 +1,25 @@
import Card from "@/components/ui/card";
import DataTable from "@/components/ui/data-table";
import SectionTitle from "@/components/ui/section-title";
import { DatabaseType, databaseColumns } from "../table";
type DatabaseSectionProps = {
databases: DatabaseType[];
};
const DatabaseSection = ({ databases }: DatabaseSectionProps) => {
return (
<div className="flex flex-col">
<SectionTitle className="mt-8">Databases</SectionTitle>
<Card className="mt-4 px-2 flex-1 pb-2">
<DataTable
columns={databaseColumns as never}
data={databases}
pagination
/>
</Card>
</div>
);
};
export default DatabaseSection;

View File

@ -0,0 +1,65 @@
import BackButton from "@/components/ui/back-button";
import PageTitle from "@/components/ui/page-title";
import api, { parseJson } from "@/lib/api";
import { IoServer } from "react-icons/io5";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import ConnectionStatus, {
getConnectionLabel,
} from "../components/connection-status";
import Card from "@/components/ui/card";
import BackupSection from "./components/backups-section";
import DatabaseSection from "./components/databases-section";
const ViewServerPage = () => {
const id = useParams().id!;
const { data, isLoading, error } = useQuery({
queryKey: ["servers"],
queryFn: () => api.servers[":id"].$get({ param: { id } }).then(parseJson),
});
const check = useQuery({
queryKey: ["server", id],
queryFn: async () => {
return api.servers.check[":id"].$get({ param: { id } }).then(parseJson);
},
refetchInterval: 30000,
});
if (isLoading || error) {
return null;
}
return (
<main>
<BackButton to="/servers" />
<PageTitle>Server Information</PageTitle>
<Card className="mt-4 p-4 md:p-8">
<IoServer className="text-4xl text-gray-600" />
<div className="mt-2 flex items-center">
<p className="text-xl text-gray-800">{data?.name}</p>
{data?.connection?.host ? (
<span className="inline-block rounded-lg px-2 py-0.5 bg-gray-100 ml-3 text-sm">
{data?.connection?.host}
</span>
) : null}
</div>
<div className="text-sm mt-1 flex items-center gap-2">
<ConnectionStatus status={check.data?.success} error={check.error} />
<p>{getConnectionLabel(check.data?.success, check.error)}</p>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 items-stretch gap-4 w-full overflow-hidden">
<DatabaseSection databases={data?.databases || []} />
<BackupSection databases={data?.databases || []} />
</div>
</main>
);
};
export default ViewServerPage;

View File

@ -0,0 +1,176 @@
import { TableColumn } from "@/components/ui/data-table";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import BackupButton from "../components/backup-button";
import { copyToClipboard, formatBytes } from "@/lib/utils";
import BackupStatus from "../components/backup-status";
import { queryClient } from "@/lib/queryClient";
import Button from "@/components/ui/button";
import { IoCloudDownload, IoCopy, IoEllipsisVertical } from "react-icons/io5";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { confirmDlg } from "@/components/containers/confirm-dialog";
import api, { parseJson } from "@/lib/api";
import { toast } from "sonner";
dayjs.extend(relativeTime);
export type DatabaseType = {
id: string;
name: string;
isActive: boolean;
createdAt: string;
serverId: string;
lastBackupAt: string | null;
};
export const databaseColumns: TableColumn<DatabaseType>[] = [
{
name: "Name",
selector: (i) => i.name,
},
{
name: "Last Backup",
selector: (i) =>
i.lastBackupAt ? dayjs(i.lastBackupAt).format("YYYY-MM-DD HH:mm") : "",
cell: (i) => (i.lastBackupAt ? dayjs(i.lastBackupAt).fromNow() : "never"),
},
{
selector: (i) => i.id,
width: "160px",
cell: (i) => (
<div className="flex items-center justify-end gap-1 w-full">
<BackupButton
databaseId={i.id}
onCreate={() => {
queryClient.invalidateQueries(["backups/by-server", i.serverId]);
}}
/>
</div>
),
},
];
export type BackupType = {
id: string;
serverId: string;
databaseId: string;
type: string;
status: string;
output: string;
key: string | null;
hash: string | null;
size: number | null;
createdAt: Date;
database?: DatabaseType;
};
const onRestoreBackup = async (row: BackupType) => {
try {
const res = await api.backups.restore.$post({
json: { backupId: row.id },
});
await parseJson(res);
toast.success("Queueing database restore!");
queryClient.invalidateQueries(["backups/by-server", row.serverId]);
} catch (err) {
toast.error("Failed to restore backup! " + (err as Error).message);
}
};
export const backupsColumns: TableColumn<BackupType>[] = [
{
name: "Type",
selector: (i) => i.type,
cell: (i) => (i.type === "backup" ? "Backup" : "Restore"),
},
{
name: "Database Name",
selector: (i) => i.database?.name || "-",
},
{
name: "Status",
selector: (i) => i.status,
cell: (i) => <BackupStatus status={i.status as never} output={i.output} />,
},
{
name: "Data",
selector: (i) => i.key || "-",
center: true,
cell: (i) =>
i.key ? (
<Button
variant="outline"
icon={IoCloudDownload}
className="min-w-[80px] px-2"
size="sm"
>
{formatBytes(i.size || 0)}
</Button>
) : (
"-"
),
},
{
name: "SHA256",
selector: (i) => i.hash || "",
cell: (i) =>
i.hash ? (
<Button
icon={IoCopy}
size="sm"
className="truncate shrink-0 px-2 -mx-2"
variant="ghost"
title={i.hash}
onClick={() => copyToClipboard(i.hash!)}
>
<p className="flex-1">{i.hash.substring(0, 8) + ".."}</p>
</Button>
) : (
"-"
),
},
{
name: "Timestamp",
selector: (i) => dayjs(i.createdAt).format("YYYY-MM-DD HH:mm"),
cell: (i) => {
const diff = dayjs().diff(dayjs(i.createdAt), "days");
return diff < 3
? dayjs(i.createdAt).fromNow()
: dayjs(i.createdAt).format("DD/MM/YY HH:mm");
},
},
{
selector: (i) => i.id,
width: "40px",
style: { padding: 0 },
cell: (row) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<IoEllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
confirmDlg.onOpen({
title: "Restore Backup",
description: "Are you sure want to restore this backup?",
onConfirm: () => onRestoreBackup(row),
});
}}
>
Restore
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];

View File

@ -0,0 +1,50 @@
import { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx}"],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
primary: {
"50": "#eef2ff",
"100": "#e0e7ff",
"200": "#c7d2fe",
"300": "#a5b4fc",
"400": "#818cf8",
"500": "#6366f1",
"600": "#4f46e5",
"700": "#4338ca",
"800": "#3730a3",
"900": "#312e81",
"950": "#1e1b4b",
},
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;

View File

@ -18,7 +18,12 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@backend/*": ["../backend/src/*"],
}
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]

View File

@ -1,7 +1,14 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react-swc' import react from "@vitejs/plugin-react-swc";
import path from "path";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
}) resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@backend": path.resolve(__dirname, "../backend/src"),
},
},
});