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