mirror of
https://github.com/khairul169/db-backup-tool.git
synced 2025-04-28 16:49:34 +07:00
feat: add frontend
This commit is contained in:
parent
093b0056fb
commit
3d7508816f
@ -1,4 +1,4 @@
|
||||
import { STORAGE_DIR } from "@/consts";
|
||||
import { STORAGE_DIR } from "../consts";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
|
@ -1,8 +1,8 @@
|
||||
import path from "path";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { DATABASE_PATH } from "@/consts";
|
||||
import { mkdir } from "@/utility/utils";
|
||||
import { DATABASE_PATH } from "../consts";
|
||||
import { mkdir } from "../utility/utils";
|
||||
import schema from "./schema";
|
||||
|
||||
// Create database directory if not exists
|
||||
|
@ -1,6 +1,6 @@
|
||||
import fs from "fs";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { DATABASE_PATH } from "@/consts";
|
||||
import { DATABASE_PATH } from "../consts";
|
||||
import db, { sqlite } from ".";
|
||||
import { seed } from "./seed";
|
||||
|
||||
|
@ -3,6 +3,7 @@ import type {
|
||||
PostgresConfig,
|
||||
} from "../../types/database.types";
|
||||
import { exec } from "../../utility/process";
|
||||
import { urlencode } from "../../utility/utils";
|
||||
import BaseDbms from "./base";
|
||||
|
||||
class PostgresDbms extends BaseDbms {
|
||||
@ -18,7 +19,7 @@ class PostgresDbms extends BaseDbms {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -29,7 +30,7 @@ class PostgresDbms extends BaseDbms {
|
||||
"-cC",
|
||||
"--if-exists",
|
||||
"--exit-on-error",
|
||||
"-Ftar",
|
||||
// "-Ftar",
|
||||
path,
|
||||
]);
|
||||
}
|
||||
@ -45,7 +46,7 @@ class PostgresDbms extends BaseDbms {
|
||||
private get dbUrl() {
|
||||
const { user, pass, host } = this.config;
|
||||
const port = this.config.port || 5432;
|
||||
return `postgresql://${user}:${pass}@${host}:${port}`;
|
||||
return `postgresql://${user}:${urlencode(pass)}@${host}:${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,8 +2,8 @@ import {
|
||||
createBackupSchema,
|
||||
getAllBackupQuery,
|
||||
restoreBackupSchema,
|
||||
} from "@/schemas/backup.schema";
|
||||
import BackupService from "@/services/backup.service";
|
||||
} from "../schemas/backup.schema";
|
||||
import BackupService from "../services/backup.service";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { handleError } from "@/middlewares/error-handler";
|
||||
import server from "./server.router";
|
||||
import backup from "./backup.router";
|
||||
import { cors } from "hono/cors";
|
||||
import { handleError } from "../middlewares/error-handler";
|
||||
|
||||
const routers = new Hono()
|
||||
// Middlewares
|
||||
.onError(handleError)
|
||||
.use(cors())
|
||||
|
||||
// App health check
|
||||
.get("/health-check", (c) => c.text("OK"))
|
||||
@ -14,4 +16,6 @@ const routers = new Hono()
|
||||
.route("/servers", server)
|
||||
.route("/backups", backup);
|
||||
|
||||
export type AppRouter = typeof routers;
|
||||
|
||||
export default routers;
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { checkServerSchema, createServerSchema } from "@/schemas/server.schema";
|
||||
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 router = new Hono()
|
||||
@ -27,7 +30,7 @@ const router = new Hono()
|
||||
return c.json({ success: true, databases });
|
||||
} catch (err) {
|
||||
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) => {
|
||||
const { id } = c.req.param();
|
||||
const server = await serverService.getOrFail(id);
|
||||
const server = await serverService.getById(id);
|
||||
return c.json(server);
|
||||
});
|
||||
|
||||
|
@ -1,13 +1,13 @@
|
||||
import db from "@/db";
|
||||
import fs from "fs";
|
||||
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 { BACKUP_DIR } from "@/consts";
|
||||
import { mkdir } from "@/utility/utils";
|
||||
import { hashFile } from "@/utility/hash";
|
||||
import ServerService from "../services/server.service";
|
||||
import db from "../db";
|
||||
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;
|
||||
const serverService = new ServerService();
|
||||
@ -19,16 +19,12 @@ const runBackup = async (task: PendingTasks[number]) => {
|
||||
.set({ status: "running" })
|
||||
.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 dbUtil = new DatabaseUtil(server.connection);
|
||||
|
||||
if (task.type === "backup") {
|
||||
const key = path.join(
|
||||
server.connection.host,
|
||||
dbName,
|
||||
`${Date.now()}.tar`
|
||||
);
|
||||
const key = path.join(server.connection.host, dbName, `${Date.now()}`);
|
||||
const outFile = path.join(BACKUP_DIR, key);
|
||||
mkdir(path.dirname(outFile));
|
||||
|
||||
|
@ -14,7 +14,7 @@ const sshSchema = z
|
||||
const postgresSchema = z.object({
|
||||
type: z.literal("postgres"),
|
||||
host: z.string(),
|
||||
port: z.number().optional(),
|
||||
port: z.coerce.number().int().optional(),
|
||||
user: z.string(),
|
||||
pass: z.string(),
|
||||
});
|
||||
|
@ -1,11 +1,11 @@
|
||||
import db from "@/db";
|
||||
import { backupModel, serverModel } from "@/db/models";
|
||||
import db from "../db";
|
||||
import { backupModel, serverModel } from "../db/models";
|
||||
import type {
|
||||
CreateBackupSchema,
|
||||
GetAllBackupQuery,
|
||||
RestoreBackupSchema,
|
||||
} from "@/schemas/backup.schema";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
} from "../schemas/backup.schema";
|
||||
import { and, count, desc, eq, inArray } from "drizzle-orm";
|
||||
import DatabaseService from "./database.service";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
@ -20,18 +20,28 @@ export default class BackupService {
|
||||
const page = query.page || 1;
|
||||
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({
|
||||
where: (i) =>
|
||||
and(
|
||||
serverId ? eq(i.serverId, serverId) : undefined,
|
||||
databaseId ? eq(i.databaseId, databaseId) : undefined
|
||||
),
|
||||
where,
|
||||
with: {
|
||||
database: { columns: { name: true } },
|
||||
},
|
||||
orderBy: desc(serverModel.createdAt),
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
});
|
||||
|
||||
return backups;
|
||||
return { count: totalRows.count, rows: backups };
|
||||
}
|
||||
|
||||
async getOrFail(id: string) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import db from "@/db";
|
||||
import { databaseModel } from "@/db/models";
|
||||
import db from "../db";
|
||||
import { databaseModel } from "../db/models";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import db from "@/db";
|
||||
import { databaseModel, serverModel, type ServerModel } from "@/db/models";
|
||||
import type { CreateServerSchema } from "@/schemas/server.schema";
|
||||
import db from "../db";
|
||||
import { databaseModel, serverModel, type ServerModel } from "../db/models";
|
||||
import type { CreateServerSchema } from "../schemas/server.schema";
|
||||
import { asc, desc, eq } from "drizzle-orm";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
@ -36,7 +36,15 @@ export default class ServerService {
|
||||
databases: true,
|
||||
},
|
||||
});
|
||||
return server;
|
||||
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = this.parse(server);
|
||||
delete result.connection.pass;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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 = {
|
||||
...data,
|
||||
connection: data.connection ? JSON.parse(data.connection) : null,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import DatabaseUtil from "@/lib/database-util";
|
||||
import { DOCKER_HOST, BACKUP_DIR } from "@/consts";
|
||||
import { mkdir } from "@/utility/utils";
|
||||
import DatabaseUtil from "../lib/database-util";
|
||||
import { DOCKER_HOST, BACKUP_DIR } from "../consts";
|
||||
import { mkdir } from "../utility/utils";
|
||||
import path from "path";
|
||||
|
||||
const main = async () => {
|
||||
|
@ -5,3 +5,7 @@ export const mkdir = (dir: string) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
export const urlencode = (str: string) => {
|
||||
return encodeURIComponent(str);
|
||||
};
|
||||
|
@ -22,11 +22,6 @@
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
|
4
frontend/app/globals.css
Normal file
4
frontend/app/globals.css
Normal file
@ -0,0 +1,4 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
BIN
frontend/bun.lockb
Executable file
BIN
frontend/bun.lockb
Executable file
Binary file not shown.
17
frontend/components.json
Normal file
17
frontend/components.json
Normal 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"
|
||||
}
|
||||
}
|
@ -10,18 +10,48 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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-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": {
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
|
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
22
frontend/src/app/App.tsx
Normal 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;
|
43
frontend/src/app/Router.tsx
Normal file
43
frontend/src/app/Router.tsx
Normal 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;
|
3
frontend/src/app/global.css
Normal file
3
frontend/src/app/global.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
49
frontend/src/components/containers/confirm-dialog.tsx
Normal file
49
frontend/src/components/containers/confirm-dialog.tsx
Normal 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;
|
40
frontend/src/components/containers/sidebar.tsx
Normal file
40
frontend/src/components/containers/sidebar.tsx
Normal 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;
|
18
frontend/src/components/layouts/dashboard.tsx
Normal file
18
frontend/src/components/layouts/dashboard.tsx
Normal 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;
|
78
frontend/src/components/ui/alert.tsx
Normal file
78
frontend/src/components/ui/alert.tsx
Normal 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 };
|
22
frontend/src/components/ui/back-button.tsx
Normal file
22
frontend/src/components/ui/back-button.tsx
Normal 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;
|
84
frontend/src/components/ui/button.tsx
Normal file
84
frontend/src/components/ui/button.tsx
Normal 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;
|
13
frontend/src/components/ui/card.tsx
Normal file
13
frontend/src/components/ui/card.tsx
Normal 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;
|
28
frontend/src/components/ui/checkbox.tsx
Normal file
28
frontend/src/components/ui/checkbox.tsx
Normal 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 };
|
15
frontend/src/components/ui/data-table.tsx
Normal file
15
frontend/src/components/ui/data-table.tsx
Normal 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;
|
132
frontend/src/components/ui/dialog.tsx
Normal file
132
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
198
frontend/src/components/ui/dropdown-menu.tsx
Normal file
198
frontend/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
93
frontend/src/components/ui/form.tsx
Normal file
93
frontend/src/components/ui/form.tsx
Normal 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;
|
51
frontend/src/components/ui/input.tsx
Normal file
51
frontend/src/components/ui/input.tsx
Normal 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;
|
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal 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 };
|
35
frontend/src/components/ui/nav-item.tsx
Normal file
35
frontend/src/components/ui/nav-item.tsx
Normal 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;
|
26
frontend/src/components/ui/page-title.tsx
Normal file
26
frontend/src/components/ui/page-title.tsx
Normal 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;
|
29
frontend/src/components/ui/popover.tsx
Normal file
29
frontend/src/components/ui/popover.tsx
Normal 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 }
|
13
frontend/src/components/ui/section-title.tsx
Normal file
13
frontend/src/components/ui/section-title.tsx
Normal 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;
|
222
frontend/src/components/ui/select.tsx
Normal file
222
frontend/src/components/ui/select.tsx
Normal 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;
|
30
frontend/src/components/ui/sonner.tsx
Normal file
30
frontend/src/components/ui/sonner.tsx
Normal 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;
|
28
frontend/src/components/ui/tooltip.tsx
Normal file
28
frontend/src/components/ui/tooltip.tsx
Normal 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 }
|
14
frontend/src/context/form-context.ts
Normal file
14
frontend/src/context/form-context.ts
Normal 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;
|
11
frontend/src/hooks/usePartials.ts
Normal file
11
frontend/src/hooks/usePartials.ts
Normal 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;
|
||||
};
|
32
frontend/src/hooks/useQueryParams.ts
Normal file
32
frontend/src/hooks/useQueryParams.ts
Normal 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;
|
||||
};
|
@ -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
15
frontend/src/lib/api.ts
Normal 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;
|
38
frontend/src/lib/disclosure.ts
Normal file
38
frontend/src/lib/disclosure.ts
Normal 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>
|
||||
>;
|
3
frontend/src/lib/queryClient.ts
Normal file
3
frontend/src/lib/queryClient.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { QueryClient } from "react-query";
|
||||
|
||||
export const queryClient = new QueryClient();
|
30
frontend/src/lib/utils.ts
Normal file
30
frontend/src/lib/utils.ts
Normal 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!");
|
||||
}
|
||||
};
|
@ -1,10 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./app/App.tsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
44
frontend/src/pages/home/components/server-section.tsx
Normal file
44
frontend/src/pages/home/components/server-section.tsx
Normal 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;
|
11
frontend/src/pages/home/page.tsx
Normal file
11
frontend/src/pages/home/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import ServerSection from "./components/server-section";
|
||||
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<div>
|
||||
<ServerSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
214
frontend/src/pages/servers/components/add-server-dialog.tsx
Normal file
214
frontend/src/pages/servers/components/add-server-dialog.tsx
Normal 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;
|
46
frontend/src/pages/servers/components/backup-button.tsx
Normal file
46
frontend/src/pages/servers/components/backup-button.tsx
Normal 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;
|
67
frontend/src/pages/servers/components/backup-status.tsx
Normal file
67
frontend/src/pages/servers/components/backup-status.tsx
Normal 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;
|
37
frontend/src/pages/servers/components/connection-status.tsx
Normal file
37
frontend/src/pages/servers/components/connection-status.tsx
Normal 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;
|
68
frontend/src/pages/servers/components/server-list.tsx
Normal file
68
frontend/src/pages/servers/components/server-list.tsx
Normal 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;
|
50
frontend/src/pages/servers/page.tsx
Normal file
50
frontend/src/pages/servers/page.tsx
Normal 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;
|
21
frontend/src/pages/servers/schema.ts
Normal file
21
frontend/src/pages/servers/schema.ts
Normal 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: [],
|
||||
};
|
4
frontend/src/pages/servers/stores.ts
Normal file
4
frontend/src/pages/servers/stores.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { createDisclosureStore } from "@/lib/disclosure";
|
||||
import { initialServerData } from "../servers/schema";
|
||||
|
||||
export const addServerDlg = createDisclosureStore(initialServerData);
|
@ -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;
|
@ -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;
|
65
frontend/src/pages/servers/view/page.tsx
Normal file
65
frontend/src/pages/servers/view/page.tsx
Normal 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;
|
176
frontend/src/pages/servers/view/table.tsx
Normal file
176
frontend/src/pages/servers/view/table.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
50
frontend/tailwind.config.ts
Normal file
50
frontend/tailwind.config.ts
Normal 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;
|
@ -18,7 +18,12 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@backend/*": ["../backend/src/*"],
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
@ -1,7 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@backend": path.resolve(__dirname, "../backend/src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user