commit cc7d7d71b9009dbac8b6c87803e5dea8f70b1868 Author: andres Date: Sun Jul 7 00:48:39 2024 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..fb94ae7 --- /dev/null +++ b/api/.gitignore @@ -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 \ No newline at end of file diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..bebf061 --- /dev/null +++ b/api/README.md @@ -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. diff --git a/api/biome.json b/api/biome.json new file mode 100644 index 0000000..ec7a9f9 --- /dev/null +++ b/api/biome.json @@ -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 + } + } +} diff --git a/api/bun.lockb b/api/bun.lockb new file mode 100644 index 0000000..4478f52 Binary files /dev/null and b/api/bun.lockb differ diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..255bb08 --- /dev/null +++ b/api/package.json @@ -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" +} diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..fa33b4a --- /dev/null +++ b/api/src/index.ts @@ -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); +} diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..2ca47bb --- /dev/null +++ b/api/tsconfig.json @@ -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 ''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. */ + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..0d6babe --- /dev/null +++ b/frontend/README.md @@ -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 diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..7b092ce --- /dev/null +++ b/frontend/biome.json @@ -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 + } + } +} diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100644 index 0000000..e9442af Binary files /dev/null and b/frontend/bun.lockb differ diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..f7ee37b --- /dev/null +++ b/frontend/components.json @@ -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" + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..82aa064 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + OpenStudio + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bd58e03 --- /dev/null +++ b/frontend/package.json @@ -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" +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/db-table-view/data-table.tsx b/frontend/src/components/db-table-view/data-table.tsx new file mode 100644 index 0000000..fee3541 --- /dev/null +++ b/frontend/src/components/db-table-view/data-table.tsx @@ -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 = (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[]; + + return details.map(({ column_name, udt_name, data_type }) => ({ + accessorKey: column_name, + title: column_name, + size: 300, + header: () => { + return ( +
+ {`${column_name} [${udt_name.toUpperCase()}]`} +
+ ); + }, + 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 ( + +
+ {value} + {isImage && ( + {"preview"} + )} +
+
+ ); + } + return ( +
+ {finalValue} +
+ ); + }, + })) as ColumnDef[]; + }, [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 ( +
+
+

+ {tableName} +

+

+ Rows: {data?.count} +

+
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
header.column.resetSize(), + onMouseDown: header.getResizeHandler(), + onTouchStart: header.getResizeHandler(), + className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`, + }} + /> + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ); +}; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..cc5b148 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean; +} + +const Button = forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/data-table-pagination.tsx b/frontend/src/components/ui/data-table-pagination.tsx new file mode 100644 index 0000000..7633ca2 --- /dev/null +++ b/frontend/src/components/ui/data-table-pagination.tsx @@ -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 { + table: Table; +} + +export function DataTablePagination({ + table, +}: DataTablePaginationProps) { + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx new file mode 100644 index 0000000..5a135e4 --- /dev/null +++ b/frontend/src/components/ui/data-table.tsx @@ -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 { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..709f0aa --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -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, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..de391d7 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -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"; diff --git a/frontend/src/components/ui/mode-toggle.tsx b/frontend/src/components/ui/mode-toggle.tsx new file mode 100644 index 0000000..75fd84b --- /dev/null +++ b/frontend/src/components/ui/mode-toggle.tsx @@ -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 ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..3cfdb52 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -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, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..2ad2e0b --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,120 @@ +import { cn } from "@/lib/utils"; +import { + type HTMLAttributes, + type TdHTMLAttributes, + type ThHTMLAttributes, + forwardRef, +} from "react"; + +const Table = forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ), +); +Table.displayName = "Table"; + +const TableHeader = forwardRef< + HTMLTableSectionElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = forwardRef< + HTMLTableSectionElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = forwardRef< + HTMLTableSectionElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = forwardRef< + HTMLTableRowElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = forwardRef< + HTMLTableCellElement, + ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = forwardRef< + HTMLTableCellElement, + TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = forwardRef< + HTMLTableCaptionElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/frontend/src/components/ui/theme-provider.tsx b/frontend/src/components/ui/theme-provider.tsx new file mode 100644 index 0000000..4847d98 --- /dev/null +++ b/frontend/src/components/ui/theme-provider.tsx @@ -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(initialState) + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'vite-ui-theme', + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (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 ( + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + + if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider') + + return context +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..8862324 --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..b19bf57 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -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 = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] } + +export function getValuable>(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]}` +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ed8319f --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + + + + , + ); +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..4f8a674 --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -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 */ diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..88ea91f --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -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 ( + <> +
+
+ + + Home + +
+ + +
+ + + ); +} diff --git a/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx b/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx new file mode 100644 index 0000000..765ee41 --- /dev/null +++ b/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/src/routes/db/$dbName/tables/$tableName/index.tsx b/frontend/src/routes/db/$dbName/tables/$tableName/index.tsx new file mode 100644 index 0000000..853ea1c --- /dev/null +++ b/frontend/src/routes/db/$dbName/tables/$tableName/index.tsx @@ -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(); + +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(); + +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(); + +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 ( + + {table} ({target.join(", ")}) + + ); + }, + }), + 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 ( +
+
+
+

+ {name} +

+ + Explore data + +
+ +
+

+ Columns +

+
+
+ +
+ +
+

+ Indexes +

+
+
+ {tableIndexes?.length ? ( + + ) : ( +
No indexes.
+ )} +
+
+

+ Foreign Keys +

+
+
+ {tableForeignKeys?.length ? ( + + ) : ( +
No foreign keys.
+ )} +
+
+
+ ); +} diff --git a/frontend/src/routes/db/$dbName/tables/index.tsx b/frontend/src/routes/db/$dbName/tables/index.tsx new file mode 100644 index 0000000..68c3401 --- /dev/null +++ b/frontend/src/routes/db/$dbName/tables/index.tsx @@ -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(); + +const createColumns = (dbName: string) => { + return [ + columnHelper.accessor("table_name", { + header: "Name", + + cell: (props) => { + const tableName = props.getValue(); + return ( +
+ + {tableName} + +
+ ); + }, + }), + + 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 ( +
+ {indexes?.split(",").map((index) => ( +
{index}
+ ))} +
+ ); + }, + }), + + columnHelper.accessor("comments", { + header: "Comment", + }), + + columnHelper.accessor("schema_name", { + header: "Schema", + }), + ]; +}; + +function Component() { + const { dbName } = Route.useParams(); + const [sorting, setSorting] = useState([]); + + 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 ( +
+
+
+

+ {dbName} +

+

+ Tables: {details?.length ?? 0} +

+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const sorted = header.column.getIsSorted(); + if (header.column.getCanSort()) { + return ( + + + + ); + } + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx new file mode 100644 index 0000000..076c0d3 --- /dev/null +++ b/frontend/src/routes/index.tsx @@ -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 ( +
+

Welcome Home!

+
+ ) + } + return +} + +function TableView({ dbSchema }: { dbSchema: string }) { + return ( +
+

Table View

+ {dbSchema} +
+ ) +} diff --git a/frontend/src/services/db/db.hooks.ts b/frontend/src/services/db/db.hooks.ts new file mode 100644 index 0000000..88cdc03 --- /dev/null +++ b/frontend/src/services/db/db.hooks.ts @@ -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, + }); +}; diff --git a/frontend/src/services/db/db.instance.ts b/frontend/src/services/db/db.instance.ts new file mode 100644 index 0000000..14b827c --- /dev/null +++ b/frontend/src/services/db/db.instance.ts @@ -0,0 +1,5 @@ +import ky from 'ky' + +export const dbInstance = ky.create({ + prefixUrl: 'http://localhost:3000' +}) diff --git a/frontend/src/services/db/db.query-keys.ts b/frontend/src/services/db/db.query-keys.ts new file mode 100644 index 0000000..536ae7b --- /dev/null +++ b/frontend/src/services/db/db.query-keys.ts @@ -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; diff --git a/frontend/src/services/db/db.service.ts b/frontend/src/services/db/db.service.ts new file mode 100644 index 0000000..439e31d --- /dev/null +++ b/frontend/src/services/db/db.service.ts @@ -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(); + } + + getTablesList({ dbName, sortDesc, sortField }: GetTablesListArgs) { + return dbInstance + .get(`api/databases/${dbName}/tables`, { + searchParams: getValuable({ sortField, sortDesc }), + }) + .json(); + } + + getTableData({ dbName, tableName, page, perPage }: GetTableDataArgs) { + return dbInstance + .get(`api/databases/${dbName}/tables/${tableName}/data`, { + searchParams: getValuable({ perPage, page }), + }) + .json(); + } + + getTableColumns(name: string) { + return dbInstance.get(`api/db/tables/${name}/columns`).json(); + } + + getTableIndexes(name: string) { + return dbInstance.get(`api/db/tables/${name}/indexes`).json(); + } + + getTableForeignKeys(name: string) { + return dbInstance + .get(`api/db/tables/${name}/foreign-keys`) + .json(); + } +} + +export const dbService = new DbService(); diff --git a/frontend/src/services/db/db.types.ts b/frontend/src/services/db/db.types.ts new file mode 100644 index 0000000..e714f4e --- /dev/null +++ b/frontend/src/services/db/db.types.ts @@ -0,0 +1,64 @@ +export type DatabasesResponse = Array; + +// 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>; +}; + +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[]; diff --git a/frontend/src/services/db/index.ts b/frontend/src/services/db/index.ts new file mode 100644 index 0000000..68a92d5 --- /dev/null +++ b/frontend/src/services/db/index.ts @@ -0,0 +1,2 @@ +export * from './db.hooks' +export * from './db.types' diff --git a/frontend/src/tanstack-table-typedef.ts b/frontend/src/tanstack-table-typedef.ts new file mode 100644 index 0000000..e0f0500 --- /dev/null +++ b/frontend/src/tanstack-table-typedef.ts @@ -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 { + className?: string + } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..ed77210 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..7cb7e37 --- /dev/null +++ b/frontend/tailwind.config.js @@ -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")], +} \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..16db678 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -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"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..ea9d0cd --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..3afdd6e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..0d4c7f1 --- /dev/null +++ b/frontend/vite.config.ts @@ -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'), + }, + }, +})