initial commit

This commit is contained in:
2024-07-07 00:48:39 +02:00
commit cc7d7d71b9
49 changed files with 3017 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.idea

44
api/.gitignore vendored Normal file
View 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
View 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
View 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

Binary file not shown.

20
api/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

17
frontend/components.json Normal file
View File

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

13
frontend/index.html Normal file
View 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
View 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"
}

View File

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

1
frontend/public/vite.svg Normal file
View 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

View 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>
);
};

View 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 };

View 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>
);
}

View 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>
);
}

View 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,
};

View 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";

View 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>
);
}

View 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,
};

View 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,
};

View 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
View 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
View 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
View 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>,
);
}

View 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 */

View 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 />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)
}

View 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,
});
};

View File

@@ -0,0 +1,5 @@
import ky from 'ky'
export const dbInstance = ky.create({
prefixUrl: 'http://localhost:3000'
})

View 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;

View 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();

View 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[];

View File

@@ -0,0 +1,2 @@
export * from './db.hooks'
export * from './db.types'

View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

View 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")],
}

View 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
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

View 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
View 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'),
},
},
})