mirror of
https://github.com/ershisan99/db-studio.git
synced 2025-12-16 12:33:05 +00:00
initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.idea
|
||||
44
api/.gitignore
vendored
Normal file
44
api/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
**/*.trace
|
||||
**/*.zip
|
||||
**/*.tar.gz
|
||||
**/*.tgz
|
||||
**/*.log
|
||||
package-lock.json
|
||||
**/*.bun
|
||||
|
||||
.env
|
||||
19
api/README.md
Normal file
19
api/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Elysia with Bun runtime
|
||||
|
||||
## Getting Started
|
||||
|
||||
To get started with this template, simply paste this command into your terminal:
|
||||
|
||||
```bash
|
||||
bun create elysia ./elysia-example
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
To start the development server run:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000/ with your browser to see the result.
|
||||
22
api/biome.json
Normal file
22
api/biome.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"json": {
|
||||
"parser": {
|
||||
"allowComments": true,
|
||||
"allowTrailingCommas": true
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
api/bun.lockb
Normal file
BIN
api/bun.lockb
Normal file
Binary file not shown.
20
api/package.json
Normal file
20
api/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "1.0.50",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "bun run --watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.0.2",
|
||||
"@it-incubator/prettier-config": "^0.1.2",
|
||||
"elysia": "latest",
|
||||
"postgres": "^3.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.8.3",
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"prettier": "@it-incubator/prettier-config",
|
||||
"module": "src/index.js"
|
||||
}
|
||||
294
api/src/index.ts
Normal file
294
api/src/index.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import cors from "@elysiajs/cors";
|
||||
import { Elysia } from "elysia";
|
||||
import postgres from "postgres";
|
||||
const DB_URL = Bun.env.DB_URL;
|
||||
|
||||
if (!DB_URL) {
|
||||
console.error("DB_URL not found in environment variables");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(DB_URL);
|
||||
|
||||
const [{ version }] = await sql`SELECT version()`;
|
||||
console.log("pg version: ", version);
|
||||
|
||||
const app = new Elysia({ prefix: "/api" })
|
||||
.get("/", () => "Hello Elysia")
|
||||
.get("/databases", async () => {
|
||||
const databases = await getDatabases();
|
||||
return new Response(JSON.stringify(databases, null, 2)).json();
|
||||
})
|
||||
.get("/databases/:dbName/tables", async ({ query, params }) => {
|
||||
const { sortField, sortDesc } = query;
|
||||
const { dbName } = params;
|
||||
|
||||
const tables = await getTables(dbName, sortField, sortDesc === "true");
|
||||
|
||||
return new Response(JSON.stringify(tables, null, 2)).json();
|
||||
})
|
||||
.get("databases/:dbName/tables/:name/data", async ({ params, query }) => {
|
||||
const { name, dbName } = params;
|
||||
const { perPage = "50", page = "0" } = query;
|
||||
|
||||
const offset = (
|
||||
Number.parseInt(perPage, 10) * Number.parseInt(page, 10)
|
||||
).toString();
|
||||
|
||||
const rows = sql`
|
||||
SELECT COUNT(*)
|
||||
FROM ${sql(dbName)}.${sql(name)}`;
|
||||
|
||||
const tables = sql`
|
||||
SELECT *
|
||||
FROM ${sql(dbName)}.${sql(name)}
|
||||
LIMIT ${perPage} OFFSET ${offset}`;
|
||||
|
||||
const [[count], data] = await Promise.all([rows, tables]);
|
||||
|
||||
return {
|
||||
count: count.count,
|
||||
data,
|
||||
};
|
||||
})
|
||||
.get("db/tables/:name/columns", async ({ params, query }) => {
|
||||
const { name } = params;
|
||||
|
||||
const columns = await getColumns(name);
|
||||
return new Response(JSON.stringify(columns, null, 2)).json();
|
||||
})
|
||||
.get("db/tables/:name/indexes", async ({ params, query }) => {
|
||||
const { name } = params;
|
||||
|
||||
const indexes = await getIndexes(name);
|
||||
return new Response(JSON.stringify(indexes, null, 2)).json();
|
||||
})
|
||||
.get("db/tables/:name/foreign-keys", async ({ params, query }) => {
|
||||
const { name } = params;
|
||||
|
||||
const foreignKeys = await getForeignKeys(name);
|
||||
return new Response(JSON.stringify(foreignKeys, null, 2)).json();
|
||||
})
|
||||
.use(cors())
|
||||
.listen(3000);
|
||||
|
||||
console.log(
|
||||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
|
||||
);
|
||||
|
||||
async function getIndexes(table: string) {
|
||||
const returnObj = {};
|
||||
|
||||
const [tableOidResult] = await sql`
|
||||
SELECT oid
|
||||
FROM pg_class
|
||||
WHERE relname = ${table}
|
||||
`;
|
||||
|
||||
const tableOid = tableOidResult.oid;
|
||||
|
||||
const columnsResult = await sql`
|
||||
SELECT attnum, attname
|
||||
FROM pg_attribute
|
||||
WHERE attrelid = ${tableOid}
|
||||
AND attnum > 0
|
||||
`;
|
||||
|
||||
const columns = {};
|
||||
columnsResult.forEach((row) => {
|
||||
columns[row.attnum] = row.attname;
|
||||
});
|
||||
|
||||
const indexResult = await sql`
|
||||
SELECT
|
||||
relname,
|
||||
indisunique::int,
|
||||
indisprimary::int,
|
||||
indkey,
|
||||
(indpred IS NOT NULL)::int as indispartial
|
||||
FROM pg_index i
|
||||
JOIN pg_class ci ON ci.oid = i.indexrelid
|
||||
WHERE i.indrelid = ${tableOid}
|
||||
`;
|
||||
|
||||
return indexResult.map((row) => {
|
||||
return {
|
||||
relname: row.relname,
|
||||
key: row.relname,
|
||||
type: row.indispartial
|
||||
? "INDEX"
|
||||
: row.indisprimary
|
||||
? "PRIMARY"
|
||||
: row.indisunique
|
||||
? "UNIQUE"
|
||||
: "INDEX",
|
||||
columns: row.indkey.split(" ").map((indkey) => columns[indkey]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getColumns(tableName: string) {
|
||||
return await sql`
|
||||
SELECT
|
||||
cols.column_name,
|
||||
cols.data_type,
|
||||
cols.udt_name,
|
||||
pgd.description AS column_comment
|
||||
FROM
|
||||
information_schema.columns AS cols
|
||||
LEFT JOIN
|
||||
pg_catalog.pg_statio_all_tables AS st ON st.relname = cols.table_name
|
||||
LEFT JOIN
|
||||
pg_catalog.pg_description AS pgd ON pgd.objoid = st.relid AND pgd.objsubid = cols.ordinal_position
|
||||
WHERE
|
||||
cols.table_name = ${tableName}
|
||||
ORDER BY
|
||||
cols.ordinal_position;
|
||||
`;
|
||||
}
|
||||
|
||||
async function getForeignKeys(table: string) {
|
||||
const result = await sql`
|
||||
SELECT
|
||||
conname,
|
||||
condeferrable::int AS deferrable,
|
||||
pg_get_constraintdef(oid) AS definition
|
||||
FROM
|
||||
pg_constraint
|
||||
WHERE
|
||||
conrelid = (
|
||||
SELECT pc.oid
|
||||
FROM pg_class AS pc
|
||||
INNER JOIN pg_namespace AS pn ON (pn.oid = pc.relnamespace)
|
||||
WHERE pc.relname = ${table}
|
||||
)
|
||||
AND contype = 'f'::char
|
||||
ORDER BY conkey, conname
|
||||
`;
|
||||
|
||||
return result.map((row) => {
|
||||
const match = row.definition.match(
|
||||
/FOREIGN KEY\s*\((.+)\)\s*REFERENCES (.+)\((.+)\)(.*)$/iy,
|
||||
);
|
||||
console.log(match);
|
||||
console.log("match[0]", match[0]);
|
||||
console.log("match[1]", match[1]);
|
||||
console.log("match[2]", match[2]);
|
||||
if (match) {
|
||||
const sourceColumns = match[1]
|
||||
.split(",")
|
||||
.map((col) => col.replaceAll('"', "").trim());
|
||||
const targetTableMatch = match[2].match(
|
||||
/^(("([^"]|"")+"|[^"]+)\.)?"?("([^"]|"")+"|[^"]+)$/,
|
||||
);
|
||||
console.log("targetTableMatch", targetTableMatch);
|
||||
const targetTable = targetTableMatch ? targetTableMatch[0].trim() : null;
|
||||
const targetColumns = match[3]
|
||||
.split(",")
|
||||
.map((col) => col.replaceAll('"', "").trim());
|
||||
const { onDelete, onUpdate } = getActions(match[4]);
|
||||
return {
|
||||
conname: row.conname,
|
||||
deferrable: Boolean(row.deferrable),
|
||||
definition: row.definition,
|
||||
source: sourceColumns,
|
||||
ns: targetTableMatch
|
||||
? targetTableMatch[0].replaceAll('"', "").trim()
|
||||
: null,
|
||||
table: targetTable.replaceAll('"', ""),
|
||||
target: targetColumns,
|
||||
on_delete: onDelete ?? "NO ACTION",
|
||||
on_update: onUpdate ?? "NO ACTION",
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getActions(matchString: string) {
|
||||
const onActions = "RESTRICT|NO ACTION|CASCADE|SET NULL|SET DEFAULT";
|
||||
const onDeleteRegex = new RegExp(`ON DELETE (${onActions})`);
|
||||
const onUpdateRegex = new RegExp(`ON UPDATE (${onActions})`);
|
||||
|
||||
const onDeleteMatch = matchString.match(onDeleteRegex);
|
||||
const onUpdateMatch = matchString.match(onUpdateRegex);
|
||||
|
||||
const onDeleteAction = onDeleteMatch ? onDeleteMatch[1] : "NO ACTION";
|
||||
const onUpdateAction = onUpdateMatch ? onUpdateMatch[1] : "NO ACTION";
|
||||
|
||||
return {
|
||||
onDelete: onDeleteAction,
|
||||
onUpdate: onUpdateAction,
|
||||
};
|
||||
}
|
||||
|
||||
async function getTables(
|
||||
dbName: string,
|
||||
sortField?: string,
|
||||
sortDesc?: boolean,
|
||||
) {
|
||||
const tables = await sql`
|
||||
WITH primary_keys AS (SELECT pg_class.relname AS table_name,
|
||||
pg_namespace.nspname AS schema_name,
|
||||
pg_attribute.attname AS primary_key
|
||||
FROM pg_index
|
||||
JOIN
|
||||
pg_class ON pg_class.oid = pg_index.indrelid
|
||||
JOIN
|
||||
pg_attribute ON pg_attribute.attrelid = pg_class.oid AND
|
||||
pg_attribute.attnum = ANY (pg_index.indkey)
|
||||
JOIN
|
||||
pg_namespace ON pg_namespace.oid = pg_class.relnamespace
|
||||
WHERE pg_index.indisprimary)
|
||||
SELECT t.schemaname AS schema_name,
|
||||
t.tablename AS table_name,
|
||||
pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS total_size,
|
||||
pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS table_size,
|
||||
pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) -
|
||||
pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS index_size,
|
||||
COALESCE(
|
||||
(SELECT obj_description((quote_ident(t.schemaname) || '.' || quote_ident(t.tablename))::regclass)),
|
||||
''
|
||||
) AS comments,
|
||||
(SELECT reltuples::bigint
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = t.tablename
|
||||
AND n.nspname = t.schemaname) AS row_count,
|
||||
(SELECT string_agg(indexname, ', ')
|
||||
FROM pg_indexes
|
||||
WHERE tablename = t.tablename
|
||||
AND schemaname = t.schemaname) AS indexes,
|
||||
t.tableowner AS owner,
|
||||
COALESCE(
|
||||
(SELECT string_agg(pk.primary_key, ', ')
|
||||
FROM primary_keys pk
|
||||
WHERE pk.schema_name = t.schemaname
|
||||
AND pk.table_name = t.tablename),
|
||||
''
|
||||
) AS primary_key
|
||||
FROM pg_tables t
|
||||
WHERE t.schemaname = ${dbName}
|
||||
|
||||
ORDER BY ${
|
||||
sortField
|
||||
? sql`${sql(sortField)}
|
||||
${sortDesc ? sql`DESC` : sql`ASC`}`
|
||||
: sql`t.schemaname, t.tablename`
|
||||
}`;
|
||||
|
||||
return tables.map((table) => ({
|
||||
...table,
|
||||
total_size: Number.parseInt(table.total_size, 10),
|
||||
table_size: Number.parseInt(table.table_size, 10),
|
||||
index_size: Number.parseInt(table.index_size, 10),
|
||||
row_count: Number.parseInt(table.row_count, 10),
|
||||
}));
|
||||
}
|
||||
|
||||
async function getDatabases() {
|
||||
const result = await sql`
|
||||
SELECT nspname
|
||||
FROM pg_catalog.pg_namespace;`;
|
||||
|
||||
return result.map(({ nspname }) => nspname);
|
||||
}
|
||||
105
api/tsconfig.json
Normal file
105
api/tsconfig.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "ES2022" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
"types": [
|
||||
"bun-types"
|
||||
] /* Specify type package names to be included without being referenced in a source file. */,
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
30
frontend/README.md
Normal file
30
frontend/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
20
frontend/biome.json
Normal file
20
frontend/biome.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
|
||||
"organizeImports": { "enabled": true },
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": { "indentStyle": "space", "indentWidth": 2 },
|
||||
"json": {
|
||||
"parser": {
|
||||
"allowComments": true,
|
||||
"allowTrailingCommas": true
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
frontend/bun.lockb
Normal file
BIN
frontend/bun.lockb
Normal 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": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenStudio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint:types": "tsc -b",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@it-incubator/prettier-config": "^0.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@tanstack/react-query": "^5.50.1",
|
||||
"@tanstack/react-router": "^1.43.12",
|
||||
"@tanstack/react-table": "^8.19.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^1.4.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.8.3",
|
||||
"@tanstack/react-query-devtools": "^5.50.1",
|
||||
"@tanstack/router-devtools": "^1.43.12",
|
||||
"@tanstack/router-plugin": "^1.43.12",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.1"
|
||||
},
|
||||
"prettier": "@it-incubator/prettier-config"
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
241
frontend/src/components/db-table-view/data-table.tsx
Normal file
241
frontend/src/components/db-table-view/data-table.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
DataTablePagination,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTableColumnsQuery, useTableDataQuery } from "@/services/db";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Rows3 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
function isUrl(value: string) {
|
||||
return z.string().url().safeParse(value).success;
|
||||
}
|
||||
|
||||
const imageUrlRegex = new RegExp(
|
||||
/(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|svg|webp|bmp))/i,
|
||||
);
|
||||
|
||||
function isImageUrl(value: string) {
|
||||
return value.match(imageUrlRegex);
|
||||
}
|
||||
|
||||
export const DataTable = ({
|
||||
tableName,
|
||||
dbName,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
onPageIndexChange,
|
||||
}: {
|
||||
tableName: string;
|
||||
pageIndex: number;
|
||||
dbName: string;
|
||||
pageSize: number;
|
||||
onPageIndexChange: (pageIndex: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
}) => {
|
||||
const { data: details } = useTableColumnsQuery({ name: tableName });
|
||||
const { data } = useTableDataQuery({
|
||||
tableName,
|
||||
dbName,
|
||||
perPage: pageSize,
|
||||
page: pageIndex,
|
||||
});
|
||||
|
||||
const paginationUpdater: OnChangeFn<PaginationState> = (args) => {
|
||||
if (typeof args === "function") {
|
||||
const newArgs = args({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
});
|
||||
if (newArgs.pageSize !== pageSize) {
|
||||
onPageSizeChange(newArgs.pageSize);
|
||||
} else if (newArgs.pageIndex !== pageIndex) {
|
||||
onPageIndexChange(newArgs.pageIndex);
|
||||
}
|
||||
} else {
|
||||
onPageSizeChange(args.pageSize);
|
||||
onPageIndexChange(args.pageIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!details) return [] as ColumnDef<any>[];
|
||||
|
||||
return details.map(({ column_name, udt_name, data_type }) => ({
|
||||
accessorKey: column_name,
|
||||
title: column_name,
|
||||
size: 300,
|
||||
header: () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
data_type === "integer" && "text-right",
|
||||
)}
|
||||
>
|
||||
{`${column_name} [${udt_name.toUpperCase()}]`}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
sortable: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue(column_name) as any;
|
||||
let finalValue = value;
|
||||
if (udt_name === "timestamp") {
|
||||
finalValue = new Date(value as string).toLocaleString();
|
||||
}
|
||||
if (typeof value === "string" && isUrl(value)) {
|
||||
const isImage = isImageUrl(value);
|
||||
return (
|
||||
<a
|
||||
href={value}
|
||||
target={"_blank"}
|
||||
className={cn("hover:underline")}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className={"flex items-center break-all gap-4"}>
|
||||
{value}
|
||||
{isImage && (
|
||||
<img
|
||||
src={value}
|
||||
alt={"preview"}
|
||||
className="size-20 object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("break-all", data_type === "integer" && "text-right")}
|
||||
>
|
||||
{finalValue}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
})) as ColumnDef<any>[];
|
||||
}, [details]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: data?.data ?? [],
|
||||
columns,
|
||||
columnResizeMode: "onChange",
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
rowCount: data?.count ?? 0,
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
},
|
||||
},
|
||||
onPaginationChange: paginationUpdater,
|
||||
manualPagination: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col gap-4 flex-1 max-h-full pb-3"}>
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Rows3 /> {tableName}
|
||||
</h1>
|
||||
<p>
|
||||
Rows: <strong>{data?.count}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border min-h-0 h-full overflow-auto w-full min-w-0">
|
||||
<Table
|
||||
className={"table-fixed min-w-full"}
|
||||
{...{
|
||||
style: {
|
||||
width: table.getCenterTotalSize(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
className={"relative"}
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<div
|
||||
{...{
|
||||
onDoubleClick: () => header.column.resetSize(),
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`,
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
57
frontend/src/components/ui/button.tsx
Normal file
57
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
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",
|
||||
iconSm: "size-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
96
frontend/src/components/ui/data-table-pagination.tsx
Normal file
96
frontend/src/components/ui/data-table-pagination.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui";
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50, 1000].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex text-nowrap items-center justify-end text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/ui/data-table.tsx
Normal file
80
frontend/src/components/ui/data-table.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
frontend/src/components/ui/dropdown-menu.tsx
Normal file
203
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
type HTMLAttributes,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
|
||||
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 = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
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-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
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 bg-popover p-1 text-popover-foreground 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
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-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
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-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-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 = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
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-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-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 = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
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 = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: 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,
|
||||
};
|
||||
7
frontend/src/components/ui/index.ts
Normal file
7
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./button";
|
||||
export * from "./data-table-pagination";
|
||||
export * from "./dropdown-menu";
|
||||
export * from "./mode-toggle";
|
||||
export * from "./select";
|
||||
export * from "./table";
|
||||
export * from "./theme-provider";
|
||||
37
frontend/src/components/ui/mode-toggle.tsx
Normal file
37
frontend/src/components/ui/mode-toggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
useTheme,
|
||||
} from "@/components/ui";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
162
frontend/src/components/ui/select.tsx
Normal file
162
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
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 = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
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 = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
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 = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Content>,
|
||||
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 bg-popover text-popover-foreground 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",
|
||||
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 = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Label>,
|
||||
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 = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Item>,
|
||||
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-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-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 = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Separator>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
120
frontend/src/components/ui/table.tsx
Normal file
120
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type TdHTMLAttributes,
|
||||
type ThHTMLAttributes,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
|
||||
const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = forwardRef<
|
||||
HTMLTableRowElement,
|
||||
HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
71
frontend/src/components/ui/theme-provider.tsx
Normal file
71
frontend/src/components/ui/theme-provider.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'vite-ui-theme',
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
129
frontend/src/index.css
Normal file
129
frontend/src/index.css
Normal file
@@ -0,0 +1,129 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
text-underline-position: under;
|
||||
}
|
||||
.grid-rows-layout {
|
||||
grid-template-rows: 60px 1fr;
|
||||
}
|
||||
|
||||
.grid-cols-layout {
|
||||
grid-template-columns: 264px 1fr;
|
||||
}
|
||||
|
||||
.max-w-layout {
|
||||
max-width: calc(100vw - 264px);
|
||||
}
|
||||
|
||||
.w-layout {
|
||||
width: calc(100vw - 264px);
|
||||
}
|
||||
.resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 5px;
|
||||
background: rgba(100, 100, 100, 0.5);
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.resizer.isResizing {
|
||||
background: rgba(100, 100, 100, 20);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
tr {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.resizer {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
*:hover > .resizer {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.h-layout {
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
24
frontend/src/lib/utils.ts
Normal file
24
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
type Valuable<T> = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] }
|
||||
|
||||
export function getValuable<T extends object, V = Valuable<T>>(obj: T): V {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(
|
||||
([, v]) => !((typeof v === 'string' && !v.length) || v === null || typeof v === 'undefined')
|
||||
)
|
||||
) as V
|
||||
}
|
||||
|
||||
export function prettyBytes(bytes: number): string {
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']
|
||||
if (bytes === 0) return '0 B'
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const size = bytes / Math.pow(1024, i)
|
||||
return `${size.toFixed(2)} ${units[i]}`
|
||||
}
|
||||
36
frontend/src/main.tsx
Normal file
36
frontend/src/main.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { ThemeProvider } from "@/components/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
// Import the generated route tree
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
// Create a new router instance
|
||||
const router = createRouter({ routeTree });
|
||||
// Register the router instance for type safety
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// Render the app
|
||||
const rootElement = document.getElementById("root");
|
||||
if (rootElement && !rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
115
frontend/src/routeTree.gen.ts
Normal file
115
frontend/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/* prettier-ignore-start */
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file is auto-generated by TanStack Router
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as DbDbNameTablesIndexImport } from './routes/db/$dbName/tables/index'
|
||||
import { Route as DbDbNameTablesTableNameIndexImport } from './routes/db/$dbName/tables/$tableName/index'
|
||||
import { Route as DbDbNameTablesTableNameDataImport } from './routes/db/$dbName/tables/$tableName/data'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const DbDbNameTablesIndexRoute = DbDbNameTablesIndexImport.update({
|
||||
path: '/db/$dbName/tables/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const DbDbNameTablesTableNameIndexRoute =
|
||||
DbDbNameTablesTableNameIndexImport.update({
|
||||
path: '/db/$dbName/tables/$tableName/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const DbDbNameTablesTableNameDataRoute =
|
||||
DbDbNameTablesTableNameDataImport.update({
|
||||
path: '/db/$dbName/tables/$tableName/data',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/db/$dbName/tables/': {
|
||||
id: '/db/$dbName/tables/'
|
||||
path: '/db/$dbName/tables'
|
||||
fullPath: '/db/$dbName/tables'
|
||||
preLoaderRoute: typeof DbDbNameTablesIndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/db/$dbName/tables/$tableName/data': {
|
||||
id: '/db/$dbName/tables/$tableName/data'
|
||||
path: '/db/$dbName/tables/$tableName/data'
|
||||
fullPath: '/db/$dbName/tables/$tableName/data'
|
||||
preLoaderRoute: typeof DbDbNameTablesTableNameDataImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/db/$dbName/tables/$tableName/': {
|
||||
id: '/db/$dbName/tables/$tableName/'
|
||||
path: '/db/$dbName/tables/$tableName'
|
||||
fullPath: '/db/$dbName/tables/$tableName'
|
||||
preLoaderRoute: typeof DbDbNameTablesTableNameIndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren({
|
||||
IndexRoute,
|
||||
DbDbNameTablesIndexRoute,
|
||||
DbDbNameTablesTableNameDataRoute,
|
||||
DbDbNameTablesTableNameIndexRoute,
|
||||
})
|
||||
|
||||
/* prettier-ignore-end */
|
||||
|
||||
/* ROUTE_MANIFEST_START
|
||||
{
|
||||
"routes": {
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/db/$dbName/tables/",
|
||||
"/db/$dbName/tables/$tableName/data",
|
||||
"/db/$dbName/tables/$tableName/"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/db/$dbName/tables/": {
|
||||
"filePath": "db/$dbName/tables/index.tsx"
|
||||
},
|
||||
"/db/$dbName/tables/$tableName/data": {
|
||||
"filePath": "db/$dbName/tables/$tableName/data.tsx"
|
||||
},
|
||||
"/db/$dbName/tables/$tableName/": {
|
||||
"filePath": "db/$dbName/tables/$tableName/index.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
ROUTE_MANIFEST_END */
|
||||
128
frontend/src/routes/__root.tsx
Normal file
128
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
ModeToggle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
buttonVariants,
|
||||
} from "@/components/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDatabasesListQuery, useTablesListQuery } from "@/services/db";
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
import { Database, Rows3, Table2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
|
||||
const searchSchema = z.object({
|
||||
dbSchema: z.string().optional().catch(""),
|
||||
});
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root,
|
||||
validateSearch: (search) => searchSchema.parse(search),
|
||||
});
|
||||
|
||||
function Root() {
|
||||
const { data } = useDatabasesListQuery();
|
||||
|
||||
const params = useParams({ strict: false });
|
||||
const dbName = params.dbName ?? "";
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const handleSelectedSchema = (schema: string) => {
|
||||
void navigate({ to: "/db/$dbName/tables", params: { dbName: schema } });
|
||||
};
|
||||
|
||||
const { data: tables } = useTablesListQuery({ dbName });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-screen grid grid-rows-layout grid-cols-layout">
|
||||
<header className="p-2 flex gap-2 border-b items-center col-span-full">
|
||||
<ModeToggle />
|
||||
<Link to="/" className="[&.active]:font-bold">
|
||||
Home
|
||||
</Link>
|
||||
</header>
|
||||
<aside className={"p-3"}>
|
||||
<Select value={dbName} onValueChange={handleSelectedSchema}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Database Schema" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data?.map((schema) => {
|
||||
return (
|
||||
<SelectItem value={schema} key={schema}>
|
||||
{schema}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<nav className="flex flex-col gap-1 mt-4">
|
||||
{dbName && (
|
||||
<Link
|
||||
to={"/db/$dbName/tables"}
|
||||
params={{ dbName }}
|
||||
activeOptions={{ exact: true }}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded py-1.5 pl-1.5",
|
||||
"hover:bg-muted",
|
||||
"[&.active]:bg-muted [&.active]:font-semibold",
|
||||
)}
|
||||
>
|
||||
<Database className={"size-4"} /> {dbName}
|
||||
</Link>
|
||||
)}
|
||||
{tables?.map((table) => {
|
||||
return (
|
||||
<div
|
||||
key={table.table_name}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2.5 rounded py-1.5 justify-between w-full",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className={cn(
|
||||
"w-full flex gap-2 items-center",
|
||||
"hover:underline",
|
||||
"[&.active]:font-semibold",
|
||||
)}
|
||||
to={"/db/$dbName/tables/$tableName"}
|
||||
params={{ tableName: table.table_name, dbName: dbName }}
|
||||
>
|
||||
<Table2 className={"size-4 shrink-0"} />
|
||||
{table.table_name}
|
||||
</Link>
|
||||
<Link
|
||||
className={cn(
|
||||
"hover:underline shrink-0",
|
||||
buttonVariants({ variant: "ghost", size: "iconSm" }),
|
||||
"[&.active]:bg-muted",
|
||||
)}
|
||||
title={"Explore Data"}
|
||||
aria-label={"Explore Data"}
|
||||
to={"/db/$dbName/tables/$tableName/data"}
|
||||
params={{ tableName: table.table_name, dbName: dbName }}
|
||||
search={{ pageIndex: 0, pageSize: 10 }}
|
||||
>
|
||||
<Rows3 className={"size-4 shrink-0"} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<Outlet />
|
||||
</div>
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
frontend/src/routes/db/$dbName/tables/$tableName/data.tsx
Normal file
41
frontend/src/routes/db/$dbName/tables/$tableName/data.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { DataTable } from "@/components/db-table-view/data-table";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
const tableSearchSchema = z.object({
|
||||
pageSize: z.number().catch(10),
|
||||
pageIndex: z.number().catch(0),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/db/$dbName/tables/$tableName/data")({
|
||||
component: TableView,
|
||||
validateSearch: (search) => tableSearchSchema.parse(search),
|
||||
});
|
||||
|
||||
function TableView() {
|
||||
const { tableName, dbName } = Route.useParams();
|
||||
const { pageSize, pageIndex } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const updatePageSize = (value: number) => {
|
||||
return void navigate({
|
||||
search: (prev) => ({ ...prev, pageSize: value, pageIndex: 0 }),
|
||||
});
|
||||
};
|
||||
const updatePageIndex = (pageIndex: number) => {
|
||||
return void navigate({ search: (prev) => ({ ...prev, pageIndex }) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 h-layout w-layout">
|
||||
<DataTable
|
||||
dbName={dbName}
|
||||
tableName={tableName}
|
||||
pageSize={pageSize}
|
||||
pageIndex={pageIndex}
|
||||
onPageIndexChange={updatePageIndex}
|
||||
onPageSizeChange={updatePageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
frontend/src/routes/db/$dbName/tables/$tableName/index.tsx
Normal file
161
frontend/src/routes/db/$dbName/tables/$tableName/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type TableColumn,
|
||||
type TableForeignKey,
|
||||
type TableIndexEntry,
|
||||
useTableColumnsQuery,
|
||||
useTableForeignKeysQuery,
|
||||
useTableIndexesQuery,
|
||||
} from "@/services/db";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { ArrowRight, Table2 } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/db/$dbName/tables/$tableName/")({
|
||||
component: TableDetailsTable,
|
||||
});
|
||||
const columnHelper = createColumnHelper<TableColumn>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor("column_name", {
|
||||
header: "Column",
|
||||
}),
|
||||
columnHelper.accessor("data_type", {
|
||||
header: "Type",
|
||||
}),
|
||||
columnHelper.accessor("udt_name", {
|
||||
header: "UDT",
|
||||
}),
|
||||
columnHelper.accessor("column_comment", {
|
||||
header: "Comment",
|
||||
}),
|
||||
];
|
||||
|
||||
const tableIndexesColumnHelper = createColumnHelper<TableIndexEntry>();
|
||||
|
||||
const tableIndexesColumns = [
|
||||
tableIndexesColumnHelper.accessor("type", {
|
||||
header: "Type",
|
||||
}),
|
||||
tableIndexesColumnHelper.accessor("columns", {
|
||||
header: "Columns",
|
||||
cell: (props) => props.getValue().join(", "),
|
||||
}),
|
||||
tableIndexesColumnHelper.accessor("key", {
|
||||
header: "Key",
|
||||
}),
|
||||
];
|
||||
|
||||
const tableForeignKeysColumnHelper = createColumnHelper<TableForeignKey>();
|
||||
|
||||
const tableForeignKeysColumns = [
|
||||
tableForeignKeysColumnHelper.accessor("source", {
|
||||
header: "Source",
|
||||
cell: (props) => props.getValue().join(", "),
|
||||
}),
|
||||
tableForeignKeysColumnHelper.accessor("target", {
|
||||
header: "Target",
|
||||
cell: (props) => {
|
||||
const { table, target } = props.row.original;
|
||||
return (
|
||||
<Link
|
||||
from={Route.fullPath}
|
||||
to={"../$tableName"}
|
||||
params={{ tableName: table }}
|
||||
className={"hover:underline"}
|
||||
>
|
||||
{table} ({target.join(", ")})
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
}),
|
||||
tableForeignKeysColumnHelper.accessor("on_delete", {
|
||||
header: "On Delete",
|
||||
}),
|
||||
tableForeignKeysColumnHelper.accessor("on_update", {
|
||||
header: "On Update",
|
||||
}),
|
||||
];
|
||||
|
||||
function TableDetailsTable() {
|
||||
const { tableName: name } = Route.useParams();
|
||||
const { data: tableColumns } = useTableColumnsQuery({ name });
|
||||
const { data: tableIndexes } = useTableIndexesQuery({ name });
|
||||
const { data: tableForeignKeys } = useTableForeignKeysQuery({ name });
|
||||
|
||||
return (
|
||||
<div className={"p-3 w-layout"}>
|
||||
<div className={"flex flex-col gap-4 flex-1 max-h-full pb-3"}>
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h1 className={"text-2xl font-bold flex items-center gap-2"}>
|
||||
<Table2 /> {name}
|
||||
</h1>
|
||||
<Link
|
||||
from={Route.fullPath}
|
||||
to={"./data"}
|
||||
search={{
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
}}
|
||||
className={cn("flex gap-2 items-center", "hover:underline")}
|
||||
>
|
||||
Explore data <ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h2 className={"text-xl font-bold flex items-center gap-2"}>
|
||||
Columns
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
|
||||
}
|
||||
>
|
||||
<DataTable columns={columns} data={tableColumns ?? []} />
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h2 className={"text-xl font-bold flex items-center gap-2"}>
|
||||
Indexes
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
|
||||
}
|
||||
>
|
||||
{tableIndexes?.length ? (
|
||||
<DataTable
|
||||
columns={tableIndexesColumns}
|
||||
data={tableIndexes ?? []}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center p-4">No indexes.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h2 className={"text-xl font-bold flex items-center gap-2"}>
|
||||
Foreign Keys
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
|
||||
}
|
||||
>
|
||||
{tableForeignKeys?.length ? (
|
||||
<DataTable
|
||||
columns={tableForeignKeysColumns}
|
||||
data={tableForeignKeys ?? []}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center p-4">No foreign keys.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
frontend/src/routes/db/$dbName/tables/index.tsx
Normal file
271
frontend/src/routes/db/$dbName/tables/index.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui";
|
||||
import { cn, prettyBytes } from "@/lib/utils";
|
||||
import { type TableInfo, useTablesListQuery } from "@/services/db";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
type SortingState,
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { ArrowUp, Database } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/db/$dbName/tables/")({
|
||||
component: Component,
|
||||
});
|
||||
|
||||
const columnHelper = createColumnHelper<TableInfo>();
|
||||
|
||||
const createColumns = (dbName: string) => {
|
||||
return [
|
||||
columnHelper.accessor("table_name", {
|
||||
header: "Name",
|
||||
|
||||
cell: (props) => {
|
||||
const tableName = props.getValue();
|
||||
return (
|
||||
<div className={"flex w-full"}>
|
||||
<Link
|
||||
className={"hover:underline w-full"}
|
||||
to={"/db/$dbName/tables/$tableName"}
|
||||
params={{ dbName, tableName }}
|
||||
>
|
||||
{tableName}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("table_size", {
|
||||
header: "Table Size",
|
||||
cell: (props) => prettyBytes(props.getValue()),
|
||||
meta: {
|
||||
className: "text-end",
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("index_size", {
|
||||
header: "Index Size",
|
||||
cell: (props) => prettyBytes(props.getValue()),
|
||||
meta: {
|
||||
className: "text-end",
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("total_size", {
|
||||
header: "Total Size",
|
||||
cell: (props) => prettyBytes(props.getValue()),
|
||||
meta: {
|
||||
className: "text-end",
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("row_count", {
|
||||
header: "Rows",
|
||||
meta: {
|
||||
className: "text-end",
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("primary_key", {
|
||||
header: "Primary key",
|
||||
}),
|
||||
|
||||
columnHelper.accessor("indexes", {
|
||||
header: "Indexes",
|
||||
cell: (props) => {
|
||||
const indexes = props.getValue();
|
||||
if (!indexes) return null;
|
||||
return (
|
||||
<div>
|
||||
{indexes?.split(",").map((index) => (
|
||||
<div key={index}>{index}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("comments", {
|
||||
header: "Comment",
|
||||
}),
|
||||
|
||||
columnHelper.accessor("schema_name", {
|
||||
header: "Schema",
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const { dbName } = Route.useParams();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const { data } = useTablesListQuery({
|
||||
dbName,
|
||||
sortDesc: sorting[0]?.desc,
|
||||
sortField: sorting[0]?.id,
|
||||
});
|
||||
|
||||
const details = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return data;
|
||||
}, [data]);
|
||||
|
||||
const columns = useMemo(() => createColumns(dbName), [dbName]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: details,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={"p-3 h-layout w-layout"}>
|
||||
<div className={"flex flex-col gap-4 flex-1 max-h-full pb-3"}>
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h1 className={"text-2xl font-bold flex items-center gap-2"}>
|
||||
<Database /> {dbName}
|
||||
</h1>
|
||||
<p>
|
||||
Tables: <strong>{details?.length ?? 0}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
|
||||
}
|
||||
>
|
||||
<Table
|
||||
className={"min-w-full"}
|
||||
{...{
|
||||
style: {
|
||||
width: table.getCenterTotalSize(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const sorted = header.column.getIsSorted();
|
||||
if (header.column.getCanSort()) {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"p-0",
|
||||
header.column.columnDef.meta?.className,
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
title={
|
||||
header.column.getNextSortingOrder() === "asc"
|
||||
? "Sort ascending"
|
||||
: header.column.getNextSortingOrder() === "desc"
|
||||
? "Sort descending"
|
||||
: "Clear sort"
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<ArrowUp
|
||||
className={cn(
|
||||
"ml-2 size-4 opacity-0",
|
||||
sorted && "opacity-100",
|
||||
(sorted as string) === "desc" && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableHead
|
||||
className={cn(
|
||||
"text-nowrap",
|
||||
header.column.columnDef.meta?.className,
|
||||
)}
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-nowrap",
|
||||
cell.column?.columnDef?.meta?.className,
|
||||
)}
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/routes/index.tsx
Normal file
26
frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Index,
|
||||
})
|
||||
|
||||
function Index() {
|
||||
const { dbSchema } = Route.useSearch()
|
||||
if (!dbSchema) {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h3>Welcome Home!</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <TableView dbSchema={dbSchema} />
|
||||
}
|
||||
|
||||
function TableView({ dbSchema }: { dbSchema: string }) {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h3>Table View</h3>
|
||||
{dbSchema}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
frontend/src/services/db/db.hooks.ts
Normal file
64
frontend/src/services/db/db.hooks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { DB_QUERY_KEYS } from "./db.query-keys";
|
||||
import { dbService } from "./db.service";
|
||||
import type { GetTableDataArgs, GetTablesListArgs } from "./db.types";
|
||||
|
||||
export const useDatabasesListQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: [DB_QUERY_KEYS.DATABASES.ALL],
|
||||
queryFn: () => dbService.getDatabasesList(),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTablesListQuery = (args: GetTablesListArgs) => {
|
||||
return useQuery({
|
||||
queryKey: [DB_QUERY_KEYS.TABLES.ALL, args],
|
||||
queryFn: () => dbService.getTablesList(args),
|
||||
enabled: !!args.dbName,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTableDataQuery = (args: GetTableDataArgs) => {
|
||||
return useQuery({
|
||||
queryKey: [DB_QUERY_KEYS.TABLES.DATA, args],
|
||||
queryFn: () => dbService.getTableData(args),
|
||||
placeholderData: (previousData, previousQuery) => {
|
||||
if (
|
||||
typeof previousQuery?.queryKey[1] !== "string" &&
|
||||
(previousQuery?.queryKey[1].dbName !== args.dbName ||
|
||||
previousQuery?.queryKey[1].tableName !== args.tableName)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return previousData;
|
||||
},
|
||||
enabled: !!args.tableName && !!args.dbName,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTableColumnsQuery = (args: { name?: string }) => {
|
||||
return useQuery({
|
||||
queryKey: [DB_QUERY_KEYS.TABLES.COLUMNS, args],
|
||||
queryFn: () => dbService.getTableColumns(args.name ?? ""),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: !!args.name,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTableIndexesQuery = (args: { name?: string }) => {
|
||||
return useQuery({
|
||||
queryKey: [DB_QUERY_KEYS.TABLES.INDEXES, args],
|
||||
queryFn: () => dbService.getTableIndexes(args.name ?? ""),
|
||||
enabled: !!args.name,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTableForeignKeysQuery = (args: { name?: string }) => {
|
||||
return useQuery({
|
||||
queryKey: [DB_QUERY_KEYS.TABLES.FOREIGN_KEYS, args],
|
||||
queryFn: () => dbService.getTableForeignKeys(args.name ?? ""),
|
||||
enabled: !!args.name,
|
||||
});
|
||||
};
|
||||
5
frontend/src/services/db/db.instance.ts
Normal file
5
frontend/src/services/db/db.instance.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import ky from 'ky'
|
||||
|
||||
export const dbInstance = ky.create({
|
||||
prefixUrl: 'http://localhost:3000'
|
||||
})
|
||||
12
frontend/src/services/db/db.query-keys.ts
Normal file
12
frontend/src/services/db/db.query-keys.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const DB_QUERY_KEYS = {
|
||||
DATABASES: {
|
||||
ALL: "databases.all",
|
||||
},
|
||||
TABLES: {
|
||||
ALL: "tables.all",
|
||||
DATA: "tables.data",
|
||||
COLUMNS: "tables.columns",
|
||||
INDEXES: "tables.indexes",
|
||||
FOREIGN_KEYS: "tables.foreign_keys",
|
||||
},
|
||||
} as const;
|
||||
50
frontend/src/services/db/db.service.ts
Normal file
50
frontend/src/services/db/db.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getValuable } from "@/lib/utils";
|
||||
import { dbInstance } from "@/services/db/db.instance";
|
||||
import type {
|
||||
DatabasesResponse,
|
||||
GetTableDataArgs,
|
||||
GetTableDataResponse,
|
||||
GetTablesListArgs,
|
||||
GetTablesListResponse,
|
||||
TableColumns,
|
||||
TableForeignKeys,
|
||||
TableIndexes,
|
||||
} from "@/services/db/db.types";
|
||||
|
||||
class DbService {
|
||||
getDatabasesList() {
|
||||
return dbInstance.get("api/databases").json<DatabasesResponse>();
|
||||
}
|
||||
|
||||
getTablesList({ dbName, sortDesc, sortField }: GetTablesListArgs) {
|
||||
return dbInstance
|
||||
.get(`api/databases/${dbName}/tables`, {
|
||||
searchParams: getValuable({ sortField, sortDesc }),
|
||||
})
|
||||
.json<GetTablesListResponse>();
|
||||
}
|
||||
|
||||
getTableData({ dbName, tableName, page, perPage }: GetTableDataArgs) {
|
||||
return dbInstance
|
||||
.get(`api/databases/${dbName}/tables/${tableName}/data`, {
|
||||
searchParams: getValuable({ perPage, page }),
|
||||
})
|
||||
.json<GetTableDataResponse>();
|
||||
}
|
||||
|
||||
getTableColumns(name: string) {
|
||||
return dbInstance.get(`api/db/tables/${name}/columns`).json<TableColumns>();
|
||||
}
|
||||
|
||||
getTableIndexes(name: string) {
|
||||
return dbInstance.get(`api/db/tables/${name}/indexes`).json<TableIndexes>();
|
||||
}
|
||||
|
||||
getTableForeignKeys(name: string) {
|
||||
return dbInstance
|
||||
.get(`api/db/tables/${name}/foreign-keys`)
|
||||
.json<TableForeignKeys>();
|
||||
}
|
||||
}
|
||||
|
||||
export const dbService = new DbService();
|
||||
64
frontend/src/services/db/db.types.ts
Normal file
64
frontend/src/services/db/db.types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type DatabasesResponse = Array<string>;
|
||||
|
||||
// Tables List
|
||||
export type GetTablesListArgs = {
|
||||
dbName: string;
|
||||
sortField?: string;
|
||||
sortDesc?: boolean;
|
||||
};
|
||||
export type TableInfo = {
|
||||
comments: string;
|
||||
index_size: number;
|
||||
indexes: string;
|
||||
owner: string;
|
||||
primary_key: string;
|
||||
row_count: number;
|
||||
schema_name: string;
|
||||
table_name: string;
|
||||
table_size: number;
|
||||
total_size: number;
|
||||
};
|
||||
export type GetTablesListResponse = TableInfo[];
|
||||
|
||||
// Table Data
|
||||
export type GetTableDataArgs = {
|
||||
tableName: string;
|
||||
dbName: string;
|
||||
perPage?: number;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
export type GetTableDataResponse = {
|
||||
count: number;
|
||||
data: Array<Record<string, any>>;
|
||||
};
|
||||
|
||||
export type TableColumn = {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
udt_name: string;
|
||||
column_comment?: any;
|
||||
};
|
||||
export type TableColumns = TableColumn[];
|
||||
|
||||
export type TableIndexEntry = {
|
||||
key: string;
|
||||
type: string;
|
||||
columns: string[];
|
||||
descs: any[];
|
||||
lengths: any[];
|
||||
};
|
||||
export type TableIndexes = TableIndexEntry[];
|
||||
|
||||
export type TableForeignKey = {
|
||||
conname: string;
|
||||
deferrable: boolean;
|
||||
definition: string;
|
||||
source: string[];
|
||||
ns: string;
|
||||
table: string;
|
||||
target: string[];
|
||||
on_delete: string;
|
||||
on_update: string;
|
||||
};
|
||||
export type TableForeignKeys = TableForeignKey[];
|
||||
2
frontend/src/services/db/index.ts
Normal file
2
frontend/src/services/db/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './db.hooks'
|
||||
export * from './db.types'
|
||||
8
frontend/src/tanstack-table-typedef.ts
Normal file
8
frontend/src/tanstack-table-typedef.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { RowData } from '@tanstack/react-table'
|
||||
|
||||
declare module '@tanstack/table-core' {
|
||||
// @ts-expect-error - this is a global module augmentation
|
||||
interface ColumnMeta<TData extends RowData> {
|
||||
className?: string
|
||||
}
|
||||
}
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
77
frontend/tailwind.config.js
Normal file
77
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
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")],
|
||||
}
|
||||
27
frontend/tsconfig.app.json
Normal file
27
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
frontend/tsconfig.json
Normal file
11
frontend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
frontend/tsconfig.node.json
Normal file
13
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
import * as path from 'node:path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite(), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user