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";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { DATABASE_PATH } from "@/consts";
|
import { DATABASE_PATH } from "../consts";
|
||||||
import { mkdir } from "@/utility/utils";
|
import { mkdir } from "../utility/utils";
|
||||||
import schema from "./schema";
|
import schema from "./schema";
|
||||||
|
|
||||||
// Create database directory if not exists
|
// Create database directory if not exists
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { DATABASE_PATH } from "@/consts";
|
import { DATABASE_PATH } from "../consts";
|
||||||
import db, { sqlite } from ".";
|
import db, { sqlite } from ".";
|
||||||
import { seed } from "./seed";
|
import { seed } from "./seed";
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import type {
|
|||||||
PostgresConfig,
|
PostgresConfig,
|
||||||
} from "../../types/database.types";
|
} from "../../types/database.types";
|
||||||
import { exec } from "../../utility/process";
|
import { exec } from "../../utility/process";
|
||||||
|
import { urlencode } from "../../utility/utils";
|
||||||
import BaseDbms from "./base";
|
import BaseDbms from "./base";
|
||||||
|
|
||||||
class PostgresDbms extends BaseDbms {
|
class PostgresDbms extends BaseDbms {
|
||||||
@ -18,7 +19,7 @@ class PostgresDbms extends BaseDbms {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dump(dbName: string, path: string) {
|
async dump(dbName: string, path: string) {
|
||||||
return exec(["pg_dump", this.dbUrl + `/${dbName}`, "-Ftar", "-f", path]);
|
return exec(["pg_dump", this.dbUrl + `/${dbName}`, "-Z9", "-f", path]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async restore(path: string) {
|
async restore(path: string) {
|
||||||
@ -29,7 +30,7 @@ class PostgresDbms extends BaseDbms {
|
|||||||
"-cC",
|
"-cC",
|
||||||
"--if-exists",
|
"--if-exists",
|
||||||
"--exit-on-error",
|
"--exit-on-error",
|
||||||
"-Ftar",
|
// "-Ftar",
|
||||||
path,
|
path,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -45,7 +46,7 @@ class PostgresDbms extends BaseDbms {
|
|||||||
private get dbUrl() {
|
private get dbUrl() {
|
||||||
const { user, pass, host } = this.config;
|
const { user, pass, host } = this.config;
|
||||||
const port = this.config.port || 5432;
|
const port = this.config.port || 5432;
|
||||||
return `postgresql://${user}:${pass}@${host}:${port}`;
|
return `postgresql://${user}:${urlencode(pass)}@${host}:${port}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,8 +2,8 @@ import {
|
|||||||
createBackupSchema,
|
createBackupSchema,
|
||||||
getAllBackupQuery,
|
getAllBackupQuery,
|
||||||
restoreBackupSchema,
|
restoreBackupSchema,
|
||||||
} from "@/schemas/backup.schema";
|
} from "../schemas/backup.schema";
|
||||||
import BackupService from "@/services/backup.service";
|
import BackupService from "../services/backup.service";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { handleError } from "@/middlewares/error-handler";
|
|
||||||
import server from "./server.router";
|
import server from "./server.router";
|
||||||
import backup from "./backup.router";
|
import backup from "./backup.router";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { handleError } from "../middlewares/error-handler";
|
||||||
|
|
||||||
const routers = new Hono()
|
const routers = new Hono()
|
||||||
// Middlewares
|
// Middlewares
|
||||||
.onError(handleError)
|
.onError(handleError)
|
||||||
|
.use(cors())
|
||||||
|
|
||||||
// App health check
|
// App health check
|
||||||
.get("/health-check", (c) => c.text("OK"))
|
.get("/health-check", (c) => c.text("OK"))
|
||||||
@ -14,4 +16,6 @@ const routers = new Hono()
|
|||||||
.route("/servers", server)
|
.route("/servers", server)
|
||||||
.route("/backups", backup);
|
.route("/backups", backup);
|
||||||
|
|
||||||
|
export type AppRouter = typeof routers;
|
||||||
|
|
||||||
export default routers;
|
export default routers;
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { checkServerSchema, createServerSchema } from "@/schemas/server.schema";
|
|
||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
import DatabaseUtil from "@/lib/database-util";
|
import ServerService from "../services/server.service";
|
||||||
import ServerService from "@/services/server.service";
|
import {
|
||||||
|
checkServerSchema,
|
||||||
|
createServerSchema,
|
||||||
|
} from "../schemas/server.schema";
|
||||||
|
import DatabaseUtil from "../lib/database-util";
|
||||||
|
|
||||||
const serverService = new ServerService();
|
const serverService = new ServerService();
|
||||||
const router = new Hono()
|
const router = new Hono()
|
||||||
@ -27,7 +30,7 @@ const router = new Hono()
|
|||||||
return c.json({ success: true, databases });
|
return c.json({ success: true, databases });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new HTTPException(400, {
|
throw new HTTPException(400, {
|
||||||
message: "Cannot connect to the database.",
|
message: (err as any).message || "Cannot connect to the database.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -49,7 +52,7 @@ const router = new Hono()
|
|||||||
|
|
||||||
.get("/:id", async (c) => {
|
.get("/:id", async (c) => {
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const server = await serverService.getOrFail(id);
|
const server = await serverService.getById(id);
|
||||||
return c.json(server);
|
return c.json(server);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import db from "@/db";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { backupModel, databaseModel } from "@/db/models";
|
|
||||||
import DatabaseUtil from "@/lib/database-util";
|
|
||||||
import ServerService from "@/services/server.service";
|
|
||||||
import { and, asc, eq, sql } from "drizzle-orm";
|
import { and, asc, eq, sql } from "drizzle-orm";
|
||||||
import { BACKUP_DIR } from "@/consts";
|
import ServerService from "../services/server.service";
|
||||||
import { mkdir } from "@/utility/utils";
|
import db from "../db";
|
||||||
import { hashFile } from "@/utility/hash";
|
import { backupModel, databaseModel } from "../db/models";
|
||||||
|
import DatabaseUtil from "../lib/database-util";
|
||||||
|
import { BACKUP_DIR } from "../consts";
|
||||||
|
import { mkdir } from "../utility/utils";
|
||||||
|
import { hashFile } from "../utility/hash";
|
||||||
|
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
const serverService = new ServerService();
|
const serverService = new ServerService();
|
||||||
@ -19,16 +19,12 @@ const runBackup = async (task: PendingTasks[number]) => {
|
|||||||
.set({ status: "running" })
|
.set({ status: "running" })
|
||||||
.where(eq(backupModel.id, task.id));
|
.where(eq(backupModel.id, task.id));
|
||||||
|
|
||||||
const server = serverService.parse(task.server as never);
|
const server = serverService.parse(task.server);
|
||||||
const dbName = task.database.name;
|
const dbName = task.database.name;
|
||||||
const dbUtil = new DatabaseUtil(server.connection);
|
const dbUtil = new DatabaseUtil(server.connection);
|
||||||
|
|
||||||
if (task.type === "backup") {
|
if (task.type === "backup") {
|
||||||
const key = path.join(
|
const key = path.join(server.connection.host, dbName, `${Date.now()}`);
|
||||||
server.connection.host,
|
|
||||||
dbName,
|
|
||||||
`${Date.now()}.tar`
|
|
||||||
);
|
|
||||||
const outFile = path.join(BACKUP_DIR, key);
|
const outFile = path.join(BACKUP_DIR, key);
|
||||||
mkdir(path.dirname(outFile));
|
mkdir(path.dirname(outFile));
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ const sshSchema = z
|
|||||||
const postgresSchema = z.object({
|
const postgresSchema = z.object({
|
||||||
type: z.literal("postgres"),
|
type: z.literal("postgres"),
|
||||||
host: z.string(),
|
host: z.string(),
|
||||||
port: z.number().optional(),
|
port: z.coerce.number().int().optional(),
|
||||||
user: z.string(),
|
user: z.string(),
|
||||||
pass: z.string(),
|
pass: z.string(),
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import db from "@/db";
|
import db from "../db";
|
||||||
import { backupModel, serverModel } from "@/db/models";
|
import { backupModel, serverModel } from "../db/models";
|
||||||
import type {
|
import type {
|
||||||
CreateBackupSchema,
|
CreateBackupSchema,
|
||||||
GetAllBackupQuery,
|
GetAllBackupQuery,
|
||||||
RestoreBackupSchema,
|
RestoreBackupSchema,
|
||||||
} from "@/schemas/backup.schema";
|
} from "../schemas/backup.schema";
|
||||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
import { and, count, desc, eq, inArray } from "drizzle-orm";
|
||||||
import DatabaseService from "./database.service";
|
import DatabaseService from "./database.service";
|
||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
|
||||||
@ -20,18 +20,28 @@ export default class BackupService {
|
|||||||
const page = query.page || 1;
|
const page = query.page || 1;
|
||||||
const limit = query.limit || 10;
|
const limit = query.limit || 10;
|
||||||
|
|
||||||
|
const where = and(
|
||||||
|
serverId ? eq(backupModel.serverId, serverId) : undefined,
|
||||||
|
databaseId ? eq(backupModel.databaseId, databaseId) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const [totalRows] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(backupModel)
|
||||||
|
.where(where)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const backups = await db.query.backup.findMany({
|
const backups = await db.query.backup.findMany({
|
||||||
where: (i) =>
|
where,
|
||||||
and(
|
with: {
|
||||||
serverId ? eq(i.serverId, serverId) : undefined,
|
database: { columns: { name: true } },
|
||||||
databaseId ? eq(i.databaseId, databaseId) : undefined
|
},
|
||||||
),
|
|
||||||
orderBy: desc(serverModel.createdAt),
|
orderBy: desc(serverModel.createdAt),
|
||||||
limit,
|
limit,
|
||||||
offset: (page - 1) * limit,
|
offset: (page - 1) * limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return backups;
|
return { count: totalRows.count, rows: backups };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrFail(id: string) {
|
async getOrFail(id: string) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import db from "@/db";
|
import db from "../db";
|
||||||
import { databaseModel } from "@/db/models";
|
import { databaseModel } from "../db/models";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import db from "@/db";
|
import db from "../db";
|
||||||
import { databaseModel, serverModel, type ServerModel } from "@/db/models";
|
import { databaseModel, serverModel, type ServerModel } from "../db/models";
|
||||||
import type { CreateServerSchema } from "@/schemas/server.schema";
|
import type { CreateServerSchema } from "../schemas/server.schema";
|
||||||
import { asc, desc, eq } from "drizzle-orm";
|
import { asc, desc, eq } from "drizzle-orm";
|
||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
|
||||||
@ -36,7 +36,15 @@ export default class ServerService {
|
|||||||
databases: true,
|
databases: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return server;
|
|
||||||
|
if (!server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.parse(server);
|
||||||
|
delete result.connection.pass;
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: CreateServerSchema) {
|
async create(data: CreateServerSchema) {
|
||||||
@ -73,7 +81,7 @@ export default class ServerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
parse(data: ServerModel) {
|
parse<T extends Pick<ServerModel, "connection" | "ssh">>(data: T) {
|
||||||
const result = {
|
const result = {
|
||||||
...data,
|
...data,
|
||||||
connection: data.connection ? JSON.parse(data.connection) : null,
|
connection: data.connection ? JSON.parse(data.connection) : null,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import DatabaseUtil from "@/lib/database-util";
|
import DatabaseUtil from "../lib/database-util";
|
||||||
import { DOCKER_HOST, BACKUP_DIR } from "@/consts";
|
import { DOCKER_HOST, BACKUP_DIR } from "../consts";
|
||||||
import { mkdir } from "@/utility/utils";
|
import { mkdir } from "../utility/utils";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
|
@ -5,3 +5,7 @@ export const mkdir = (dir: string) => {
|
|||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const urlencode = (str: string) => {
|
||||||
|
return encodeURIComponent(str);
|
||||||
|
};
|
||||||
|
@ -22,11 +22,6 @@
|
|||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4
frontend/app/globals.css
Normal file
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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.3.4",
|
||||||
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dayjs": "^1.11.11",
|
||||||
|
"hono": "^4.3.4",
|
||||||
|
"lucide-react": "^0.378.0",
|
||||||
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-data-table-component": "^7.6.2",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
|
"react-hook-form": "^7.51.4",
|
||||||
|
"react-icons": "^5.2.1",
|
||||||
|
"react-query": "^3.39.3",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"sonner": "^1.4.41",
|
||||||
|
"tailwind-merge": "^2.3.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@types/react-helmet": "^6.1.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
"@typescript-eslint/parser": "^7.2.0",
|
"@typescript-eslint/parser": "^7.2.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.6",
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
}
|
}
|
||||||
|
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 React from "react";
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from "react-dom/client";
|
||||||
import App from './App.tsx'
|
import App from "./app/App.tsx";
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>
|
||||||
)
|
);
|
||||||
|
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,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@backend/*": ["../backend/src/*"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
})
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
"@backend": path.resolve(__dirname, "../backend/src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user