42 Commits

Author SHA1 Message Date
KarimTamani e169b84ade Add UUID V4 to PostgreSQL 2026-08-05 23:42:16 +01:00
Tamani Karim 994c797195 Merge pull request #8 from albertoarena/test/automated-tests
test: add automated test suite (Vitest) for the SQL import/render pipeline
2026-08-05 23:01:43 +01:00
Alberto Arena 42e2bcf5e9 test: add round-trip tests and CI workflow
Add the Phase 4 import -> render -> import round-trip: for each of the six
dialects, parse a CREATE TABLE + foreign key schema into a model, render it
back to DDL, parse the rendered DDL again, and assert the two models are
equal. Comparison is on a normalized model (table and column names, resolved
type names, key/constraint flags, relationships), not raw SQL, since
formatting and identifier quoting legitimately differ. A small in-memory
adapter assembles the DatabaseType the renderer consumes from the importer
output, which the app normally reconstructs via the database.

Primary keys are canonicalized to non-nullable in the comparison: SQL Server
emits its primary key as a table-level constraint and the importer only
forces NOT NULL for inline primary keys, so the flag would otherwise differ
on the round trip although the schemas are equivalent.

Add a GitHub Actions workflow running npm ci + npm test on pushes to main
and on pull requests. Typecheck and lint are intentionally left out for now:
the current codebase does not pass a strict project typecheck or a clean
lint, so gating on them would fail CI on pre-existing, unrelated issues.
2026-08-05 08:13:50 +02:00
Alberto Arena 91fe5d6cf2 test: add Phase 3 SQL render tests for all dialects
Cover getRenderer().renderDDL across MySQL, MariaDB, PostgreSQL, SQLite,
Oracle and SQL Server. A shared DatabaseType fixture (users + posts with a
primary key, auto-increment, NOT NULL UNIQUE column with a length, DEFAULT
and a posts to users foreign key) is rendered per dialect, asserting the
emitted DDL creates both tables and columns and carries the primary key,
unique constraint, dialect-specific auto-increment spelling (AUTO_INCREMENT,
SERIAL, AUTOINCREMENT, IDENTITY), variable-length text type, DEFAULT value
and the foreign key with ON DELETE CASCADE.

The fixture is built from the seed data types so field type ids hydrate the
way the app hydrates them, and embeds relationship source/target objects
because the SQLite renderer orders tables from the raw database before the
migration step re-hydrates. Assertions match quote-agnostic patterns rather
than exact strings, since identifier quoting and formatting differ by dialect.
2026-08-05 07:26:31 +02:00
Alberto Arena 585f5c7a7f test: add Phase 2 SQL import tests for all dialects
Cover getImporter().parseSql across MySQL, MariaDB, PostgreSQL, SQLite,
Oracle and SQL Server. Each dialect gets a representative CREATE TABLE +
foreign key + index fixture, asserting table and column counts, primary
key, NOT NULL, UNIQUE and DEFAULT parsing, data-type mapping, and the
captured foreign key (direction, cardinality and ON DELETE action). Also
parse the bundled PostgreSQL dump as a realistic case and verify malformed
input surfaces errors without dropping valid tables or throwing.

Data-type fixtures are built from the seed arrays via the same transform
the app uses in seedDataTypes, so tests run without booting the WASM
SQLite database. The parser is a WASM module whose init() is async; the
importer constructor does not await it, so the suite awaits init() once in
beforeAll, after which every synchronous parseSql call resolves.
2026-08-05 06:30:49 +02:00
Alberto Arena d0cd2d8fa4 test: add Vitest and Phase 1 pure-helper suite
Introduce automated testing to a project that had no test runner, no
test script and no CI. Vitest fits the existing Vite setup with near-zero
config; a standalone vitest.config.ts wires the @/ alias via
vite-tsconfig-paths and runs in the node environment, avoiding the app's
Tailwind/React/WASM plugins that pure-logic tests do not need.

Cover the cheapest, highest-value surface first: pure helpers with no DOM,
WASM or database. getNextSequence and cloneField (field.ts), the
charset/collation and SQLite integer-column reordering plus enum naming
(render-uttils.ts), and orderTables including the CircularDependencyError
cycle path that backs the Foreign Key Cycle Detection feature.

The build's tsc step is unaffected (plain tsc no-ops on the root config)
and the new files typecheck clean under strict and lint clean.
2026-08-05 06:24:18 +02:00
Tamani Karim fab51f2d13 Merge pull request #7 from albertoarena/build/build-oom-heap-flag
PR approved!

Thank you, @albertoarena , for your contribution! We really appreciate your support.
2026-08-04 17:46:11 +01:00
Alberto Arena fe84e0d748 build: raise Node heap for production build
The production build renders thousands of modules and exceeds V8's
default heap ceiling, so `npm run build` fails with a JavaScript
heap out-of-memory error during "rendering chunks". Set
NODE_OPTIONS=--max-old-space-size=8192 on the Vite build step via
cross-env (kept portable for Windows), so a fresh clone builds
without any manual environment setup. Document the matching Docker
memory requirement in DOCKER.md, since the container build hits the
same limit when the Docker VM has too little RAM.
2026-08-04 18:06:11 +02:00
Tamani Karim ae9e052cf6 Merge pull request #6 from albertoarena/docs/fix-readme-docker-docs
docs: fix README typos and Docker Compose syntax
2026-08-04 16:30:10 +01:00
Alberto Arena 557387a4fd docs: fix README typos and Docker Compose syntax
Correct the "Welcome" heading typo and reword "Index Suggestions"
in the feature list. The intro now lists all six exportable SQL
dialects (adding Oracle and SQL Server) so it matches the
Supported Databases section. Add the port 3000 dev-server URL to
the local setup instructions, and switch the Docker Compose
commands in README and DOCKER.md from the deprecated V1
"docker-compose" to the current V2 "docker compose" syntax.
2026-08-04 17:25:52 +02:00
KarimTamani 4a2f6ae8f5 add COOP/COEP headers to nginx config to fix macOS database crashes 2026-06-30 16:12:15 +01:00
KarimTamani e8838d1f1e Update the README image 2026-06-10 22:34:07 +01:00
Tamani Karim c1ae2dbf69 New Dialects 2026-06-10 22:16:08 +01:00
KarimTamani eb6c80417c 1.4.0 2026-06-10 22:12:23 +01:00
KarimTamani 5cce8cc03d 1.3.3 2026-06-10 22:12:05 +01:00
KarimTamani caa277577a Improved UI/UX + Lazy Loading 2026-06-10 21:43:46 +01:00
KarimTamani 6c3b431ee2 Add Oracle & MSSQL Support 2026-06-10 18:09:54 +01:00
dependabot[bot] 3985e53886 Bump rollup from 4.52.2 to 4.60.1
Bumps [rollup](https://github.com/rollup/rollup) from 4.52.2 to 4.60.1.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.52.2...v4.60.1)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.60.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 21:33:30 +01:00
dependabot[bot] ef27393d9a Bump the npm_and_yarn group across 1 directory with 9 updates
Bumps the npm_and_yarn group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [lodash](https://github.com/lodash/lodash) | `4.17.21` | `4.18.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `5.4.20` | `5.4.21` |
| [minimatch](https://github.com/isaacs/minimatch) | `3.1.2` | `3.1.5` |
| [esbuild](https://github.com/evanw/esbuild) | `0.21.5` | `removed` |
| [flatted](https://github.com/WebReflection/flatted) | `3.3.3` | `3.4.2` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.1.0` | `4.1.1` |
| [picomatch](https://github.com/micromatch/picomatch) | `2.3.1` | `2.3.2` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `6.23.0` | `6.30.3` |
| [tar](https://github.com/isaacs/node-tar) | `7.5.1` | `7.5.13` |



Updates `lodash` from 4.17.21 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1)

Updates `vite` from 5.4.20 to 5.4.21
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.21/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.21/packages/vite)

Updates `minimatch` from 3.1.2 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Removes `esbuild`

Updates `flatted` from 3.3.3 to 3.4.2
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

Updates `js-yaml` from 4.1.0 to 4.1.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

Updates `react-router` from 6.23.0 to 6.30.3
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router@6.30.3/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@6.30.3/packages/react-router)

Updates `tar` from 7.5.1 to 7.5.13
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.1...v7.5.13)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 5.4.21
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: esbuild
  dependency-version: 
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: react-router
  dependency-version: 6.30.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: tar
  dependency-version: 7.5.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 21:28:31 +01:00
Tamani Karim 9feb0397bc Merge pull request #4 from stackrender/copilot/dockerize-the-application
Copilot/dockerize the application

“Merge Docker build improvements and memory fix into main
2026-03-29 16:18:18 +01:00
KarimTamani 7e7f7c7397 Increase Node memory to prevent out-of-memory errors during build 2026-03-29 16:08:59 +01:00
KarimTamani e342ffa831 Merge remote-tracking branch 'origin/main' into copilot/dockerize-the-application 2026-03-29 14:22:50 +01:00
KarimTamani f1656fafcd Updated Reade.me 2026-03-29 14:06:22 +01:00
KarimTamani caca671b36 Add View docs link in import database dialog 2026-03-29 14:04:49 +01:00
Tamani Karim 1b1c8ebdf6 Merge pull request #3 from stackrender/KarimTamani-patch-1
Add docs link
2026-03-28 19:25:40 +01:00
Tamani Karim c31ab6423b Add docs link 2026-03-28 19:25:21 +01:00
KarimTamani aa3688a0e6 Improve performance and accessibility; add documentation 2026-03-28 19:22:59 +01:00
KarimTamani 39317143b1 Update Discord link & Security contaact email 2026-02-09 22:11:29 +01:00
Tamani Karim 14db841617 Merge pull request #1 from stackrender/uiux-improvements
V1.0.2
2026-01-01 15:52:47 +01:00
KarimTamani 1d78d05468 V1.0.2 2026-01-01 15:51:17 +01:00
KarimTamani d9e5333f96 Mobile Responsivity 2026-01-01 15:49:48 +01:00
copilot-swe-agent[bot] f68b0c97bf Add comprehensive Docker documentation
Co-authored-by: oovaa <103980029+oovaa@users.noreply.github.com>
2025-11-17 08:23:23 +00:00
copilot-swe-agent[bot] 42699038ad Remove obsolete version field from docker-compose.yml
Co-authored-by: oovaa <103980029+oovaa@users.noreply.github.com>
2025-11-17 08:21:29 +00:00
copilot-swe-agent[bot] f594b4e938 Add Docker support with Dockerfile, docker-compose, and nginx config
Co-authored-by: oovaa <103980029+oovaa@users.noreply.github.com>
2025-11-17 08:17:58 +00:00
copilot-swe-agent[bot] f6511df039 Initial exploration and planning for dockerization
Co-authored-by: oovaa <103980029+oovaa@users.noreply.github.com>
2025-11-17 07:41:02 +00:00
copilot-swe-agent[bot] 9f6b61643f Initial plan 2025-11-17 07:36:56 +00:00
KarimTamani 7c443f0edf Release V1.0.1 2025-09-27 18:19:21 +01:00
KarimTamani 6bb10b86f2 Merging UI/UX Improvements with the main branch 2025-09-26 20:52:12 +01:00
KarimTamani 1348262649 Redesign the app using ShadCn UI + new feature (order tables) 2025-09-26 20:30:57 +01:00
KarimTamani c226adfb27 Edit : make <create_database_modal> default CTA create empty diagram 2025-09-01 15:28:46 +01:00
KarimTamani 0e82e5b965 Test branch protection rules 2025-08-22 17:30:12 +01:00
KarimTamani cd9861bfd8 Add Spaces 2025-08-22 17:27:56 +01:00
216 changed files with 22251 additions and 15528 deletions
+53
View File
@@ -0,0 +1,53 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build outputs
dist
dist-ssr
*.local
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Editor directories and files
.vscode
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose.yml
# Misc
README.md
LICENSE
CODE_OF_CONDUCT.md
CONTRIBUTING.md
SECURITY.md
# Lock files (we keep package-lock.json but ignore others)
pnpm-lock.yaml
yarn.lock
bun.lockb
# Vite cache
vite.config.ts.timestamp-*
+18
View File
@@ -0,0 +1,18 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
tamanikarim50@gmail.com. contact@stackrender.io.
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the
+2 -2
View File
@@ -27,10 +27,10 @@ If you find a bug:
If you have an idea for improvement: If you have an idea for improvement:
- Open a **feature request** in the issues section. - Open a **feature request** in the issues section.
- Describe the feature and explain why it would be useful. - Describe the feature and explain why it would be useful.
- You may also discuss ideas with the community on [Discord](https://discord.gg/DsN8RcPR6Y). - You may also discuss ideas with the community on [Discord](https://discord.gg/4dv26jR4Pj).
## License Agreement ## License Agreement
By contributing, you agree that your work will be licensed under the **GNU Affero General Public License v3.0**. By contributing, you agree that your work will be licensed under the **GNU Affero General Public License v3.0**.
## Questions or Support ## Questions or Support
If you have questions about the contribution process, open a discussion on GitHub or reach out on our [Discord](https://discord.gg/DsN8RcPR6Y). If you have questions about the contribution process, open a discussion on GitHub or reach out on our [Discord](https://discord.gg/4dv26jR4Pj).
+168
View File
@@ -0,0 +1,168 @@
# Docker Deployment Guide
This guide explains how to run StackRender using Docker.
## Quick Start
### Using Docker Compose (Recommended)
The easiest way to run StackRender:
```bash
docker compose up -d
```
The application will be available at `http://localhost:8080`
To stop the application:
```bash
docker compose down
```
### Using Docker CLI
Build the image:
```bash
docker build -t stackrender .
```
Run the container:
```bash
docker run -d -p 8080:80 --name stackrender stackrender
```
Stop and remove the container:
```bash
docker stop stackrender
docker rm stackrender
```
## Configuration
### Port Mapping
By default, the application runs on port 80 inside the container and is mapped to port 8080 on the host. You can change this in `docker-compose.yml`:
```yaml
ports:
- "YOUR_PORT:80"
```
Or when using Docker CLI:
```bash
docker run -d -p YOUR_PORT:80 --name stackrender stackrender
```
### Health Check
The Docker Compose configuration includes a health check that verifies the application is responding correctly:
```yaml
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
## Architecture
### Multi-Stage Build
The Dockerfile uses a multi-stage build approach:
1. **Builder Stage**: Uses Node.js 20 to install dependencies and build the application
2. **Production Stage**: Uses Nginx Alpine to serve the static files
This approach minimizes the final image size by excluding build tools and dependencies.
### Nginx Configuration
The application uses a custom Nginx configuration (`nginx.conf`) that:
- Enables gzip compression for better performance
- Sets security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection)
- Handles client-side routing (React Router)
- Configures caching for static assets
## Troubleshooting
### Build Issues
If you encounter SSL certificate issues during the build, the Dockerfile includes a workaround:
```dockerfile
RUN npm config set strict-ssl false && npm install
```
This is necessary in some build environments with certificate validation issues.
### Out of Memory During Build
The production build is memory-intensive (the app compiles thousands of modules). If `docker compose up` or `docker build` fails with an out-of-memory error such as `Reached heap limit` or `cannot allocate memory` during the `npm run build` step, increase the memory available to Docker:
- **Docker Desktop**: Settings, then Resources, then Memory. Set it to at least **6-8 GB** and apply.
The Dockerfile raises the Node heap (`NODE_OPTIONS=--max-old-space-size`) for the build. That heap size must fit inside the memory available to Docker, so raising the Docker memory limit is the fix when the build runs out of memory.
### Container Not Starting
Check the logs:
```bash
docker logs stackrender
```
Or with Docker Compose:
```bash
docker compose logs
```
### Port Already in Use
If port 8080 is already in use, you can change it in `docker-compose.yml` or use a different port with Docker CLI:
```bash
docker run -d -p 8081:80 --name stackrender stackrender
```
## Development
For development, it's recommended to run the application directly with Node.js instead of Docker:
```bash
npm install
npm run dev
```
Docker is primarily intended for production deployments or testing the production build locally.
## Production Deployment
For production deployments:
1. Build the image:
```bash
docker build -t stackrender:production .
```
2. Push to your container registry:
```bash
docker tag stackrender:production your-registry/stackrender:latest
docker push your-registry/stackrender:latest
```
3. Deploy using your orchestration tool (Kubernetes, Docker Swarm, etc.)
## Additional Resources
- [Docker Documentation](https://docs.docker.com/)
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Nginx Documentation](https://nginx.org/en/docs/)
+36
View File
@@ -0,0 +1,36 @@
# Build stage - using standard debian-based node image for better compatibility
FROM node:20 AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
# Note: strict-ssl is disabled to handle potential certificate issues in some build environments
RUN npm config set strict-ssl false && npm install
# Copy source files
COPY . .
# Increase Node memory to prevent out-of-memory errors during build
ENV NODE_OPTIONS=--max-old-space-size=4096
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+36 -15
View File
@@ -1,12 +1,12 @@
![App Screenshot](https://github.com/stackrender/.github/blob/main/assets/white_hero.PNG?raw=true) ![App Screenshot](https://github.com/stackrender/.github/blob/main/assets/app_screenshot.png?raw=true)
<h4 align="center"> <h4 align="center">
<a href="https://www.stackrender.io"> <a href="https://www.stackrender.io">
<img src="https://img.shields.io/badge/Start%20Building!-gray.svg?logo=data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjRkZENzAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTMgMyBMMiAxMyBoNyBMMTEgMjEgTDIyIDExIGgtNyBMIDEzIDMgeiIvPjwvc3ZnPg==" alt="Start Building!" /> <img src="https://img.shields.io/badge/Start%20Building!-gray.svg?logo=data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjRkZENzAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTMgMyBMMiAxMyBoNyBMMTEgMjEgTDIyIDExIGgtNyBMIDEzIDMgeiIvPjwvc3ZnPg==" alt="Start Building!" />
</a> </a>
<a href="https://discord.com/invite/DsN8RcPR6Y"> <a href="https://discord.com/invite/4dv26jR4Pj">
<img src="https://img.shields.io/discord/1352085267535761448?color=5865F2&label=Discord&logo=discord&logoColor=white" alt="Discord community channel" /> <img src="https://img.shields.io/discord/1352085267535761448?color=5865F2&label=Discord&logo=discord&logoColor=white" alt="Discord community channel" />
</a> </a>
<a href="https://x.com/intent/follow?screen_name=Iam_The_Dev"> <a href="https://x.com/intent/follow?screen_name=Iam_The_Dev">
@@ -15,16 +15,16 @@
</h4> </h4>
# Welcom to StackRender ⚡ # Welcome to StackRender ⚡
StackRender was born from the need to automate backend development, covering everything from the database to the final API endpoint. Our first step toward this long-term vision is to provide a **next-generation, free, open-source database schema diagram generator**. StackRender was born from the need to automate backend development, covering everything from the database to the final API endpoint. Our first step toward this long-term vision is to provide a **next-generation, free, open-source database schema diagram generator**.
StackRender helps you go from **specifications** to a **fully functional, production-ready database** that can be exported in your preferred SQL dialect: **MySQL, PostgreSQL, MariaDB, or SQLite**. StackRender helps you go from **specifications** to a **fully functional, production-ready database** that can be exported in your preferred SQL dialect: **MySQL, PostgreSQL, MariaDB, SQLite, Oracle, or SQL Server**.
## Main features ## Main features
- **Interactive Diagram UI** Visually design and manage your database schemas with an intuitive drag-and-drop interface. - **Interactive Diagram UI** Visually design and manage your database schemas with an intuitive drag-and-drop interface.
- **In-Depth Tables & Columns Control** Fully customize tables, columns, types, and constraints. - **In-Depth Tables & Columns Control** Fully customize tables, columns, types, and constraints.
- **Indices Suggestions** Receive recommendations to optimize database performance. - **Index Suggestions** Receive recommendations to optimize database performance.
- **Import / Export SQL DDL** Easily import existing schemas or export your design as SQL scripts. - **Import / Export SQL DDL** Easily import existing schemas or export your design as SQL scripts.
- **Foreign Key Cycle Detection** Identify and resolve circular dependencies in relationships. - **Foreign Key Cycle Detection** Identify and resolve circular dependencies in relationships.
- **AI-Powered Database Assistant** (Cloud version) Generate database diagrams from specifications and perform additional operations such as schema enrichment, soft-delete implementation, and automatic documentation generation. - **AI-Powered Database Assistant** (Cloud version) Generate database diagrams from specifications and perform additional operations such as schema enrichment, soft-delete implementation, and automatic documentation generation.
@@ -38,25 +38,46 @@ StackRender is currently in **Public Beta**. Star and watch this repository to g
- ✅ PostgreSQL - ✅ PostgreSQL
- ✅ MySQL - ✅ MySQL
- ✅ MariaDB - ✅ MariaDB
- ✅ SQLite - ✅ SQLite
- ✅ Oracle
- ✅ SQL Server (MSSQL)
And more coming very soon!
## Get Started ## Get Started
Use the [cloud version](https://www.stackrender.io) or deploy locally to start designing your database schemas in minutes. Use the [cloud version](https://www.stackrender.io) or deploy locally to start designing your database schemas in minutes.
### How to Use ### How to Use Locally
#### Using Docker (Recommended)
The easiest way to run StackRender locally is using Docker:
```bash
# Build and run using Docker Compose
docker compose up
# Or build and run using Docker directly
docker build -t stackrender .
docker run -p 8080:80 stackrender
```
Then visit `http://localhost:8080` in your browser.
#### Using Node.js
Install dependencies and start the development server: Install dependencies and start the development server:
```text ```bash
npm install npm install
npm run dev npm run dev
``` ```
Then visit `http://localhost:3000` in your browser.
### How to Build ### How to Build
Install dependencies and start the production build: Install dependencies and create a production build:
```text ```bash
npm install npm install
npm run start npm run build
``` ```
## Try Using It in the Cloud ## Try Using It in the Cloud
1. Visit [StackRender.io](https://www.stackrender.io) 1. Visit [StackRender.io](https://www.stackrender.io)
@@ -67,8 +88,8 @@ npm run start
6. Export your Database SQL DDL and run it! 6. Export your Database SQL DDL and run it!
## 🤝 Community ## 🤝 Community
- [**Docs**](https://www.stackrender.io/docs) Explore the StackRender docs to learn more about its features.
- [**Discord**](https://discord.com/invite/DsN8RcPR6Y) Live discussions about upcoming releases and the future of StackRender. - [**Discord**](https://discord.com/invite/4dv26jR4Pj) Live discussions about upcoming releases and the future of StackRender.
- [**GitHub Issues**](https://github.com/stackrender/stackrender/issues) Report bugs and errors to help us improve your experience. - [**GitHub Issues**](https://github.com/stackrender/stackrender/issues) Report bugs and errors to help us improve your experience.
- [**X (Twitter)**](https://x.com/intent/follow?screen_name=Iam_The_Dev) Get the latest StackRender news and updates. - [**X (Twitter)**](https://x.com/intent/follow?screen_name=Iam_The_Dev) Get the latest StackRender news and updates.
@@ -76,7 +97,7 @@ npm run start
We welcome all contributions, whether small bug fixes or major feature additions. We welcome all contributions, whether small bug fixes or major feature additions.
- Discuss your ideas and contributions in our [Discord](https://discord.com/invite/DsN8RcPR6Y) community. - Discuss your ideas and contributions in our [Discord](https://discord.com/invite/4dv26jR4Pj) community.
- Follow the [Contributing Guide](./CONTRIBUTING.md) to get started. - Follow the [Contributing Guide](./CONTRIBUTING.md) to get started.
- Agree to the [Code of Conduct](./CODE_OF_CONDUCT.md) to ensure a positive and respectful environment. - Agree to the [Code of Conduct](./CODE_OF_CONDUCT.md) to ensure a positive and respectful environment.
+1 -1
View File
@@ -11,7 +11,7 @@ We currently provide security updates for the latest stable release of StackRend
## Reporting a Vulnerability ## Reporting a Vulnerability
If you discover a security vulnerability in StackRender, please report it to us directly. If you discover a security vulnerability in StackRender, please report it to us directly.
- Email: security@stackrender.com - Email: contact@stackrender.io
- GitHub Security Advisory: Use the "Report a vulnerability" option in the repository's Security tab. - GitHub Security Advisory: Use the "Report a vulnerability" option in the repository's Security tab.
When reporting, please include: When reporting, please include:
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks",
"providers": "@/providers"
},
"registries": {}
}
+18
View File
@@ -0,0 +1,18 @@
services:
stackrender:
build:
context: .
dockerfile: Dockerfile
image: stackrender:latest
container_name: stackrender
ports:
- "8080:80"
restart: unless-stopped
environment:
- NODE_ENV=production
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
+31 -26
View File
@@ -1,28 +1,33 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" /> <head>
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<title>StackRender Studio</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta key="title" content="StackRender Studio" property="og:title" /> <title>StackRender Studio</title>
<meta <meta key="title" content="StackRender Studio" property="og:title" />
content="StackRender is an AI-powered tool that helps you design and edit database diagrams effortlessly with a smooth, beautiful UI. Generate clean SQL DDL for PostgreSQL, MySQL, MariaDB, and SQLite in seconds." <meta
property="og:description" content="StackRender is an AI-powered tool that helps you design and edit database diagrams effortlessly with a smooth, beautiful UI. Generate clean SQL DDL for PostgreSQL, MySQL, MariaDB, and SQLite in seconds."
/> property="og:description" />
<meta <meta
content="StackRender is an AI-powered tool that helps you design and edit database diagrams effortlessly with a smooth, beautiful UI. Generate clean SQL DDL for PostgreSQL, MySQL, MariaDB, and SQLite in seconds." content="StackRender is an AI-powered tool that helps you design and edit database diagrams effortlessly with a smooth, beautiful UI. Generate clean SQL DDL for PostgreSQL, MySQL, MariaDB, and SQLite in seconds."
name="description" name="description" />
/> <meta key="viewport"
<meta content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
key="viewport" name="viewport" />
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" <link href="/favicon.png" rel="icon" sizes="32x32" />
name="viewport" <link rel="preconnect" href="https://fonts.googleapis.com" />
/> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="/favicon.png" rel="icon" sizes="32x32" /> <link
</head> href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Manrope:wght@200..800&display=swap"
<body> rel="stylesheet" />
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script> </head>
</body>
</html> <body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Putting these at the server level ensures BOTH index.html AND your cached .js/.wasm assets use them!
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|wasm)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
}
}
+4737 -8912
View File
File diff suppressed because it is too large Load Diff
+47 -17
View File
@@ -1,31 +1,54 @@
{ {
"name": "vite-template", "name": "StackRender",
"private": true, "private": true,
"version": "0.0.0", "version": "1.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build",
"lint": "eslint -c .eslintrc.json ./src/**/**/*.{ts,tsx} --fix", "lint": "eslint -c .eslintrc.json ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-sql": "^6.9.0", "@codemirror/lang-sql": "^6.9.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@heroui/react": "^2.7.6", "@guanmingchiu/sqlparser-ts": "^0.61.1",
"@internationalized/date": "^3.8.2", "@internationalized/date": "^3.8.2",
"@nextui-org/react": "^2.6.11", "@nextui-org/react": "^2.6.11",
"@powersync/drizzle-driver": "^0.4.0", "@powersync/drizzle-driver": "^0.4.0",
"@powersync/react": "^1.5.3", "@powersync/react": "^1.5.3",
"@powersync/web": "^1.20.0", "@powersync/web": "^1.20.0",
"@radix-ui/react-tooltip": "^1.2.3", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-aria/visually-hidden": "3.8.21", "@react-aria/visually-hidden": "3.8.21",
"@react-types/shared": "3.28.0", "@react-types/shared": "3.28.0",
"@reecelucas/react-use-hotkeys": "^2.0.0", "@reecelucas/react-use-hotkeys": "^2.0.0",
"@types/react-resizable": "^3.0.8", "@tabler/icons-react": "^3.34.1",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.12",
"@uiw/react-codemirror": "^4.23.12", "@uiw/react-codemirror": "^4.23.12",
"@xyflow/react": "^12.6.3", "@xyflow/react": "^12.6.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"drizzle-orm": "^0.43.1", "drizzle-orm": "^0.43.1",
@@ -33,29 +56,32 @@
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"framer-motion": "11.15.0", "framer-motion": "11.15.0",
"i18next-browser-languagedetector": "^8.0.5", "i18next-browser-languagedetector": "^8.0.5",
"lodash": "^4.17.21", "lodash": "^4.18.1",
"lucide-react": "^0.501.0", "lucide-react": "^0.501.0",
"next-themes": "^0.4.6",
"node-sql-parser": "^5.3.9", "node-sql-parser": "^5.3.9",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"pgsql-ast-parser": "^12.0.1", "pgsql-ast-parser": "^12.0.1",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"react": "18.3.1", "react": "18.3.1",
"react-day-picker": "^9.10.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-resizable": "^3.0.5", "react-resizable-panels": "^2.1.9",
"react-router-dom": "6.23.0", "react-router-dom": "6.30.3",
"react-tag-input": "^6.10.6", "react-tag-input": "^6.10.6",
"sonner": "^2.0.7",
"sql-formatter": "^15.6.3", "sql-formatter": "^15.6.3",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.3.1",
"tailwind-variants": "0.3.0", "tailwind-variants": "0.3.0",
"tailwindcss": "3.4.16", "tailwindcss": "^4.1.13",
"tailwindcss-animate": "^1.0.7",
"use-undo": "^1.1.1", "use-undo": "^1.1.1",
"uuid": "^11.1.0" "uuid": "^11.1.0",
"vaul": "^1.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "20.5.7", "@types/node": "20.19.0",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/pluralize": "^0.0.33", "@types/pluralize": "^0.0.33",
"@types/react": "18.3.3", "@types/react": "18.3.3",
@@ -64,6 +90,8 @@
"@typescript-eslint/parser": "8.11.0", "@typescript-eslint/parser": "8.11.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "10.4.19", "autoprefixer": "10.4.19",
"cross-env": "^7.0.3",
"esbuild": "^0.28.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
@@ -73,11 +101,13 @@
"eslint-plugin-react": "^7.23.2", "eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-unused-imports": "4.1.4", "eslint-plugin-unused-imports": "4.1.4",
"postcss": "8.4.38",
"prettier": "3.3.3", "prettier": "3.3.3",
"rollup": "^4.61.1",
"tw-animate-css": "^1.3.6",
"typescript": "5.6.3", "typescript": "5.6.3",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-top-level-await": "^1.5.0",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2",
"vitest": "^2.1.9"
} }
} }
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 75 KiB

+1 -11
View File
@@ -1,38 +1,28 @@
import "@/styles/globals.css" import "@/styles/globals.css"
import { TooltipProvider } from "./components/tooltip/tooltip";
import { ReactFlowProvider } from "@xyflow/react"; import { ReactFlowProvider } from "@xyflow/react";
import useAppRoutes from "./routes/app-route"; import useAppRoutes from "./routes/app-route";
import { SyncProvider } from "./providers/sync-provider/sync-provider"; import { SyncProvider } from "./providers/sync-provider/sync-provider";
import DatabaseProvider from "./providers/database-provider/database-provider"; import DatabaseProvider from "./providers/database-provider/database-provider";
import DiagramProvider from "./providers/diagram-provider/diagram-provider"; import DiagramProvider from "./providers/diagram-provider/diagram-provider";
import { ToastProvider } from "@heroui/react";
import { ModalProvider } from "./providers/modal-provider/modal-provider"; import { ModalProvider } from "./providers/modal-provider/modal-provider";
import DatabaseHotkeysProvider from "./providers/database-hotkeys/database-hotkeys-provider"; import DatabaseHotkeysProvider from "./providers/database-hotkeys/database-hotkeys-provider";
function App() { function App() {
const appRoutes = useAppRoutes(); const appRoutes = useAppRoutes();
return ( return (
<> <>
<ToastProvider placement="bottom-right" />
<SyncProvider> <SyncProvider>
<ReactFlowProvider> <ReactFlowProvider>
<DatabaseProvider> <DatabaseProvider>
<DiagramProvider> <DiagramProvider>
<TooltipProvider delayDuration={0}>
<ModalProvider> <ModalProvider>
<DatabaseHotkeysProvider> <DatabaseHotkeysProvider>
{appRoutes} {appRoutes}
</DatabaseHotkeysProvider> </DatabaseHotkeysProvider>
</ModalProvider> </ModalProvider>
</TooltipProvider>
</DiagramProvider> </DiagramProvider>
</DatabaseProvider> </DatabaseProvider>
</ReactFlowProvider> </ReactFlowProvider>
@@ -1,107 +0,0 @@
import { Autocomplete as HeroUiAutocomplete, AutocompleteItem, AutocompleteSection } from "@heroui/react";
import { Key, useState } from "react";
interface AutocompleteProps {
items?: any[]
label?: string,
defaultSelection?: string | undefined
onSelectionChange?: (item: any) => void,
placeholder?: string
isDisabled?: boolean
selectedItem?: Key,
grouped?: boolean
}
const headingClasses =
"flex w-full py-1.5 px-2 bg-default shadow-md border-1 font-medium rounded-md dark:border-font/5 dark:bg-background-100";
const Autocomplete: React.FC<AutocompleteProps> = ({ items, label = "name", onSelectionChange, defaultSelection, placeholder, isDisabled, selectedItem, grouped = false }) => {
const onItemChange = (item: Key | null) => {
onSelectionChange && onSelectionChange(item);
}
if (!grouped)
return (
<HeroUiAutocomplete
radius="sm"
isDisabled={isDisabled}
defaultItems={items || []}
size="sm"
onSelectionChange={onItemChange}
defaultSelectedKey={defaultSelection as any}
variant="bordered"
aria-label={placeholder}
placeholder={placeholder}
selectedKey={selectedItem as any}
className="h-8 w-full focus-visible:ring-0 shadow-none "
classNames={{
clearButton: "text-icon",
selectorButton: "text-icon",
popoverContent: "rounded-md "
}}
inputProps={{
classNames: {
inputWrapper: " border-divider group-hover:border-primary group-data-[focus=true]:border-primary",
},
}}
>
{(item: any) => <AutocompleteItem key={item.id}>{item[label]}</AutocompleteItem>}
</HeroUiAutocomplete>
)
if (grouped) {
return <HeroUiAutocomplete
radius="sm"
isDisabled={isDisabled}
size="sm"
onSelectionChange={onItemChange}
defaultSelectedKey={defaultSelection as any}
variant="bordered"
aria-label={placeholder}
placeholder={placeholder}
className="h-8 w-full focus-visible:ring-0 shadow-none "
selectedKey={selectedItem as any}
classNames={{
clearButton: "text-icon",
selectorButton: "text-icon",
popoverContent: "rounded-md "
}}
inputProps={{
classNames: {
inputWrapper: "border-divider group-hover:border-primary group-data-[focus=true]:border-primary",
},
}}
>
{
items ? Object.keys(items).map((key: string) => (
<AutocompleteSection
key={key.toUpperCase()}
classNames={{
heading: headingClasses,
}}
title={key.toUpperCase()}
>
{
items[key as any].map((item: any) => (
<AutocompleteItem key={item.id}>{item[label]}</AutocompleteItem>
))
}
</AutocompleteSection>
)) : []
}
</HeroUiAutocomplete>
}
}
export default Autocomplete;
@@ -1,37 +0,0 @@
import { DatabaseType } from "@/lib/database"
import { Checkbox, Image } from "@heroui/react";
interface DatabaseCheckboxProps {
database: DatabaseType,
}
const DatabaseCheckbox: React.FC<DatabaseCheckboxProps> = ({ database }) => {
return (
<Checkbox
aria-label={database.name}
value={database.dialect}
className="database-checkbox"
classNames={{
base: "flex min-w-[128px] min-h-[128px] max-w-[128px] max-h-[128px] hover:bg-default rounded-md relative data-[selected=true]:border-1 data-[selected=true]:border-primary data-[selected=true]:bg-primary/5",
wrapper: "absolute top-2 left-2 before:border-divider group-data-[hover=true]:before:bg-default " ,
label : "flex items-center justify-center w-full h-full p-2 "
}}
>
<div className="min-w-full h-full flex items-center justify-center ">
<Image
src={database.logo}
className="w-full rounded-none"
/>
</div>
</Checkbox>
)
}
export default DatabaseCheckbox;
@@ -1,54 +0,0 @@
import { Checkbox, Chip, cn, useCheckbox } from "@heroui/react";
import { useTheme } from "next-themes";
import React from "react";
interface OptionCheckboxProps {
value: string;
label: string;
icon?: React.ReactNode;
logo?: string;
isSelected?: boolean;
}
const OptionCheckbox: React.FC<OptionCheckboxProps> = (props) => {
const { value, icon, logo, label, isSelected } = props;
let variant : any = isSelected ? {
variant: "solid",
color: "default"
} : {
variant: "borderd",
color: "default"
}
return (
<Checkbox
aria-label={value}
value={value}
size="sm"
className="option-checkbox"
classNames={{
wrapper: "hidden",
label: "flex items-center justify-center w-full h-full "
}}
>
<Chip radius="sm" {...variant as any} className={cn("dark:bg-background px-3 h-9 border-1 transition-all duration-300 border-divider text-font/90 ",
!isSelected ? "dark:bg-default" : undefined
)}
avatar={logo ? <img src={logo} height={12} /> : undefined}
startContent={
icon
}
>
<span className="text-xs font-medium ">
{label}
</span>
</Chip>
</Checkbox>
)
}
export default React.memo(OptionCheckbox);
@@ -1,8 +1,14 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip/tooltip";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@heroui/react";
import { Copy, CopyCheck } from "lucide-react"; import { Copy, CopyCheck } from "lucide-react";
import { Tooltip , TooltipContent, TooltipTrigger} from "./ui/tooltip";
import { Button } from "./ui/button";
@@ -17,6 +23,8 @@ const Clipboard: React.FC<ClipboardProps> = ({ text }) => {
const [isCopied, setIsCopied] = useState<boolean>(false); const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = useCallback(async () => { const copyToClipboard = useCallback(async () => {
if (text) if (text)
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@@ -33,15 +41,14 @@ const Clipboard: React.FC<ClipboardProps> = ({ text }) => {
}, [text]) }, [text])
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger asChild>
<span> <span>
<Button <Button
size="sm" size="icon"
isIconOnly variant="outline"
variant="bordered" className="w-7 h-7 text-muted-foreground bg-card/50 backdrop-blur-xs dark:backdrop-blur-md shadow-lg"
className="text-font/90 border-1 border-divider" onClick={copyToClipboard}
onPressEnd={copyToClipboard}
> >
{ {
!isCopied ? <Copy className="size-4" /> : <CopyCheck className="size-4" /> !isCopied ? <Copy className="size-4" /> : <CopyCheck className="size-4" />
+130
View File
@@ -0,0 +1,130 @@
import React, { useEffect, useState } from "react";
import { useTheme } from "@/providers/theme-provider/theme-provider";
import { cn } from "@/lib/utils";
import { Spinner } from "./ui/shadcn-io/spinner";
interface CodeEditorProps {
defaultValue?: string;
value?: string;
className?: string | undefined,
readOnly?: boolean,
onChange?: (sql: string) => void
}
const CodeEditor: React.FC<CodeEditorProps> = ({
defaultValue,
value,
className,
readOnly = false,
onChange
}) => {
const [editor, setEditor] = useState<any>(null);
useEffect(() => {
let mounted = true;
Promise.all([
import("@uiw/react-codemirror"),
import("@codemirror/lang-sql"),
import("@codemirror/view"),
]).then(([cm, sqlLang, view]) => {
if (!mounted) return;
const overrideDarkTheme = view.EditorView.theme({
'.cm-content': {
backgroundColor: "#1c2025",
},
".cm-gutter": {
backgroundColor: "#1c2025",
},
".cm-gutterElement": {
color: "#4b515a"
},
".ͼp": {
color: "#A994FF"
},
".cm-line .ͼq": {
color: "#ff6363"
},
".ͼu": {
color: "#B6E672"
},
".ͼv": {
color: "#6cdcc4"
}
}, { dark: true });
const overrideLightTheme = view.EditorView.theme({
".ͼb": {
color: "#2A1D66"
},
".cm-gutterElement": {
color: "#62748e"
},
".cm-gutter": {
backgroundColor: "white",
},
".cm-gutters": {
borderColor: "#f2f4f6"
},
".cm-line": {
color: "#0f172b"
},
})
setEditor({
CodeMirror: cm.default,
oneDark: cm.oneDark,
sql: sqlLang.sql,
overrideDarkTheme,
overrideLightTheme,
});
});
return () => {
mounted = false;
};
}, []);
const { theme } = useTheme();
if (!editor) {
return (
<div
className={cn(
"flex flex-1 w-full min-h-9 rounded-sm border border-border bg-card items-center justify-center",
className
)}
>
<Spinner className="text-primary" />
</div>
);
}
const { CodeMirror, oneDark, sql, overrideDarkTheme, overrideLightTheme } = editor;
return (
<CodeMirror
defaultValue={defaultValue}
value={value}
className={cn("flex flex-1 w-full min-h-9 rounded-sm h-full bg-card border-1 border-border !min-w-0 overflow-hidden", className)}
extensions={[sql()]}
readOnly={readOnly}
theme={theme != "dark" ? overrideLightTheme : [oneDark, overrideDarkTheme]}
onChange={onChange}
/>
)
}
export default React.memo(CodeEditor);
@@ -1,9 +1,10 @@
import { colorOptions } from "@/lib/colors"; import { colorOptions } from "@/lib/colors";
import { Button, Popover, PopoverContent, PopoverTrigger } from "@heroui/react";
import { Slash } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip/tooltip";
import { useTranslation } from "react-i18next"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Button } from "./ui/button";
import { IconSlash } from "@tabler/icons-react";
interface ColorPickerProps { interface ColorPickerProps {
defaultColor?: string; defaultColor?: string;
@@ -15,11 +16,12 @@ interface ColorPickerProps {
const ColorPicker: React.FC<ColorPickerProps> = ({ defaultColor, onChange }) => { const ColorPicker: React.FC<ColorPickerProps> = ({ defaultColor, onChange }) => {
const [color, setColor] = useState<string | undefined>(defaultColor as string); const [color, setColor] = useState<string | undefined>(defaultColor as string);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
setColor(defaultColor) ; setColor(defaultColor);
} , [defaultColor]) }, [defaultColor])
const onSelect = useCallback((color: string | undefined) => { const onSelect = useCallback((color: string | undefined) => {
setIsOpen(false); setIsOpen(false);
@@ -28,6 +30,44 @@ const ColorPicker: React.FC<ColorPickerProps> = ({ defaultColor, onChange }) =>
}, [onChange]) }, [onChange])
return ( return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild >
<Button
size="sm"
className="size-7 cursor-pointer rounded-md border-1 transition-shadow hover:shadow-md border-border rounded-sm bg-secondary hover:bg-secondary"
style={{
backgroundColor: color ? color : undefined
}}
>
</Button>
</PopoverTrigger>
<PopoverContent className="p-2 w-fit">
<div className="grid grid-cols-4 gap-2">
{colorOptions.map(color => (
<div
key={color}
className="size-7 cursor-pointer rounded-sm border-1 transition-shadow hover:shadow-md border-border"
style={{
backgroundColor: color
}}
onClick={() => onSelect(color)}
>
</div>
))}
<div
key={undefined}
className="size-7 cursor-pointer rounded-sm border-2 border-border transition-shadow hover:shadow-md bg-background dark:bg-secondary"
onClick={() => onSelect(undefined)}
>
<IconSlash className="size-8 text-destructive -ml-1 -mt-1" />
</div>
</div>
</PopoverContent>
</Popover>
/*
<Popover placement="bottom" radius="sm" isOpen={isOpen} onOpenChange={(open) => setIsOpen(open)} shadow="sm"> <Popover placement="bottom" radius="sm" isOpen={isOpen} onOpenChange={(open) => setIsOpen(open)} shadow="sm">
<PopoverTrigger> <PopoverTrigger>
<Button size="sm" <Button size="sm"
@@ -41,42 +81,10 @@ const ColorPicker: React.FC<ColorPickerProps> = ({ defaultColor, onChange }) =>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent>
<div className="grid grid-cols-4 gap-2">
{colorOptions.map(color => (
<div
key={color}
className="size-8 cursor-pointer rounded-md border-2 transition-shadow hover:shadow-md border-divider"
style={{
backgroundColor: color
}}
onClick={() => onSelect(color)}
>
</div>
))}
<Tooltip>
<TooltipTrigger>
<div
key={undefined}
className="size-8 cursor-pointer rounded-md border-2 border-muted transition-shadow hover:shadow-md dark:border-default-100"
style={{
backgroundColor: "white"
}}
onClick={() => onSelect(undefined)}
>
<Slash className="size-full text-danger-500" />
</div>
</TooltipTrigger>
<TooltipContent>
{t("color_picker.default_color")}
</TooltipContent>
</Tooltip>
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
*/
) )
} }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface ComboboxProps {
items: any[]
label?: string;
placeholder?: string;
selectedItem?: string;
onSelectionChange?: (id: string) => void;
className?: string;
isDisabled?: boolean
}
export const Combobox = (props: ComboboxProps) => {
const { items, label = "label", placeholder, onSelectionChange, selectedItem, className, isDisabled } = props;
const [query, setQuery] = React.useState<string>("");
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState<string | undefined>("")
React.useEffect(() => {
setValue(selectedItem);
}, [selectedItem]);
React.useEffect(() => {
onSelectionChange && onSelectionChange(value as string);
}, [value])
React.useEffect(() => {
if (open)
setQuery("");
}, [open])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild disabled={isDisabled}>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("justify-between", className)}
>
<label className="truncate">
{
value
? items.find((item) => item.id === value)?.[label]
: (placeholder ? placeholder : "Select items...")}
</label>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command
filter={(value, search) => {
return 1
}}
>
<CommandInput placeholder={placeholder ? placeholder : "Select items..."}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
{items.filter((item: any) => (item[label] as string).toLocaleLowerCase().includes(query.toLocaleLowerCase())).map((item) => (
<CommandItem
key={item.id}
value={item.id}
onSelect={(currentValue) => {
setValue(currentValue);
setOpen(false)
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === item.id ? "opacity-100" : "opacity-0"
)}
/>
{item[label]}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+105
View File
@@ -0,0 +1,105 @@
import React from 'react'
import {
IconArrowRightDashed,
IconChevronRight,
IconDeviceLaptop,
IconMoon,
IconSun,
} from '@tabler/icons-react'
import { useSearch } from '@/providers/search-provider/search-provider'
import { useTheme } from '@/providers/theme-provider/theme-provider'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { useSidebarData } from '@/components/layout/app-sidebar/data/sidebar-data'
import { ScrollArea } from './ui/scroll-area'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
export function CommandMenu() {
const navigate = useNavigate()
const { setTheme } = useTheme()
const { open, setOpen } = useSearch()
const runCommand = React.useCallback(
(command: () => unknown) => {
setOpen(false)
command()
},
[setOpen]
)
const { t } = useTranslation() ;
const sidebarData = useSidebarData();
return (
<CommandDialog modal open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t("navbar.command")} />
<CommandList>
<ScrollArea type='hover' className='h-72 pr-1'>
<CommandEmpty>No results found.</CommandEmpty>
{sidebarData.navGroups.map((group: any) => (
<CommandGroup key={group.title} heading={group.title}>
{group.items.map((navItem: any, i: number) => {
if (navItem.url)
return (
<CommandItem
key={`${navItem.url}-${i}`}
value={navItem.title}
onSelect={() => {
runCommand(() => navigate(navItem.url))
}}
>
<div className='mr-2 flex h-4 w-4 items-center justify-center'>
<IconArrowRightDashed className='text-muted-foreground/80 size-2' />
</div>
{navItem.title}
</CommandItem>
)
return navItem.items?.map((subItem: any, i: number) => (
<CommandItem
key={`${navItem.title}-${subItem.url}-${i}`}
value={`${navItem.title}-${subItem.url}`}
onSelect={() => {
runCommand(() => navigate(subItem.url))
}}
>
<div className='mr-2 flex h-4 w-4 items-center justify-center'>
<IconArrowRightDashed className='text-muted-foreground/80 size-2' />
</div>
{navItem.title} <IconChevronRight /> {subItem.title}
</CommandItem>
))
})}
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup heading='Theme'>
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
<IconSun /> <span>{t("menu.light")}</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
<IconMoon className='scale-90' />
<span>{t("menu.dark")}</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('system'))}>
<IconDeviceLaptop />
<span>{t("menu.system")}</span>
</CommandItem>
</CommandGroup>
</ScrollArea>
</CommandList>
</CommandDialog>
)
}
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface DatePickerProps {
value?: Date | undefined;
onValueChange?: (value?: Date) => void
}
const DatePicker: React.FC<DatePickerProps> = ({value, onValueChange}) => {
const [open, setOpen] = React.useState(false)
const [date, setDate] = React.useState<Date | undefined>(value)
React.useEffect(() => {
onValueChange && onValueChange(date)
}, [date])
return (
<div className="flex flex-col gap-3">
<Label htmlFor="date" className="px-1">
Date of birth
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date"
className="w-full justify-between font-normal"
>
{date ? date.toLocaleDateString() : "Select date"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={date}
captionLayout="dropdown"
onSelect={(date) => {
setDate(date)
setOpen(false)
}}
/>
</PopoverContent>
</Popover>
</div>
)
}
export { DatePicker }
@@ -1,28 +1,17 @@
import { Image } from "@heroui/react";
import { useTheme } from "next-themes";
interface EmptyListProps { interface EmptyListProps {
title?: string; title?: string;
description?: string; description?: string;
} }
const EmptyList: React.FC<EmptyListProps> = ({ title, description }) => { const EmptyList: React.FC<EmptyListProps> = ({ title, description }) => {
return ( return (
<div className="flex flex-col items-center gap-1 h-full pt-[86px] main-linear-background rounded-lg"> <div className="flex flex-col items-center gap-1 h-full pt-[86px] main-linear-background rounded-lg">
{ {
<Image <img
width={64} width={64}
src={"/stackrender.png"} src={"/stackrender.png"}
/> />
} }
{ {
@@ -32,7 +21,6 @@ const EmptyList: React.FC<EmptyListProps> = ({ title, description }) => {
</h1> </h1>
} }
{ {
description && description &&
<p className="text-sm text-font/70"> <p className="text-sm text-font/70">
{description} {description}
+95
View File
@@ -0,0 +1,95 @@
import React, { useCallback } from 'react'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
import Menu from './layout/menu/menu'
import { Search } from './search'
import { Button } from './ui/button'
//import { ProfileDropdown } from './profile-dropdown'
import { useModal } from '@/providers/modal-provider/modal-provider'
import { Modals } from '@/providers/modal-provider/modal-contxet'
import { useTranslation } from 'react-i18next'
import { ThemeSwitch } from './theme-switch'
import RenameDb from '@/features/database/components/rename-db'
import LanguagesDropdown from './languages-dropdown'
import { useIsMobile } from '@/hooks/use-mobile'
interface HeaderProps extends React.HTMLAttributes<HTMLElement> {
fixed?: boolean
ref?: React.Ref<HTMLElement>
}
export const Header = ({
className,
fixed,
children,
...props
}: HeaderProps) => {
const [offset, setOffset] = React.useState(0)
React.useEffect(() => {
const onScroll = () => {
setOffset(document.body.scrollTop || document.documentElement.scrollTop)
}
// Add scroll listener to the body
document.addEventListener('scroll', onScroll, { passive: true })
// Clean up the event listener on unmount
return () => document.removeEventListener('scroll', onScroll)
}, []);
const { t } = useTranslation();
const isMobile = useIsMobile();
return (
<header
className={cn(
'bg-background flex-col md:flex items-center gap-3 p-2 sm:gap-4 ',
fixed && 'header-fixed peer/header fixed z-50 w-[inherit] rounded-md ',
offset > 10 && fixed ? 'shadow-sm' : 'shadow-none',
className
)}
{...props}
>
<div className='grid grid-cols-3 items-center w-full '>
<div className="justify-self-start flex h-8 gap-3 items-center ">
<SidebarTrigger variant='outline' className='scale-125 sm:scale-100' />
<Separator orientation='vertical' className='h-6' />
{!isMobile && <Menu />}
</div>
<div className=" justify-self-center ">
<Search className='hidden lg:flex' placeholder={t("navbar.search")} />
{
isMobile &&
<div className='w-full'>
<RenameDb />
</div>
}
</div>
<div className="justify-self-end flex gap-2">
{
!isMobile &&
<div className='w-full'>
<RenameDb />
</div>
}
<ThemeSwitch />
<LanguagesDropdown />
</div>
</div>
{isMobile && <div className='flex justify-center'>
<Menu />
</div>
}
</header>
)
}
Header.displayName = 'Header'
+90
View File
@@ -0,0 +1,90 @@
// input-tags.tsx
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
type InputTagsProps = Omit<React.ComponentProps<"input">, "value" | "onChange"> & {
value: string[];
onChange: React.Dispatch<React.SetStateAction<string[]>>;
};
const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
({ className, value, onChange, ...props }, ref) => {
const [pendingDataPoint, setPendingDataPoint] = React.useState("");
React.useEffect(() => {
if (pendingDataPoint.includes(",")) {
const newDataPoints = new Set([
...value,
...pendingDataPoint.split(",").map((chunk) => chunk.trim()),
]);
onChange(Array.from(newDataPoints));
setPendingDataPoint("");
}
}, [pendingDataPoint, onChange, value]);
const addPendingDataPoint = () => {
if (pendingDataPoint) {
const newDataPoints = new Set([...value, pendingDataPoint]);
onChange(Array.from(newDataPoints));
setPendingDataPoint("");
}
};
return (
<div
className={cn(
// caveat: :has() variant requires tailwind v3.4 or above: https://tailwindcss.com/blog/tailwindcss-v3-4#new-has-variant
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-offset-2 dark:has-[:focus-visible]:ring-neutral-300 min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-border dark:bg-background ",
className
)}
>
{value.map((item) => (
<Badge key={item} variant="secondary">
{item}
<Button
variant="ghost"
size="icon"
className="ml-2 h-3 w-3"
onClick={() => {
onChange(value.filter((i) => i !== item));
}}
>
<XIcon className="w-3" />
</Button>
</Badge>
))}
<input
className="flex-1 outline-none "
value={pendingDataPoint}
onChange={(e) => setPendingDataPoint(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addPendingDataPoint();
} else if (
e.key === "Backspace" &&
pendingDataPoint.length === 0 &&
value.length > 0
) {
e.preventDefault();
onChange(value.slice(0, -1));
}
}}
{...props}
ref={ref}
/>
</div>
);
}
);
InputTags.displayName = "InputTags";
export { InputTags };
+84
View File
@@ -0,0 +1,84 @@
import { languages } from "@/i18";
import { Language } from "@/i18/types";
import { Check } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Button } from "./ui/button";
import { IconLanguage } from "@tabler/icons-react";
const LanguagesDropDonw: React.FC = ({ }) => {
const { t, i18n } = useTranslation();
const [currentLanguage, setCurrentLanguage] = useState<Language>(() => languages.find((language: Language) => language.code == i18n.language) as Language);
const changeLanguage = useCallback((language: Language) => {
i18n.changeLanguage(language.code);
}, [i18n])
useEffect(() => {
setCurrentLanguage(languages.find((language: Language) => language.code == i18n.language) as Language)
}, [i18n.language])
return (
<Popover>
<PopoverTrigger asChild>
<Button
size={"icon"}
variant={"outline"}
>
<IconLanguage className="size-5"/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-60 p-1 rounded-md">
<Command className="rounded-lg border shadow-md ">
<CommandInput
aria-label={t("navbar.search")}
placeholder={`${t("navbar.search")}`}
/>
<CommandList>
<CommandEmpty>No language found.</CommandEmpty>
{
languages.map((language: Language) => (
<CommandItem className="flex justify-between cursor-pointer" key={language.code} onSelect={() => changeLanguage(language)}>
<span>{language.name} <span className="text-muted-foreground">({language.nativeName})</span></span>
<CommandShortcut>
{
currentLanguage.code == language.code && <Check className="text-icon size-4" />
}
</CommandShortcut>
</CommandItem>
))
}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export default React.memo(LanguagesDropDonw);
@@ -0,0 +1,60 @@
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from '@/components/ui/sidebar'
import { NavGroup } from '../nav-group'
import { useSidebarData } from './data/sidebar-data'
import { Separator } from '@/components/ui/separator';
import { useDiagramOps } from '@/providers/diagram-provider/diagram-provider';
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const sidebarData = useSidebarData();
const { openController } = useDiagramOps();
return (
<Sidebar collapsible='icon' {...props} >
<SidebarHeader>
<div className='flex items-center gap-2'>
<img
className='w-8 p-[4px] rounded-md '
src='/stackrender.png'
/>
<h3 className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground truncate font-semibold text-sm '>
StackRender
</h3>
</div>
</SidebarHeader>
<SidebarContent>
{sidebarData.navGroups.map((props: any, index: number) => (
<div key={props.title}>
<NavGroup key={props.title} {...props} onClick={() => openController(true)} />
{
index != sidebarData.navGroups.length - 1 &&
<div className='px-2'>
<Separator />
</div>
}
</div>
))}
</SidebarContent>
<SidebarFooter className='p-0'>
{sidebarData.footerNavGroups.map((props: any, index: number) => (
<div key={props.title}>
<NavGroup key={props.title} {...props} />
{
index != sidebarData.navGroups.length - 1 &&
<div className='px-2'>
<Separator />
</div>
}
</div>
))}
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}
@@ -0,0 +1,64 @@
import {
IconBrandDiscord,
IconBrandGithub,
IconTableAlias,
IconVectorSpline,
} from '@tabler/icons-react'
import type { SidebarData } from '../../types';
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { BookIcon } from 'lucide-react';
export const useSidebarData = () => {
const { t } = useTranslation();
const sidebarData: SidebarData = useMemo(() => ({
navGroups: [
{
title: t("sidebar.database"),
items: [
{
title: t("sidebar.tables"),
url: '/database/tables',
icon: IconTableAlias,
},
{
title: t("sidebar.relationships"),
url: '/database/relationships',
icon: IconVectorSpline,
},
],
},
],
footerNavGroups:[ {
title: '',
items: [
{
title: "Discord",
url: "https://discord.gg/4dv26jR4Pj",
icon: IconBrandDiscord,
newTab : true
},
{
title: "Github",
url: "https://github.com/stackrender/stackrender",
icon: IconBrandGithub,
newTab : true
},
{
title : t("sidebar.documentation") ,
url : "https://www.stackrender.io/docs" ,
icon : BookIcon,
newTab : true
}
],
}]
}), [t])
return sidebarData;
}
+22
View File
@@ -0,0 +1,22 @@
import { Spinner } from "../ui/shadcn-io/spinner";
interface LoadingProps {
text?: string;
}
export const Loading: React.FC<LoadingProps> = ({ text }) => {
return (
<div className="fixed bg-black/50 backdrop-opacity-disabled w-screen h-screen fixed flex items-center justify-center z-[50] left-0 top-0 text-white flex-col gap-2">
<Spinner size={48} variant="ellipsis" className="text-primary" />
<span className="font-medium">
Loading ...
</span>
</div>
)
}
@@ -1,29 +1,29 @@
import { useTranslation } from "react-i18next"
import { useTheme } from "next-themes"; import { MenuItem } from "../../types";
import DropdownMenu, { MenuDropdownProps } from "./menu-dropdown"; import { useMemo } from "react";
import React, { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useModal } from "@/providers/modal-provider/modal-provider";
import { Modals } from "@/providers/modal-provider/modal-contxet"; import { Modals } from "@/providers/modal-provider/modal-contxet";
import { useDatabaseHistory } from "@/providers/database-history/database-history-provider"; import { useDatabaseHistory } from "@/providers/database-history/database-history-provider";
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider"; import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { TableType } from "@/lib/schemas/table-schema"; import { useModal } from "@/providers/modal-provider/modal-provider";
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider"; import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
import { TableType } from "@/lib/schemas/table-schema";
import { CardinalityStyle } from "@/lib/database"; import { CardinalityStyle } from "@/lib/database";
import { useTheme } from "@/providers/theme-provider/theme-provider";
export const useMenuData = () => {
const Menu: React.FC = ({ }) => {
const { setTheme, resolvedTheme } = useTheme()
const { t } = useTranslation(); const { t } = useTranslation();
const { setTheme, theme } = useTheme()
const { open } = useModal(); const { open } = useModal();
const { canRedo, canUndo, undo, redo } = useDatabaseHistory(); const { canRedo, canUndo, undo, redo } = useDatabaseHistory();
const { deleteMultiTables } = useDatabaseOperations(); const { deleteMultiTables } = useDatabaseOperations();
const { database } = useDatabase(); const { database } = useDatabase();
const { openController, showController, cardinalityStyle, changeCardinalityStyle } = useDiagramOps(); const { openController, showController, cardinalityStyle, changeCardinalityStyle } = useDiagramOps();
const menu: MenuDropdownProps[] = useMemo(() => [ const menuData: MenuItem[] = useMemo(() => [
{ {
id: "menu.file", id: "menu.file",
title: t("menu.file"), title: t("menu.file"),
@@ -53,13 +53,15 @@ const Menu: React.FC = ({ }) => {
id: "menu.export_sql", id: "menu.export_sql",
title: t("menu.export_sql"), title: t("menu.export_sql"),
clickHandler: () => { clickHandler: () => {
open(Modals.EXPORT_SQL) open(Modals.EXPORT_SQL)
} }
}, },
//{ title: t("menu.export_orm_models"), divide: true }, //{ title: t("menu.export_orm_models"), divide: true },
{ {
id: "menu.delete_project", id: "menu.delete_project",
title: t("menu.delete_project"), theme: "danger", clickHandler: () => { title: t("menu.delete_project"), theme: "destructive", clickHandler: () => {
open(Modals.DELETE_DATABASE) open(Modals.DELETE_DATABASE)
} }
}, },
@@ -93,7 +95,7 @@ const Menu: React.FC = ({ }) => {
{ {
id: "menu.symbolic", id: "menu.symbolic",
selected: cardinalityStyle == CardinalityStyle.SYMBOLIC, selected: cardinalityStyle == CardinalityStyle.SYMBOLIC,
title: t("menu.symbolic"), clickHandler: () => { title: t("menu.symbolic"), clickHandler: () => {
changeCardinalityStyle(CardinalityStyle.SYMBOLIC) changeCardinalityStyle(CardinalityStyle.SYMBOLIC)
} }
}, },
@@ -108,6 +110,8 @@ const Menu: React.FC = ({ }) => {
id: "menu.hidden", id: "menu.hidden",
selected: cardinalityStyle == CardinalityStyle.HIDDEN, selected: cardinalityStyle == CardinalityStyle.HIDDEN,
title: t("menu.hidden"), clickHandler: () => { title: t("menu.hidden"), clickHandler: () => {
changeCardinalityStyle(CardinalityStyle.HIDDEN) changeCardinalityStyle(CardinalityStyle.HIDDEN)
} }
}, },
@@ -120,23 +124,21 @@ const Menu: React.FC = ({ }) => {
openController(!showController) openController(!showController)
} }
}, },
{ {
id: "menu.theme", id: "menu.theme",
title: t("menu.theme"), title: t("menu.theme"),
children: [ children: [
{ {
id: "light", id: "light",
title: t("menu.light"), title: t("menu.light"),
clickHandler: () => setTheme("light"), clickHandler: () => setTheme("light"),
selected: resolvedTheme == "light", selected: theme != "dark",
}, },
{ {
id: "dark", id: "dark",
title: t("menu.dark"), title: t("menu.dark"),
clickHandler: () => setTheme("dark"), clickHandler: () => setTheme("dark"),
selected: resolvedTheme == "dark" selected: theme == "dark"
}, },
], ],
}, },
@@ -146,23 +148,25 @@ const Menu: React.FC = ({ }) => {
id: "menu.help", id: "menu.help",
title: t("menu.help"), title: t("menu.help"),
children: [ children: [
{ id: "menu.join_discord", title: t("menu.join_discord") , {
clickHandler : () => { id: "menu.documentation", title: t("sidebar.documentation"),
window.open("https://discord.gg/DsN8RcPR6Y", "_blank") clickHandler: () => {
window.open("https://www.stackrender.io/docs", "_blank")
} }
}, },
{
id: "menu.join_discord", title: t("menu.join_discord"),
clickHandler: () => {
window.open("https://discord.gg/4dv26jR4Pj", "_blank")
}
},
], ],
}, },
], [t, canRedo, canUndo, undo, redo, deleteMultiTables, database, showController, openController ,changeCardinalityStyle , cardinalityStyle, resolvedTheme]);
], [t, canRedo, canUndo, undo, redo , deleteMultiTables, database, showController, openController, changeCardinalityStyle, cardinalityStyle, theme]);
return <div className="gap-1 flex">
{
menu.map((menuItem, index) => (
<DropdownMenu {...menuItem} key={menuItem.id} />
))
}
</div>
}
export default React.memo(Menu);
return menuData ;
}
+103
View File
@@ -0,0 +1,103 @@
import type React from "react";
import {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
} from "@/components/ui/menubar"
import { useMenuData } from "./data/menu-data";
import { MenuItem } from "../types";
import { Check } from "lucide-react"
const Menu: React.FC = ({ }) => {
const menuData = useMenuData();
return (
<Menubar className="bg-card">
{
menuData.map((menuItem: MenuItem) => (
<MenubarMenu key={menuItem.id}>
<MenubarTrigger
onClick={menuItem.clickHandler}
>
{menuItem.title}
</MenubarTrigger>
{
menuItem.children &&
<MenubarContent>
{
menuItem.children.map((subMenuItem: MenuItem) => (
<div key={subMenuItem.id}>
{
!subMenuItem.children ?
<>
<MenubarItem
variant={subMenuItem.theme}
disabled={subMenuItem.isDisabled}
onClick={subMenuItem.clickHandler}
>
{subMenuItem.title}
{
subMenuItem.shortcut &&
<MenubarShortcut>{subMenuItem.shortcut}</MenubarShortcut>
}
</MenubarItem>
{
subMenuItem.divide &&
<MenubarSeparator />
}
</>
:
<>
<MenubarSub>
<MenubarSubTrigger>{subMenuItem.title}</MenubarSubTrigger>
<MenubarSubContent>
{
subMenuItem.children.map((child: MenuItem) => (
<MenubarItem
disabled={child.isDisabled}
onClick={child.clickHandler}
key={child.id}
className="flex items-center justify-between"
>
{
child.title
}
{
child.selected &&
<Check className="h-4 w-4" />
}
</MenubarItem>
))
}
</MenubarSubContent>
</MenubarSub>
{
subMenuItem.divide &&
<MenubarSeparator />
}
</>
}
</div>
))
}
</MenubarContent>
}
</MenubarMenu>
))
}
</Menubar>
)
}
export default Menu;
+183
View File
@@ -0,0 +1,183 @@
import { type ReactNode } from 'react'
import { Link, useLocation } from "react-router-dom";
import { ChevronRight } from 'lucide-react'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/sidebar'
import { Badge } from '../ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu'
import { NavCollapsible, NavItem, NavLink, NavGroup as NavGroupType } from './types.ts'
export function NavGroup({ title, items , onClick }: NavGroupType) {
const { state, isMobile } = useSidebar();
const location = useLocation();
const href = location.pathname
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
const key = `${item.title}-${item.url}`
if (!item.items)
return <SidebarMenuLink key={key} item={item} href={href} target={item.newTab ? "_blank" : undefined} onClick={onClick}/>
if (state === 'collapsed' && !isMobile)
return (
<SidebarMenuCollapsedDropdown key={key} item={item} href={href} />
)
return <SidebarMenuCollapsible key={key} item={item} href={href} />
})}
</SidebarMenu>
</SidebarGroup>
)
}
const NavBadge = ({ children }: { children: ReactNode }) => (
<Badge className='rounded-full px-1 py-0 text-xs'>{children}</Badge>
)
const SidebarMenuLink = ({ item, href , target , onClick}: { item: NavLink; href: string; target?: string , onClick? : ()=> void }) => {
const { setOpenMobile } = useSidebar()
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={checkIsActive(href, item)}
tooltip={item.title}
onClick={onClick}
>
<Link to={item.url} onClick={() => setOpenMobile(false)} target={target}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
const SidebarMenuCollapsible = ({
item,
href,
}: {
item: NavCollapsible
href: string
}) => {
const { setOpenMobile } = useSidebar()
return (
<Collapsible
asChild
defaultOpen={checkIsActive(href, item, true)}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={checkIsActive(href, subItem)}
>
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
{subItem.icon && <subItem.icon />}
<span>{subItem.title}</span>
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
}
const SidebarMenuCollapsedDropdown = ({
item,
href,
}: {
item: NavCollapsible
href: string
}) => {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={checkIsActive(href, item)}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side='right' align='start' sideOffset={4}>
<DropdownMenuLabel>
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
<Link
to={sub.url}
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
>
{sub.icon && <sub.icon />}
<span className='max-w-52 text-wrap'>{sub.title}</span>
{sub.badge && (
<span className='ml-auto text-xs'>{sub.badge}</span>
)}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
function checkIsActive(href: string, item: NavItem, mainNav = false) {
return (
href === item.url || // /endpint?search=param
href.split('?')[0] === item.url || // endpoint
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
(mainNav &&
href.split('/')[1] !== '' &&
href.split('/')[1] === item?.url?.split('/')[1])
)
}
+51
View File
@@ -0,0 +1,51 @@
interface BaseNavItem {
title: string
badge?: string
icon?: React.ElementType
}
type NavLink = BaseNavItem & {
url: string
items?: never;
newTab?: boolean
}
type NavCollapsible = BaseNavItem & {
items: (BaseNavItem & { url: string })[]
url?: never
}
type NavItem = NavCollapsible | NavLink
interface NavGroup {
title: string
items: NavItem[];
onClick?: () => void;
}
interface SidebarData {
footerNavGroups: NavGroup[]
navGroups: NavGroup[]
}
interface MenuItem {
id: string;
title?: string;
children?: MenuItem[];
theme?: "default" | "destructive",
isDisabled?: boolean,
divide?: boolean,
shortcut?: string,
isOpen?: boolean,
selected?: boolean;
clickHandler?: () => void
}
export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink, MenuItem }
-91
View File
@@ -1,91 +0,0 @@
import { Dropdown, DropdownMenu as HeroDropdownMenu, DropdownItem, DropdownTrigger, Button, Popover, PopoverTrigger, PopoverContent, cn } from "@heroui/react";
import { useCallback, useMemo } from "react";
import SubmenuDropdown from "./submenu-dropdown";
import { Check } from "lucide-react";
export interface MenuDropdownProps {
id: string;
title?: string;
children?: MenuDropdownProps[];
theme?: "default" | "danger",
isDisabled?: boolean,
divide?: boolean,
shortcut?: string,
isOpen?: boolean,
selected?: boolean;
clickHandler?: () => void
}
const DropdownMenu: React.FC<MenuDropdownProps> = ({ title, children, clickHandler, selected = false }) => {
const disabledChilds: string[] = useMemo(() => {
return children ? children?.filter((child: MenuDropdownProps) => child.isDisabled).map((child: MenuDropdownProps) => child.id as string) : []
}, [children]);
return <Dropdown radius="sm" shadow="sm" showArrow>
{
title &&
<DropdownTrigger onPressEnd={clickHandler}>
<Button size="sm" variant="light" className="min-w-[42px]" aria-label={title} >
<span className=" text-left flex justify-between text-small font-semibold text-font/90">
{title}
</span>
</Button>
</DropdownTrigger>
}
<HeroDropdownMenu
disabledKeys={disabledChilds}
>
{
children ? children?.map((child: MenuDropdownProps) => (
<DropdownItem
key={child.id as string}
color={child.theme}
shortcut={child.shortcut}
classNames={{
shortcut: "dark:border-font/10",
base: child.children ? "p-0" : "p-2"
}}
textValue={child.title}
endContent={
child.selected ? <Check className="text-icon size-4" /> : undefined
}
className={child.theme == "danger" ? "text-danger" : "text-font/90"}
onPressEnd={child.clickHandler}
>
{
!child.children ?
<div >
{child.title}
{
child.divide &&
<div className="w-full h-[0.5px] absolute bottom-[-0.5px] left-0 bg-divider "></div>
}
</div>
:
<div >
<SubmenuDropdown {...child} />
{
child.divide &&
<div className="w-full h-[0.5px] absolute bottom-[-0.5px] left-0 bg-divider"></div>
}
</div>
}
</DropdownItem>
)) : []
}
</HeroDropdownMenu>
</Dropdown>
}
export default DropdownMenu;
-40
View File
@@ -1,40 +0,0 @@
import { Button, Popover, PopoverContent, PopoverTrigger, useDisclosure } from "@heroui/react";
import { MenuDropdownProps } from "./menu-dropdown";
import { Check, ChevronRight } from "lucide-react";
import DropdownMenu from "./menu-dropdown";
const SubmenuDropdown: React.FC<MenuDropdownProps> = ({ id , title, children }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<div onMouseEnter={onOpen} onMouseLeave={onClose} className="p-2">
<Popover placement="right" isOpen={isOpen} radius="sm" shadow="sm" >
<PopoverTrigger >
<Button className="w-full h-6 bg-transparent p-0 text-font/90 " size="sm" value={"light"}>
<span className="w-full text-left flex justify-between text-small font-normal">
{title}
<ChevronRight className="size-4 text-icon" />
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 border-0 shadow-none block w-[200px] ">
<div className="h-8">
<DropdownMenu
id = { id }
children={children}
/>
</div>
</PopoverContent>
</Popover>
</div>
)
};
export default SubmenuDropdown;
+206
View File
@@ -0,0 +1,206 @@
import React, { ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { Button } from "./ui/button";
import { Spinner } from "./ui/shadcn-io/spinner";
export interface ModalProps {
isOpen?: boolean,
onOpenChange?: (open: boolean) => void,
className?: string,
backdrop?: "blur" | "transparent" | "opaque",
title: string,
children: ReactNode,
actionName?: string,
actionHandler?: () => void,
isDisabled?: boolean,
description?: string,
variant?: "default" | "danger",
closable?: boolean,
footer?: boolean
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onOpenChange,
className,
title,
description,
children,
actionName = "Action",
actionHandler,
isDisabled,
closable = true,
footer = true,
variant = "default"
}) => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const { t } = useTranslation();
const handleAction = async () => {
setIsLoading(true)
try {
actionHandler && await actionHandler();
} catch (error) {
setIsLoading(false);
} finally {
setIsLoading(false);
}
}
return (
<Dialog open={isOpen} onOpenChange={ closable ? onOpenChange : () => {}}>
<DialogContent className={className} showCloseButton={closable}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{
description &&
<DialogDescription>
{description}
</DialogDescription>
}
</DialogHeader>
{children}
{
footer &&
<DialogFooter >
<div className="flex justify-between w-full flex-col gap-4 sm:flex-row">
{
variant == "default" &&
<>
{
closable &&
<DialogClose asChild >
<Button variant="outline">{t("modals.close")}</Button>
</DialogClose>
}
{
!closable && <div></div>
}
{
actionHandler &&
<Button type="submit" onClick={handleAction} disabled={isDisabled || isLoading} >
{actionName}
{isLoading && <Spinner />}
</Button>
}
</>
}
{
variant == "danger" &&
<>
{
closable &&
<DialogClose asChild >
<Button variant="outline">{t("modals.close")}</Button>
</DialogClose>
}
{
actionHandler &&
<Button type="submit" variant="destructive" onClick={handleAction} disabled={isDisabled || isLoading} >
{actionName}
</Button>
}
</>
}
</div>
</DialogFooter>
}
</DialogContent>
</Dialog>
/*
<HeroUiModal
ref={targetRef}
isOpen={isOpen}
onOpenChange={onOpenChange && closable ? onOpenChange : undefined}
className={className}
backdrop={backdrop}
radius="sm"
classNames={{
base: "dark:bg-background-100",
closeButton: cn("dark:bg-background-100 dark:hover:bg-background rounded-md", !closable ? "hidden" : "")
}}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader {...moveProps} className=" flex flex-col gap-1" >
{title}
{
header &&
<p className="text-sm text-font/70 block">
{header}
</p>
}
</ModalHeader>
<ModalBody>
{children}
</ModalBody>
{footer &&
<ModalFooter>
<div className="flex w-full justify-between">
{
variant == "default" &&
<>
{
closable &&
<Button color="danger" variant="light" onPress={onClose} size="sm">
{t("modals.close")}
</Button>
}
{
!closable && <div></div>
}
{
actionHandler &&
<Button color="primary" onPress={() => handleAction(onClose)} size="sm" isDisabled={isDisabled || isLoading} isLoading={isLoading}>
{actionName}
</Button>
}
</>
}
{
variant == "danger" &&
<>
{
closable &&
<Button color="default" variant="light" onPress={onClose} size="sm">
{t("modals.close")}
</Button>
}
{
!closable && <div></div>
}
{
actionHandler &&
<Button color="danger" onPress={() => handleAction(onClose)} size="sm" isDisabled={isDisabled || isLoading} isLoading={isLoading}>
{actionName}
</Button>
}
</>
}
</div>
</ModalFooter>
}
</>
)}
</ModalContent>
</HeroUiModal>
*/
)
}
export default Modal;
-22
View File
@@ -1,22 +0,0 @@
import { Spinner } from "@heroui/react";
interface LoadingProps {
text?: string;
}
export const Loading: React.FC<LoadingProps> = ({ text }) => {
return (
<div className="fixed bg-overlay/50 backdrop-opacity-disabled w-screen h-screen fixed flex items-center justify-center z-[99] left-0 top-0 text-white flex-col gap-2">
<Spinner size="lg" />
<span className="font-medium">
Loading ...
</span>
</div>
)
}
-140
View File
@@ -1,140 +0,0 @@
import React, { ReactNode, useState } from "react";
import {
Modal as HeroUiModal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
useDraggable,
cn,
} from "@heroui/react";
import { useTranslation } from "react-i18next";
export interface ModalProps {
isOpen?: boolean,
onOpenChange?: (open: boolean) => void,
className?: string,
backdrop?: "blur" | "transparent" | "opaque",
title: string,
children: ReactNode,
actionName?: string,
actionHandler?: () => void,
isDisabled?: boolean,
header?: string,
variant?: "default" | "danger",
closable?: boolean
}
const Modal: React.FC<ModalProps> = ({ isOpen, onOpenChange, className, backdrop = "opaque", title, children, actionName = "Action", header, actionHandler, isDisabled, variant = "default", closable = true }) => {
const targetRef = React.useRef(null);
const { moveProps } = useDraggable({ targetRef, canOverflow: true, isDisabled: !isOpen });
const [isLoading, setIsLoading] = useState<boolean>(false);
const { t } = useTranslation();
const handleAction = async (onClose: () => void) => {
setIsLoading(true)
try {
actionHandler && await actionHandler();
} catch (error) {
setIsLoading(false);
} finally {
setIsLoading(false);
}
}
return (
<HeroUiModal
ref={targetRef}
isOpen={isOpen}
onOpenChange={onOpenChange && closable ? onOpenChange : undefined}
className={className}
backdrop={backdrop}
radius="sm"
classNames={{
base: "dark:bg-background-100",
closeButton: cn("dark:bg-background-100 dark:hover:bg-background rounded-md", !closable ? "hidden" : "")
}}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader {...moveProps} className=" flex flex-col gap-1" >
{title}
{
header &&
<p className="text-sm text-font/70 block">
{header}
</p>
}
</ModalHeader>
<ModalBody>
{children}
</ModalBody>
<ModalFooter>
<div className="flex w-full justify-between">
{
variant == "default" &&
<>
{
closable &&
<Button color="danger" variant="light" onPress={onClose} size="sm">
{t("modals.close")}
</Button>
}
{
!closable && <div></div>
}
{
actionHandler &&
<Button color="primary" onPress={() => handleAction(onClose)} size="sm" isDisabled={isDisabled || isLoading} isLoading={isLoading}>
{actionName}
</Button>
}
</>
}
{
variant == "danger" &&
<>
{
closable &&
<Button color="default" variant="light" onPress={onClose} size="sm">
{t("modals.close")}
</Button>
}
{
!closable && <div></div>
}
{
actionHandler &&
<Button color="danger" onPress={() => handleAction(onClose)} size="sm" isDisabled={isDisabled || isLoading} isLoading={isLoading}>
{actionName}
</Button>
}
</>
}
</div>
</ModalFooter>
</>
)}
</ModalContent>
</HeroUiModal>
)
}
export default Modal;
File diff suppressed because it is too large Load Diff
@@ -1,108 +0,0 @@
import { languages } from "@/i18";
import { Language } from "@/i18/types";
import { Button, Input, Listbox, ListboxItem, Popover, PopoverContent, PopoverTrigger } from "@heroui/react";
import { Check, Languages, Search } from "lucide-react";
import React, { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
const LanguagesDropDonw: React.FC = ({ }) => {
const { t, i18n } = useTranslation();
const [currentLanguage, setCurrentLanguage] = useState<Language>(() => languages.find((language: Language) => language.code == i18n.language) as Language);
const [languageList, setLanguageList] = useState<Language[]>(languages);
const searchRef: Ref<HTMLInputElement> = useRef<HTMLInputElement>(null);
const changeLanguage = useCallback((language: Language) => {
i18n.changeLanguage(language.code);
}, [i18n])
useEffect(() => {
setCurrentLanguage(languages.find((language: Language) => language.code == i18n.language) as Language)
}, [i18n.language])
const searchLanguage = () => {
if (!searchRef.current?.value || searchRef.current?.value.trim().length == 0) {
setLanguageList(languages);
}else
setLanguageList(languages.filter((language: Language) =>
language.code.toLowerCase().includes(searchRef.current?.value.toLowerCase() as string) ||
language.name.toLowerCase().includes(searchRef.current?.value.toLowerCase() as string) ||
language.nativeName.toLowerCase().includes(searchRef.current?.value.toLowerCase() as string)
))
console.log(searchRef.current?.value);
}
const openChange = useCallback((isOpen : boolean) => {
if (isOpen)
setLanguageList(languages)
} , [])
return (
<Popover showArrow placement="bottom" radius="sm" shadow="sm" onOpenChange={openChange}>
<PopoverTrigger>
<Button
variant="bordered"
radius="sm"
startContent={
<Languages className="size-4 text-font/90 dark:text-white" />
}
className="p-0 h-9 min-w-[142px] border-1 border-divider dark:border-font/10 text-xs bg-transparent hover:bg-default text-font/90 font-semibold "
>
<span>
{currentLanguage.nativeName} <span className="text-font/70">({currentLanguage.name})</span>
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="p-2">
<Input
variant="flat"
size="sm"
aria-label={t("navbar.search")}
placeholder={`${t("navbar.search")}`}
ref={searchRef}
onKeyUp={searchLanguage}
startContent={
<Search className="size-4 text-icon" />
}
className="h-8 w-full focus-visible:ring-0 shadow-none "
classNames={{
inputWrapper: "dark:bg-default border-divider group-hover:border-primary group-data-[focus=true]:border-primary group-data-[focus=true]:bg-default",
}}
/>
<hr className="my-2 w-full border-divider" />
<Listbox aria-label="Languages"
>
{
languageList.map((language: Language) => (
<ListboxItem
key={language.code}
textValue={language.code}
endContent={
currentLanguage.code == language.code && <Check className="text-icon size-4" />
}
onPressEnd={() => changeLanguage(language)}
className="text-font/90"
>
{language.nativeName} <span className="text-font/70"> ({language.name})</span>
</ListboxItem>
))
}
</Listbox>
</PopoverContent>
</Popover>
)
}
export default React.memo(LanguagesDropDonw);
-44
View File
@@ -1,44 +0,0 @@
import { useDatabase } from "@/providers/database-provider/database-provider";
import Menu from "../menu/menu";
import RenameDatabase from "./rename-database";
import LanguagesDropdown from "./languages-dropdown";
const Navbar: React.FC = ({ }) => {
const { database } = useDatabase();
return (
<nav className="h-12 fixed z-50 bg-background w-full flex items-center p-4 border-b border-divider ">
<img
src={`/stackrender.png`}
width={22}
alt="logo"
/>
<h3 className="font-semibold ml-2 text-slate-900 text-sm dark:text-white">StackRender</h3>
<div className="ml-4 w-full">
<div className="flex w-full justify-between items-center gap-2">
<Menu />
<div className=" absolute left-[50%] -translate-x-[50%]">
{database && <RenameDatabase database={database} />}
</div>
<div className="flex gap-2 items-center">
<a className="gh-button text-font font-medium bg-background text-xs p-4 px-2 pr-2.5 flex justif-center items-center"
target="_blank"
href="https://github.com/stackrender/stackrender">
<span className="gh-button__icon"></span>
</a>
<LanguagesDropdown />
</div>
</div>
</div>
</nav>
)
}
export default Navbar;
+33
View File
@@ -0,0 +1,33 @@
import { IconSearch } from '@tabler/icons-react'
import { cn } from '@/lib/utils'
import { useSearch } from '@/providers/search-provider/search-provider'
import { Button } from './ui/button'
interface Props {
className?: string
type?: React.HTMLInputTypeAttribute
placeholder?: string
}
export function Search({ className = '', placeholder = 'Search' }: Props) {
const { setOpen } = useSearch()
return (
<Button
variant='outline'
className={cn(
'bg-muted/25 text-muted-foreground hover:bg-muted/50 relative h-8 w-full flex-1 justify-start rounded-md text-sm font-normal shadow-none sm:pr-12 md:w-40 md:flex-none lg:w-56 xl:w-64',
className
)}
onClick={() => setOpen(true)}
>
<IconSearch
aria-hidden='true'
className='absolute top-1/2 left-1.5 -translate-y-1/2'
/>
<span className='ml-3'>{placeholder}</span>
<kbd className='bg-muted pointer-events-none absolute top-[0.3rem] right-[0.3rem] hidden h-5 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none sm:flex'>
<span className='text-xs'></span>K
</kbd>
</Button>
)
}
-65
View File
@@ -1,65 +0,0 @@
import { Link } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip/tooltip";
import { Divider } from "@heroui/react";
import React from "react";
export interface SidebarItemProps {
type?: "item" | "divider"
title?: string;
icon?: React.ReactNode;
href?: string;
isActive?: boolean,
newTab?: boolean
}
const SidebarItem: React.FC<SidebarItemProps> = ({ title, icon, href, type = "item", isActive, newTab }) => {
if (type == "item")
return (
<Tooltip delayDuration={0} >
{
!newTab &&
<TooltipTrigger asChild>
<Link
to={href as string}
target=""
className="p-2 rounded-md hover:bg-default-100 dark:hover:bg-background-50 sidebar-item " data-active={isActive}>
{icon}
</Link>
</TooltipTrigger>
}
{
newTab &&
<TooltipTrigger asChild>
<a
href={href as string}
target="_blank"
className="p-2 rounded-md hover:bg-default-100 dark:hover:bg-background-50 sidebar-item " data-active={isActive}>
{icon}
</a>
</TooltipTrigger>
}
<TooltipContent
side="left"
align="center"
>
{title}
</TooltipContent>
</Tooltip >
)
else {
return <Divider className="bg-default-200 dark:bg-divider" />
}
}
export default SidebarItem
-84
View File
@@ -1,84 +0,0 @@
import { Github, TableProperties, Workflow } from "lucide-react";
import SidebarItem, { SidebarItemProps } from "./sidebar-item";
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from 'react-i18next';
import { Discord } from "../icon/discord";
import { X } from "../icon/x";
interface SidebarProps {
children?: React.ReactNode
}
const sidebarItemClass: string = "text-font size-4 data-[active=true]:text-primary-900";
const Sidebar: React.FC<SidebarProps> = ({ }) => {
const { t } = useTranslation();
const location = useLocation();
const sidebarItems: SidebarItemProps[] = useMemo(() => [
{
title: t("sidebar.tables"),
icon: <TableProperties className={sidebarItemClass}></TableProperties>,
href: "/database/tables",
isActive: location.pathname.endsWith("/database/tables")
},
{
title: t("sidebar.relationships"),
icon: <Workflow className={sidebarItemClass}></Workflow>,
href: "/database/relationships",
isActive: location.pathname.endsWith("/database/relationships")
},
], [location , t])
const bottomSidebarItems: SidebarItemProps[] = useMemo(() => [
{
title: "X",
icon: <X className={sidebarItemClass}></X>,
href: "https://x.com/Iam_The_Dev",
newTab : true ,
},
{
title: "Discord",
icon: <Discord className={sidebarItemClass}></Discord>,
href: "https://discord.gg/DsN8RcPR6Y",
newTab : true ,
},
{
title: "Github",
icon: <Github className={sidebarItemClass}></Github>,
href: "https://github.com/stackrender/stackrender",
newTab : true ,
},
], []);
return (
<aside className="h-full z-[20] flex flex-col items-between py-2 justify-between sticky top-0 duration-500 w-12 bg-sidebar pt-[56px] dark:bg-background">
<div className="flex flex-col items-center gap-2">
{
sidebarItems.map((item: SidebarItemProps, index: number) => (
<SidebarItem
key={`top-${index}`}
{...item}
/>
))
}
</div>
<div className="flex flex-col items-center gap-2 ">
{
bottomSidebarItems.map((item: SidebarItemProps, index: number) => (
<SidebarItem
key={`bottom-${index}`}
{...item}
/>
))
}
</div>
</aside>
)
}
export default Sidebar;
-68
View File
@@ -1,68 +0,0 @@
import React, { useEffect, useState } from "react"
import { useTranslation } from "react-i18next";
import { WithContext as ReactTagInput, Tag } from 'react-tag-input';
interface TagInputProps {
defaultItems?: string[];
onItemsChange? : (items: string[]) => void
}
const KeyCodes = {
comma: 188,
enter: 13,
};
const delimiters = [KeyCodes.comma, KeyCodes.enter];
const TagInput: React.FC<TagInputProps> = ({ defaultItems = [], onItemsChange }) => {
const { t } = useTranslation();
const [tags, setTags] = useState<Tag[]>(defaultItems.map((item: string) => ({
id: item.toLowerCase(),
text: item,
className: ""
}) as Tag));
const handleDelete = (i: number) => {
setTags(tags.filter((_, index) => index !== i));
};
const handleAddition = (tag: Tag) => {
setTags([...tags, tag]);
};
const handleDrag = (tag: Tag, currPos: number, newPos: number) => {
const newTags = tags.slice();
newTags.splice(currPos, 1);
newTags.splice(newPos, 0, tag);
setTags(newTags);
};
useEffect(() => onItemsChange && onItemsChange(tags.map((tag: Tag) => tag.text)), [tags])
return (
<div>
<ReactTagInput
tags={tags}
classNames={{
tag: "font-normal inline-block m-0.5 border-1 border-default dark:border-divider rounded-full px-2 py-1 flex-row ",
remove: " bg-default-900 dark:bg-default-500 rounded-full text-xs text-center ml-1 min-w-[15px] max-w-[15px] min-h-[15px] max-h-[15px] transition-colors duration-300 hover:bg-black",
tagInputField: "relative w-full inline-flex flex-row items-center bg-default-100 border-1 border-divider hover:border-primary focus-within:border-primary h-8 min-h-8 px-2 rounded-small transition-background !duration-150 transition-colors outline-none dark:bg-default placeholder:text-foreground-500 mt-2" ,
tags : "max-h-[256px] overflow-auto "
}}
handleDelete={handleDelete}
autoFocus={false}
handleAddition={handleAddition}
handleDrag={handleDrag}
delimiters={delimiters}
placeholder={t("db_controller.field_settings.type_enter")}
/>
</div>
);
}
export default React.memo(TagInput)
+60
View File
@@ -0,0 +1,60 @@
import { useEffect } from 'react'
import { IconCheck, IconMoon, IconSun } from '@tabler/icons-react'
import { cn } from '@/lib/utils'
import { useTheme } from '@/providers/theme-provider/theme-provider'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
export function ThemeSwitch() {
const { theme, setTheme } = useTheme() ;
const { t } = useTranslation() ;
/* Update theme-color meta tag
* when theme is updated */
useEffect(() => {
const themeColor = theme === 'dark' ? '#020817' : '#fff'
const metaThemeColor = document.querySelector("meta[name='theme-color']")
if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor)
}, [theme])
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon' className='scale-95 rounded-full'>
<IconSun className='size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' />
<IconMoon className='absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' />
<span className='sr-only'>Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setTheme('light')}>
{t("menu.light")}{' '}
<IconCheck
size={14}
className={cn('ml-auto', theme !== 'light' && 'hidden')}
/>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
{t("menu.dark")}
<IconCheck
size={14}
className={cn('ml-auto', theme !== 'dark' && 'hidden')}
/>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
{t("menu.system")}
<IconCheck
size={14}
className={cn('ml-auto', theme !== 'system' && 'hidden')}
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
-46
View File
@@ -1,46 +0,0 @@
import { cn } from "@heroui/react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip/tooltip"
interface ToggleProps {
active?: boolean,
children: React.ReactNode,
onToggle?: (value: boolean) => void,
className?: string ,
label? : string
}
const ToggleButton: React.FC<ToggleProps> = ({ active = false, children, onToggle, className , label }) => {
const toggle = () => {
onToggle && onToggle(!active)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button className={
cn("p-1 px-2 transition-all hover:bg-default hover:text-font/90 rounded duration-200 ",
className ? className : "",
active ?
"text-font/90 bg-default dark:text-font/90 white dark:bg-default/40" :
"text-icon"
)
}
onClick={toggle}
>
<span className="text-sm">
{children}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
{label}
</TooltipContent>
</Tooltip>
)
}
export default ToggleButton
-30
View File
@@ -1,30 +0,0 @@
import React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@heroui/react';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 shadow-lg overflow-hidden rounded-md bg-foreground px-3 py-2 text-xs text-default animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+90
View File
@@ -0,0 +1,90 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
type AccordionTriggerProps = React.ComponentProps<typeof AccordionPrimitive.Trigger> & {
position?: "left" | "right" ;
leftContent? : React.ReactNode
}
function AccordionTrigger({
className,
children,
position = "left",
leftContent ,
...props
}: AccordionTriggerProps) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-center gap-2 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{
leftContent
}
{
position === "left" &&
<>
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
{children}
</>
}
{
position == "right" &&
<>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</>
}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+51
View File
@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
asChild?: boolean
}
>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref} // ✅ now Button can accept refs
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
})
Button.displayName = "Button"
export { Button, buttonVariants }
+211
View File
@@ -0,0 +1,211 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+31
View File
@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+184
View File
@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+142
View File
@@ -0,0 +1,142 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
))
DialogOverlay.displayName = "DialogOverlay"
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+133
View File
@@ -0,0 +1,133 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+258
View File
@@ -0,0 +1,258 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = "DropdownMenuSubContent"
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, type, ...props }, ref) => {
return (
<input
ref={ref} // ✅ allow refs
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
})
Input.displayName = "Input"
export { Input }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 text-foreground/75",
className
)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+274
View File
@@ -0,0 +1,274 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md p-1 ",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+56
View File
@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+186
View File
@@ -0,0 +1,186 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, X } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild >
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
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)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,272 @@
import {
LoaderCircleIcon,
LoaderIcon,
LoaderPinwheelIcon,
type LucideProps,
} from 'lucide-react';
import { cn } from '@/lib/utils';
type SpinnerVariantProps = Omit<SpinnerProps, 'variant'>;
const Default = ({ className, ...props }: SpinnerVariantProps) => (
<LoaderIcon className={cn('animate-spin', className)} {...props} />
);
const Circle = ({ className, ...props }: SpinnerVariantProps) => (
<LoaderCircleIcon className={cn('animate-spin', className)} {...props} />
);
const Pinwheel = ({ className, ...props }: SpinnerVariantProps) => (
<LoaderPinwheelIcon className={cn('animate-spin', className)} {...props} />
);
const CircleFilled = ({
className,
size = 24,
...props
}: SpinnerVariantProps) => (
<div className="relative" style={{ width: size, height: size }}>
<div className="absolute inset-0 rotate-180">
<LoaderCircleIcon
className={cn('animate-spin', className, 'text-foreground opacity-20')}
size={size}
{...props}
/>
</div>
<LoaderCircleIcon
className={cn('relative animate-spin', className)}
size={size}
{...props}
/>
</div>
);
const Ellipsis = ({ size = 24, ...props }: SpinnerVariantProps) => {
return (
<svg
height={size}
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Loading...</title>
<circle cx="4" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="0;ellipsis3.end+0.25s"
calcMode="spline"
dur="0.6s"
id="ellipsis1"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
<circle cx="12" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="ellipsis1.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
<circle cx="20" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="ellipsis1.begin+0.2s"
calcMode="spline"
dur="0.6s"
id="ellipsis3"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
</svg>
);
};
const Ring = ({ size = 24, ...props }: SpinnerVariantProps) => (
<svg
height={size}
stroke="currentColor"
viewBox="0 0 44 44"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Loading...</title>
<g fill="none" fillRule="evenodd" strokeWidth="2">
<circle cx="22" cy="22" r="1">
<animate
attributeName="r"
begin="0s"
calcMode="spline"
dur="1.8s"
keySplines="0.165, 0.84, 0.44, 1"
keyTimes="0; 1"
repeatCount="indefinite"
values="1; 20"
/>
<animate
attributeName="stroke-opacity"
begin="0s"
calcMode="spline"
dur="1.8s"
keySplines="0.3, 0.61, 0.355, 1"
keyTimes="0; 1"
repeatCount="indefinite"
values="1; 0"
/>
</circle>
<circle cx="22" cy="22" r="1">
<animate
attributeName="r"
begin="-0.9s"
calcMode="spline"
dur="1.8s"
keySplines="0.165, 0.84, 0.44, 1"
keyTimes="0; 1"
repeatCount="indefinite"
values="1; 20"
/>
<animate
attributeName="stroke-opacity"
begin="-0.9s"
calcMode="spline"
dur="1.8s"
keySplines="0.3, 0.61, 0.355, 1"
keyTimes="0; 1"
repeatCount="indefinite"
values="1; 0"
/>
</circle>
</g>
</svg>
);
const Bars = ({ size = 24, ...props }: SpinnerVariantProps) => (
<svg
height={size}
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Loading...</title>
<style>{`
.spinner-bar {
animation: spinner-bars-animation .8s linear infinite;
animation-delay: -.8s;
}
.spinner-bars-2 {
animation-delay: -.65s;
}
.spinner-bars-3 {
animation-delay: -0.5s;
}
@keyframes spinner-bars-animation {
0% {
y: 1px;
height: 22px;
}
93.75% {
y: 5px;
height: 14px;
opacity: 0.2;
}
}
`}</style>
<rect
className="spinner-bar"
fill="currentColor"
height="22"
width="6"
x="1"
y="1"
/>
<rect
className="spinner-bar spinner-bars-2"
fill="currentColor"
height="22"
width="6"
x="9"
y="1"
/>
<rect
className="spinner-bar spinner-bars-3"
fill="currentColor"
height="22"
width="6"
x="17"
y="1"
/>
</svg>
);
const Infinite = ({ size = 24, ...props }: SpinnerVariantProps) => (
<svg
height={size}
preserveAspectRatio="xMidYMid"
viewBox="0 0 100 100"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Loading...</title>
<path
d="M24.3 30C11.4 30 5 43.3 5 50s6.4 20 19.3 20c19.3 0 32.1-40 51.4-40 C88.6 30 95 43.3 95 50s-6.4 20-19.3 20C56.4 70 43.6 30 24.3 30z"
fill="none"
stroke="currentColor"
strokeDasharray="205.271142578125 51.317785644531256"
strokeLinecap="round"
strokeWidth="10"
style={{
transform: 'scale(0.8)',
transformOrigin: '50px 50px',
}}
>
<animate
attributeName="stroke-dashoffset"
dur="2s"
keyTimes="0;1"
repeatCount="indefinite"
values="0;256.58892822265625"
/>
</path>
</svg>
);
export type SpinnerProps = LucideProps & {
variant?:
| 'default'
| 'circle'
| 'pinwheel'
| 'circle-filled'
| 'ellipsis'
| 'ring'
| 'bars'
| 'infinite';
};
export const Spinner = ({ variant, ...props }: SpinnerProps) => {
switch (variant) {
case 'circle':
return <Circle {...props} />;
case 'pinwheel':
return <Pinwheel {...props} />;
case 'circle-filled':
return <CircleFilled {...props} />;
case 'ellipsis':
return <Ellipsis {...props} />;
case 'ring':
return <Ring {...props} />;
case 'bars':
return <Bars {...props} />;
case 'infinite':
return <Infinite {...props} />;
default:
return <Default {...props} />;
}
};
+139
View File
@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+740
View File
@@ -0,0 +1,740 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
/*
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
*/
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = { children: tooltip }
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+25
View File
@@ -0,0 +1,25 @@
import { useTheme } from "@/providers/theme-provider/theme-provider"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
ref={ref}
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+73
View File
@@ -0,0 +1,73 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className={cn( "bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" , className) } />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
/**
* List of available font names (visit the url `/settings/appearance`).
* This array is used to generate dynamic font classes (e.g., `font-inter`, `font-manrope`).
*
* 📝 How to Add a New Font (Tailwind v4+):
* 1. Add the font name here.
* 2. Update the `<link>` tag in 'index.html' to include the new font from Google Fonts (or any other source).
* 3. Add the new font family to 'index.css' using the `@theme inline` and `font-family` CSS variable.
*
* Example:
* fonts.ts → Add 'roboto' to this array.
* index.html → Add Google Fonts link for Roboto.
* index.css → Add the new font in the CSS, e.g.:
* @theme inline {
* // ... other font families
* --font-roboto: 'Roboto', var(--font-sans);
* }
*/
export const fonts = ['inter', 'manrope', 'system'] as const
+37
View File
@@ -0,0 +1,37 @@
import { Outlet } from "react-router-dom";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/layout/app-sidebar/app-sidebar";
import { Header } from "@/components/header";
interface Props {
children?: React.ReactNode
}
const Dashboard: React.FC<Props> = ({ children }) => {
return (
<SidebarProvider defaultOpen={false}>
<AppSidebar variant="inset" />
{/* dark:p-0 dark:border-l-1 */}
<SidebarInset className=" overflow-hidden max-h-screen flex flex-col flex-1 !m-0 !rounded-none bg-sidebar p-1 ">
{/*dark:border-none dark:rounded-none */}
<div className="border-4 border-sidebar-border ring-1 ring-border/30 dark:!border-background dark:ring-sidebar-border flex flex-col min-h-0 !overflow-hidden rounded-2xl h-screen">
<div className="overflow-hidden h-full flex flex-col flex-1 bg-white dark:bg-background dark:!border-1 dark:border-sidebar-border dark:rounded-[14px] !overflow-hidden" >
<Header className="bg-card border-b pl-3" />
{
children ? children : <Outlet />
}
</div>
</div>
</SidebarInset>
</SidebarProvider>
)
}
export default Dashboard;
@@ -1,5 +1,6 @@
import { CardinalityStyle } from "@/lib/database"; import { CardinalityStyle } from "@/lib/database";
import { cn } from "@heroui/react"; import { cn } from "@/lib/utils";
import React from "react"; import React from "react";
export interface CardinalityMarkerProps { export interface CardinalityMarkerProps {
@@ -16,15 +17,12 @@ const CardinalityMarker: React.FC<CardinalityMarkerProps> = ({ selected = false,
const id = `${cardinality}_${direction}${selected ? "_selected" : ""}`; const id = `${cardinality}_${direction}${selected ? "_selected" : ""}`;
const renderMarker = () => { const renderMarker = () => {
if (cardinality == "many") { if (cardinality == "many") {
if (direction == "start") if (direction == "start")
return (<path d="M 0 50 L 100 50 M 100 50 L 0 0 M 100 50 L 0 100 " />) return (<path d="M 0 50 L 100 50 M 100 50 L 0 0 M 100 50 L 0 100 " />)
else if (direction == "end") else if (direction == "end")
return (<path d="M 100 50 L 0 50 M 0 50 L 100 0 M 0 50 L 100 100" />) return (<path d="M 100 50 L 0 50 M 0 50 L 100 0 M 0 50 L 100 100" />)
} }
if (cardinality == "one") { if (cardinality == "one") {
if (direction == "start") { if (direction == "start") {
return (<path d="M 0 50 L 100 50 M 50 50 M 50 50 M 75 25 L 75 75" />) return (<path d="M 0 50 L 100 50 M 50 50 M 50 50 M 75 25 L 75 75" />)
@@ -33,8 +31,6 @@ const CardinalityMarker: React.FC<CardinalityMarkerProps> = ({ selected = false,
return (<path d="M 100 50 L 0 50 M 50 50 M 50 50 M 25 25 L 25 75" />) return (<path d="M 100 50 L 0 50 M 50 50 M 50 50 M 25 25 L 25 75" />)
} }
} }
} }
@@ -52,8 +48,8 @@ const CardinalityMarker: React.FC<CardinalityMarkerProps> = ({ selected = false,
> >
<svg <svg
fill="transparent" fill="transparent"
className={selected ? "stroke-primary" : "stroke-default-600 dark:stroke-default-400"} className={selected ? ' !stroke-[oklch(0.60_0.028_263.3984)] dark:!stroke-primary-foreground' : 'stroke-[oklch(0.72_0.022_263.3984)] dark:!stroke-muted-foreground'}
strokeWidth="4" strokeWidth="6"
width="24" width="24"
height="24" height="24"
viewBox="0 0 100 100"> viewBox="0 0 100 100">
@@ -78,23 +74,24 @@ const CardinalityMarker: React.FC<CardinalityMarkerProps> = ({ selected = false,
<circle <circle
cx="12" cx="12"
cy="12" cy="12"
r="8" r="6"
strokeWidth="1" strokeWidth="1"
className={ className={
cn("dark:fill-default fill-default", cn(" fill-background ",
selected ? " stroke-primary fill-background dark:fill-primary-900" : " stroke-default-600 dark:stroke-default-400" selected ? " stroke-[oklch(0.60_0.028_263.3984)] fill-background dark:!stroke-primary-foreground" :
" stroke-[oklch(0.72_0.022_263.3984)] dark:!stroke-muted-foreground "
) )
} }
/> />
<text <text
x="12" x="12"
y="13" y="12.5"
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
fontSize="8" fontSize="6"
className={ className={
cn("fill-font/90 dark:fill-font/90 font-semibold" , cn("fill-[oklch(0.72_0.022_263.3984)] font-semibold dark:!fill-muted-foreground" ,
selected ? "fill-primary dark:fill-primary" : "" , selected ? "fill-[oklch(0.60_0.028_263.3984)] dark:!fill-primary-foreground" : "" ,
) )
} }
> >
@@ -1,10 +1,15 @@
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/tooltip/tooltip";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useDatabaseHistory } from "@/providers/database-history/database-history-provider"; import { useDatabaseHistory } from "@/providers/database-history/database-history-provider";
import { Button, cn, Divider, Navbar } from "@heroui/react";
import { useOnViewportChange, useReactFlow } from "@xyflow/react"; import { useOnViewportChange, useReactFlow } from "@xyflow/react";
import { LayoutGrid, Redo, Scan, Undo, ZoomIn, ZoomOut } from "lucide-react"; import { LayoutGrid, Redo, Scan, Undo, ZoomIn, ZoomOut } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface DbControlButtons { interface DbControlButtons {
@@ -52,39 +57,30 @@ const DatabaseControlButtons: React.FC<DbControlButtons> = ({ adjustPositions })
}) })
}, []); }, []);
return ( return (
<Navbar className="flex rounded-md border-1 border-default-200 bg-background dark:bg-transparent bg-default dark:border-divider " isBlurred <div className="flex !rounded-md p-2 border-1 !overflow-hidden !bg-input/50 dark:!bg-card/50 text-muted-foreground shadow-md backdrop-blur-xs dark:backdrop-blur-md">
classNames={{
wrapper: "h-14 p-2 gap-1",
}}
>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<Button <Button
size="md" size={"icon"}
isIconOnly onClick={undo}
variant="light"
onPressEnd={undo}
radius="sm"
disabled={!canUndo} disabled={!canUndo}
variant={"ghost"}
> >
<Undo className={cn( <Undo className={cn(
"size-4 dark:text-white", "size-4"
!canUndo ? "text-font/30 dark:text-font/30" : ""
)} /> )} />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("control_buttons.undo")} {t("control_buttons.undo")}
<span className="ml-2 text-default-400"> <span className="ml-2">
Cntl + Z Cntl + Z
</span> </span>
</TooltipContent> </TooltipContent>
@@ -94,12 +90,11 @@ const DatabaseControlButtons: React.FC<DbControlButtons> = ({ adjustPositions })
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<Button <Button
size="md" size={"icon"}
isIconOnly onClick={adjustPositions}
variant="light" variant={"ghost"}
radius="sm" >
onPressEnd={adjustPositions}> <LayoutGrid className="size-4" />
<LayoutGrid className="size-4 dark:text-white" />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -108,18 +103,17 @@ const DatabaseControlButtons: React.FC<DbControlButtons> = ({ adjustPositions })
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Divider orientation="vertical" className="bg-font/10" /> <Separator orientation="vertical" />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<Button <Button
size="md" size="icon"
isIconOnly onClick={onZoomOut}
variant="light" variant={"ghost"}
radius="sm" >
onPressEnd={onZoomOut}> <ZoomOut className="size-4" />
<ZoomOut className="size-4 dark:text-white" />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -129,11 +123,10 @@ const DatabaseControlButtons: React.FC<DbControlButtons> = ({ adjustPositions })
</Tooltip> </Tooltip>
<Button <Button
size="md" size="icon"
isIconOnly variant={"ghost"}
variant="light" onClick={resetZoom}
onPressEnd={resetZoom} className="w-[60px] p-2 "
className="w-[60px] p-2 hover:bg-primary-foreground dark:text-white"
> >
{zoom} {zoom}
</Button> </Button>
@@ -141,12 +134,11 @@ const DatabaseControlButtons: React.FC<DbControlButtons> = ({ adjustPositions })
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<Button <Button
size="md" size="icon"
isIconOnly onClick={onZoomIn}
variant="light" variant={"ghost"}
radius="sm" >
onPressEnd={onZoomIn}> <ZoomIn className="size-4" />
<ZoomIn className="size-4 dark:text-white" />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -155,25 +147,23 @@ const DatabaseControlButtons: React.FC<DbControlButtons> = ({ adjustPositions })
{t("control_buttons.zoom_in")} {t("control_buttons.zoom_in")}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Divider orientation="vertical" className="bg-font/10" /> <Separator orientation="vertical" />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<Button <Button
size="md" size="icon"
isIconOnly onClick={onFitView}
variant="light" variant={"ghost"}
radius="sm"
onPressEnd={onFitView}
> >
<Scan className="size-4 dark:text-white" /> <Scan className="size-4" />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("control_buttons.show_all")} {t("control_buttons.show_all")}
<span className="ml-2 text-default-400"> <span className="ml-2">
Cntl + A Cntl + A
</span> </span>
</TooltipContent> </TooltipContent>
@@ -182,30 +172,26 @@ const DatabaseControlButtons: React.FC<DbControlButtons> = ({ adjustPositions })
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<Button <Button
size="md" size="icon"
isIconOnly variant={"ghost"}
variant="light" onClick={redo}
radius="sm"
onPressEnd={redo}
disabled={!canRedo} disabled={!canRedo}
> >
<Redo className={cn( <Redo className={cn("size-4")} />
"size-4 dark:text-white",
!canRedo ? "text-font/30 dark:text-font/30" : ""
)} />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("control_buttons.redo")} {t("control_buttons.redo")}
<span className="ml-2 text-default-400"> <span className="ml-2 ">
Ctnl + Shift + Z Ctnl + Shift + Z
</span> </span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</Navbar> </div>
) )
} }
export default DatabaseControlButtons; export default DatabaseControlButtons;
@@ -1,6 +1,6 @@
// Importing necessary types and hooks from React Flow (XYFlow) // Importing necessary types and hooks from React Flow (XYFlow)
import { import {
addEdge, Background, ColorMode, Connection, Controls, EdgeChange, Background, ColorMode, Connection, Controls, EdgeChange,
EdgeRemoveChange, MiniMap, NodeChange, NodePositionChange, EdgeRemoveChange, MiniMap, NodeChange, NodePositionChange,
NodeRemoveChange, OnEdgesChange, OnNodesChange, NodeRemoveChange, OnEdgesChange, OnNodesChange,
ReactFlow, useEdgesState, useNodesState, useReactFlow ReactFlow, useEdgesState, useNodesState, useReactFlow
@@ -10,51 +10,60 @@ import {
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
// Custom components and styles // Custom components and styles
import Table from "./table/table"; import Table from "./table";
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import Relationship from "./table/relationship"; import Relationship from "./relationship";
import DBController from "./db-controller/db-controller";
import { TableInsertType } from "@/lib/schemas/table-schema"; import { TableInsertType } from "@/lib/schemas/table-schema";
// Custom context providers and hooks // Custom context providers and hooks
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider"; import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { useTableToNode } from "@/hooks/use-table-to-node"; import { useTableToNode } from "@/hooks/use-table-to-node";
import { useRelationshipToEdge } from "@/hooks/use-relationship-to-edge"; import { useRelationshipToEdge } from "@/hooks/use-relationship-to-edge";
import { Cardinality, RelationshipInsertType } from "@/lib/schemas/relationship-schema"; import { RelationshipInsertType } from "@/lib/schemas/relationship-schema";
// Utils and constants // Utils and constants
import { v4 } from "uuid"; import { v4 } from "uuid";
import { TARGET_PREFIX } from "./table/field"; import { TARGET_PREFIX } from "./field";
import CardinalityMarker from "@/components/cardinality-marker/cardinality-marker"; import CardinalityMarker from "@/features/database/components/cardinality-marker";
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider"; import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/tooltip";
import { addToast, Button } from "@heroui/react";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle, Menu } from "lucide-react";
import { adjustTablesPositions, isTablesOverlapping } from "@/utils/tables"; import { adjustTablesPositions } from "@/utils/tables";
import DatabaseControlButtons from "./database-control-buttons"; //import DatabaseControlButtons from "./database-control-buttons";
import { FieldType } from "@/lib/schemas/field-schema"; import { FieldType } from "@/lib/schemas/field-schema";
import useHighlightedEdges from "@/hooks/use-highlighted-edges"; import useHighlightedEdges from "@/hooks/use-highlighted-edges";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useOverlappingTables from "@/hooks/use-overlapping-tables"; import useOverlappingTables from "@/hooks/use-overlapping-tables";
import { useTheme } from "next-themes";
import { getRelationshipSourceAndTarget } from "@/utils/relationship"; import { getRelationshipSourceAndTarget } from "@/utils/relationship";
import { useModal } from "@/providers/modal-provider/modal-provider"; import { Loading } from "@/components/layout/loading-modal";
import { Modals } from "@/providers/modal-provider/modal-contxet";
import { Loading } from "@/components/modal/loading-modal";
import { usePowerSync } from "@powersync/react";
import { CardinalityStyle } from "@/lib/database"; import { CardinalityStyle } from "@/lib/database";
import { useTheme } from "@/providers/theme-provider/theme-provider";
import { toast } from "sonner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import DatabaseControlButtons from "./database-control-buttons";
import { Modals } from "@/providers/modal-provider/modal-contxet";
import { useModal } from "@/providers/modal-provider/modal-provider";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import useToast from "@/hooks/use-toast";
const DatabasePage: React.FC = () => { const DatabaseDiagram: React.FC = () => {
const { open } = useModal(); const { open } = useModal();
const { t } = useTranslation(); const { t } = useTranslation();
const { resolvedTheme } = useTheme(); const { theme: resolvedTheme } = useTheme();
// Extract database state and operations // Extract database state and operations
const { database, getField, databases, isSwitchingDatabase, isLoading, currentDatabaseId } = useDatabase(); const { database, getField, isSwitchingDatabase, isLoading, databases } = useDatabase();
const { updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabaseOperations(); const { updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabaseOperations();
@@ -63,8 +72,8 @@ const DatabasePage: React.FC = () => {
const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// Diagram-related state (e.g. connection in progress) // Diagram-related state (e.g. connection in progress)
const { setIsConnectionInProgress, cardinalityStyle } = useDiagramOps(); const { setIsConnectionInProgress, cardinalityStyle, showController, openController } = useDiagramOps();
const raise = useToast() ;
// Destructure tables and relationships from database // Destructure tables and relationships from database
const { tables, relationships } = database || { tables: [], relationships: [] }; const { tables, relationships } = database || { tables: [], relationships: [] };
@@ -75,17 +84,18 @@ const DatabasePage: React.FC = () => {
const nodeTypes = useMemo(() => ({ table: Table }), []); const nodeTypes = useMemo(() => ({ table: Table }), []);
const edgeTypes = useMemo(() => ({ 'relationship-edge': Relationship }), []); const edgeTypes = useMemo(() => ({ 'relationship-edge': Relationship }), []);
useEffect(() => { useEffect(() => {
if (isLoading) if (isLoading)
return; return;
// no database selected , open (open database) Modal // no database selected , open (open database) Modal
if (!database && databases?.length > 0) { if (!database && databases?.length > 0) {
open(Modals.OPEN_DATABASE, { open(Modals.OPEN_DATABASE, {
closable: false closable: false
}) })
} }
else if (databases.length == 0) { else if (databases.length == 0) {
// there is no databases in the first place to select from , // there is no databases in the first place to select from ,
// we have to create a new one . // we have to create a new one .
open(Modals.CREATE_DATABASE, { open(Modals.CREATE_DATABASE, {
@@ -99,9 +109,17 @@ const DatabasePage: React.FC = () => {
}, [database?.id, isLoading]) }, [database?.id, isLoading])
useEffect(() => { useEffect(() => {
setNodes([]); if (isSwitchingDatabase) {
setEdges([]); setNodes([]);
}, [currentDatabaseId]) setEdges([]);
} else {
fitView({
duration: 500
});
}
}, [isSwitchingDatabase, database?.id])
// Called when a connection is made between fields // Called when a connection is made between fields
const onConnect = useCallback(async (connection: Connection) => { const onConnect = useCallback(async (connection: Connection) => {
@@ -122,16 +140,17 @@ const DatabasePage: React.FC = () => {
targetTableId, targetTableId,
sourceFieldId, sourceFieldId,
targetFieldId, targetFieldId,
} as RelationshipInsertType); } as RelationshipInsertType);
} else { } else {
// Show error toast if invalid relationship
addToast({
title: t("db_controller.invalid_relationship.title"), raise(
description: t("db_controller.invalid_relationship.description"), t("db_controller.invalid_relationship.title"),
color: "danger", t("db_controller.invalid_relationship.description"),
variant: "solid" "ERROR"
}); )
} }
setIsConnectionInProgress(false); setIsConnectionInProgress(false);
@@ -147,28 +166,30 @@ const DatabasePage: React.FC = () => {
const nodeRemoveChanges: NodeRemoveChange[] = changes.filter((change: NodeChange) => change.type == "remove"); const nodeRemoveChanges: NodeRemoveChange[] = changes.filter((change: NodeChange) => change.type == "remove");
// Save new positions to the database // Save new positions to the database
if (nodePositionChanges.length > 0) if (nodePositionChanges.length > 0)
updateTablePositions(nodePositionChanges.map((change: NodePositionChange) => ({ updateTablePositions(nodePositionChanges.map((change: NodePositionChange) => ({
id: change.id, id: change.id,
posX: change.position?.x, posX: change.position?.x,
posY: change.position?.y posY: change.position?.y
} as TableInsertType))); } as TableInsertType)));
// Delete tables if removed // Delete tables if removed
if (nodeRemoveChanges.length > 0) { if (nodeRemoveChanges.length > 0 && !isSwitchingDatabase) {
deleteMultiTables(nodeRemoveChanges.map((change: NodeRemoveChange) => change.id)); deleteMultiTables(nodeRemoveChanges.map((change: NodeRemoveChange) => change.id));
} }
return onNodesChange(changes); return onNodesChange(changes);
}, [onNodesChange, database]); }, [onNodesChange, database, isSwitchingDatabase]);
// Called when edges (relationships) change // Called when edges (relationships) change
const handleEdgeChanges: OnEdgesChange<any> = useCallback((changes: EdgeChange<any>[]) => { const handleEdgeChanges: OnEdgesChange<any> = useCallback((changes: EdgeChange<any>[]) => {
const edgeRemoveChanges: EdgeRemoveChange[] = changes.filter((change: EdgeChange) => change.type == "remove") as EdgeRemoveChange[]; const edgeRemoveChanges: EdgeRemoveChange[] = changes.filter((change: EdgeChange) => change.type == "remove") as EdgeRemoveChange[];
// Delete relationships from database // Delete relationships from database
if (edgeRemoveChanges.length > 0) { if (edgeRemoveChanges.length > 0 && !isSwitchingDatabase) {
deleteMultiRelationships(edgeRemoveChanges.map((change: EdgeRemoveChange) => change.id)); deleteMultiRelationships(edgeRemoveChanges.map((change: EdgeRemoveChange) => change.id));
} }
return onEdgesChange(changes as EdgeChange<never>[]); return onEdgesChange(changes as EdgeChange<never>[]);
}, [onEdgesChange]); }, [onEdgesChange, isSwitchingDatabase]);
// When user starts connecting fields // When user starts connecting fields
const onConnectStart = useCallback(() => { const onConnectStart = useCallback(() => {
@@ -199,18 +220,15 @@ const DatabasePage: React.FC = () => {
useHighlightedEdges(nodes, relationships, edges); useHighlightedEdges(nodes, relationships, edges);
const { isOverlapping, puls } = useOverlappingTables(tables); const { isOverlapping, puls } = useOverlappingTables(tables);
const isMobile = useIsMobile();
return ( return (
<div className="w-full h-screen flex relative overflow-hidden"> <div className="w-full h-full flex relative overflow-hidden">
{ {
(isSwitchingDatabase || isLoading) && <Loading /> (isSwitchingDatabase || isLoading) && <Loading />
} }
<div className="flex max-w-full">
<DBController />
</div>
{
{
<div className="relative w-full h-full "> <div className="relative w-full h-full ">
<ReactFlow <ReactFlow
colorMode={resolvedTheme as ColorMode} colorMode={resolvedTheme as ColorMode}
@@ -236,33 +254,36 @@ const DatabasePage: React.FC = () => {
> >
<Controls <Controls
position="bottom-center" position={isMobile ? "top-center" : "bottom-center"}
showFitView={false} showFitView={false}
showZoom={false} showZoom={false}
showInteractive={false} showInteractive={false}
className="shadow-none"> className="!shadow-none !border-none">
<DatabaseControlButtons <DatabaseControlButtons
adjustPositions={adjustPositions} adjustPositions={adjustPositions}
/> />
</Controls > </Controls >
<MiniMap <MiniMap
nodeStrokeWidth={4} nodeStrokeWidth={4}
className="dark:bg-content1 bg-background border-1 border-default-200 dark:border-divider rounded-md " className="!bg-input/50 ring-1 ring-border shadow-sm rounded-md overflow-hidden shadow-md backdrop-blur-xs dark:!bg-background/50 "
maskStrokeColor={resolvedTheme == "dark" ? "#272c35" : "#e9edf1"} maskStrokeColor={resolvedTheme == "dark" ? "#FFFFFF1A" : "#cad5e2"}
maskColor={resolvedTheme == "dark" ? "#20252c99" : "#f2f4f733"} maskColor={resolvedTheme == "dark" ? "#21262d88" : "#f1f5f9cc"}
maskStrokeWidth={1} maskStrokeWidth={1}
nodeClassName={"fill-default-300 dark:fill-font/30"}
nodeClassName={"!fill-foreground/20 "}
style={{ style={{
width: 164, width: 148,
height: 128 height: 128
}} }}
/> />
<Background className="dark:bg-background-100 " /> <Background color="#62748e" className="!bg-background dark:!bg-background" />
</ReactFlow> </ReactFlow>
<div <div
className="absolute left-[24px] bottom-[24px] " className={cn("absolute left-[24px] ", {
"bottom-[24px]": !isMobile,
"top-[24px]": isMobile
})}
> >
{ {
isOverlapping && isOverlapping &&
@@ -270,22 +291,30 @@ const DatabasePage: React.FC = () => {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<Button <Button
variant="shadow" variant="destructive"
size="sm" size="icon"
isIconOnly className="shadow-lg size-8"
color="danger" onClick={puls}>
className="size-8 p-1 "
onPressEnd={puls}>
<AlertTriangle className="size-4 text-white" /> <AlertTriangle className="size-4 text-white" />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent className="bg-destructive fill-destructive ">
{t("table.overlapping_tables")} {t("table.overlapping_tables")}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
} }
</div> </div>
{
isMobile &&
<Button size={"icon"} className="absolute bottom-[24px] left-[24px]"
onClick={() => {
openController(!showController)
}}
>
<Menu className="size-5" />
</Button>
}
</div> </div>
} }
@@ -314,7 +343,7 @@ const DatabasePage: React.FC = () => {
} }
export default DatabasePage; export default DatabaseDiagram;
@@ -1,11 +1,12 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/tooltip";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { RelationshipType } from "@/lib/schemas/relationship-schema"; import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider"; import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider"; import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
import { CircularDependencyError } from "@/utils/render/render-uttils"; import { CircularDependencyError } from "@/utils/render/render-uttils";
import { addToast, Alert, Button, Listbox, ListboxItem, toast } from "@heroui/react";
import { AlertTriangle, Trash } from "lucide-react"; import { AlertTriangle, Trash } from "lucide-react";
import React, { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface CircularDependencyAlertProps { interface CircularDependencyAlertProps {
@@ -51,43 +52,40 @@ const CircularDependencyAlert: React.FC<CircularDependencyAlertProps> = ({ error
deleteRelationship(id) deleteRelationship(id)
}, []) }, [])
return ( return (
<div className="flex flex-col gap-2 w-full h-full items-center pt-12 "> <div className="flex flex-col gap-2 w-full h-full items-center pt-12 ">
<AlertTriangle <AlertTriangle
className="size-12 text-danger" className="size-12 text-destructive"
/> />
<h3 className="text-danger font-semibold"> <h3 className="text-destructive font-semibold">
{t("db_controller.circular_dependency.title")} {t("db_controller.circular_dependency.title")}
</h3> </h3>
<p className="text-sm text-font/70 text-center w-[80%] max-w-[360px]"> <p className="text-sm text-muted-foreground text-center w-[80%] max-w-[360px]">
{t("db_controller.circular_dependency.description")} , <span className="font-semibold text-font/90"> {t("db_controller.circular_dependency.suggestion")} </span> {t("db_controller.circular_dependency.description")} , <span className="font-medium text-foreground"> {t("db_controller.circular_dependency.suggestion")} </span>
</p> </p>
<Listbox aria-label="Relationships" className="w-[80%] max-w-[360px]" onAction={focus} <ul aria-label="Relationships" className="w-[80%] max-w-[360px] mt-4"
> >
{ {
circularRelationships.map((relationship: RelationshipType) => ( circularRelationships.map((relationship: RelationshipType) => (
<ListboxItem <li
variant={"faded"} onClick={() => focus(relationship.id)}
className="data-[hover=true]:bg-default-500"
key={relationship.id}> key={relationship.id}>
<div className="flex items-center justify-between tex-font/90"> <div className="hover:bg-secondary flex items-center justify-between h-10 mb-2 px-3 rounded-md cursor-pointer bg-card border-1">
<span> <span className="text-sm">
{relationship.sourceTable.name} -&gt; {relationship.targetTable.name} {relationship.sourceTable.name} -&gt; {relationship.targetTable.name}
</span> </span>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<Button <Button
isIconOnly
variant="light"
color={"danger"} variant={"outline"}
size="sm" size="icon"
onPress={() => removeRelationship(relationship.id)} onClick={() => removeRelationship(relationship.id)}
className="shadow-sm size-7"
> >
<Trash className="text-danger size-4" /> <Trash className="text-destructive size-4" />
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -96,10 +94,10 @@ const CircularDependencyAlert: React.FC<CircularDependencyAlertProps> = ({ error
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</ListboxItem> </li>
)) ))
} }
</Listbox> </ul>
</div> </div>
) )
@@ -0,0 +1,148 @@
import { Ref, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { useDatabase } from "@/providers/database-provider/database-provider";
import { useDiagram } from "@/providers/diagram-provider/diagram-provider";
import { useModal } from "@/providers/modal-provider/modal-provider";
import { Modals } from "@/providers/modal-provider/modal-contxet";
import { getDefaultRelationshipName } from "@/utils/relationship";
import EmptyList from "@/components/empty-list";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { IconFilter2Up, IconPlus } from "@tabler/icons-react";
import { Accordion } from "@/components/ui/accordion";
import RelationshipAccordionItem from "./relationship-accordion-item/relationship-accordion-item";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
const RelationshipController: React.FC = ({ }) => {
const { open } = useModal();
const { database } = useDatabase();
const { relationships: allRelationships } = database || { relationships: [] };
const [relationships, setRelationships] = useState<RelationshipType[]>(allRelationships);
const nameRef: Ref<HTMLInputElement> = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const { focusedRelationshipId , setFocusedRelationshipId } = useDiagram();
useEffect(() => searchRelationships(), [allRelationships]);
useEffect(() => {
if (focusedRelationshipId) {
const accordionItem = document.getElementById(focusedRelationshipId)
if (accordionItem)
accordionItem?.scrollIntoView({
behavior: 'smooth', block: "nearest"
})
}
}, [focusedRelationshipId]);
const onOpen = useCallback(() => {
open(Modals.CREATE_RELATIONSHIP, {
onRlationshipCreated: (id: string) => setFocusedRelationshipId(id)
})
}, []);
const searchRelationships = useCallback(() => {
const keyword: string | undefined = nameRef.current?.value;
if (keyword !== undefined) {
setRelationships(() => {
return allRelationships.filter((relationship: RelationshipType) => {
if (relationship.name)
return relationship.name.toLowerCase().trim().includes(keyword.toLocaleLowerCase().trim());
else
return getDefaultRelationshipName(relationship).trim().toLocaleLowerCase().includes(
keyword.trim().toLocaleLowerCase()
)
})
})
}
}, [nameRef, allRelationships]);
const collapseAll = useCallback(() => {
setFocusedRelationshipId(undefined);
}, []);
return (
<div className="w-full h-full flex flex-col min-h-0">
<div className="flex items-center gap-2 p-3">
<Tooltip >
<TooltipTrigger asChild>
<Button
variant={"ghost"}
size={"icon"}
className="rounded-md h-8 w-8"
onClick={collapseAll}
>
<IconFilter2Up className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("db_controller.collapse")}
</TooltipContent>
</Tooltip>
<Input
className="h-8 bg-secondary dark:bg-background"
placeholder={t("db_controller.filter")}
ref={nameRef}
type="text"
onKeyUp={searchRelationships}
/>
<Tooltip >
<TooltipTrigger asChild>
<Button
variant={"default"}
size={"icon"}
className="rounded-md h-8 w-8"
onClick={onOpen}
>
<IconPlus className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("db_controller.add_relationship")}
</TooltipContent>
</Tooltip>
</div>
{
allRelationships.length > 0 &&
<ScrollArea className="flex-1 px-3 overflow-hidden">
<Accordion
type="single"
collapsible
className="w-full "
value={focusedRelationshipId}
onValueChange={setFocusedRelationshipId}
>
{relationships.map((relationship: RelationshipType) => (
<>
<RelationshipAccordionItem relationship={relationship} key={relationship.id} />
<Separator className="my-1" />
</>
))}
</Accordion>
</ScrollArea>
}
{
(allRelationships.length == 0) &&
<div className="px-3 h-full">
<EmptyList
title={t("db_controller.empty_list.no_relationships")}
description={t("db_controller.empty_list.no_relationships_description")}
/>
</div>
}
</div>
)
}
export default RelationshipController
@@ -0,0 +1,211 @@
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ForeignKeyActions } from "@/lib/field";
import { Cardinality, RelationshipInsertType, RelationshipType } from "@/lib/schemas/relationship-schema";
import { useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
interface RelationshipAccordionContentProps {
relationship: RelationshipType
}
const RelationshipAccordionContent: React.FC<RelationshipAccordionContentProps> = ({ relationship }) => {
const [cardinality, setCardinality] = useState<string>(relationship.cardinality);
const [onDeleteAction, setOnDeleteAction] = useState<string>(relationship.onDelete || ForeignKeyActions.NO_ACTION);
const [onUpdateAction, setOnUpdateAction] = useState<string>(relationship.onUpdate || ForeignKeyActions.NO_ACTION);
const { editRelationship, deleteRelationship } = useDatabaseOperations();
const { t } = useTranslation();
const changeCardinality = (selection: string) => {
setCardinality(selection);
editRelationship({
id: relationship.id,
cardinality: selection as Cardinality,
} as RelationshipInsertType);
}
const changeOnDelete = (selection: string) => {
editRelationship({
id: relationship.id,
onDelete: selection as ForeignKeyActions,
} as RelationshipInsertType);
setOnDeleteAction(selection);
}
const changeOnUpdate = (selection: string) => {
editRelationship({
id: relationship.id,
onUpdate: selection as ForeignKeyActions,
} as RelationshipInsertType);
setOnUpdateAction(selection);
}
const removeRelationship = () => {
deleteRelationship(relationship.id);
}
useEffect(() => {
setCardinality(relationship.cardinality);
}, [relationship.cardinality])
useEffect(() => {
if (!relationship.onDelete)
setOnDeleteAction(ForeignKeyActions.NO_ACTION)
else
setOnDeleteAction(relationship.onDelete as ForeignKeyActions)
}, [relationship.onDelete]);
useEffect(() => {
if (!relationship.onUpdate)
setOnUpdateAction(ForeignKeyActions.NO_ACTION)
else
setOnUpdateAction(relationship.onUpdate as ForeignKeyActions)
}, [relationship.onUpdate]);
if (!relationship.sourceTable || !relationship.targetTable)
return;
return (
<div className="w-full p-2 space-y-4">
<div className="flex">
<div className="w-full space-y-1">
<Label >
{t("db_controller.source_table")}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate text-sm text-muted-foreground">
{relationship.sourceTable?.name}({relationship.sourceField?.name})
</span>
</TooltipTrigger>
<TooltipContent>
{relationship.sourceTable?.name}({relationship.sourceField?.name})
</TooltipContent>
</Tooltip>
</div>
<div className="w-full space-y-1">
<Label >
{t("db_controller.referenced_table")}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate text-sm text-muted-foreground">
{relationship.targetTable?.name}({relationship.targetField?.name})
</span>
</TooltipTrigger>
<TooltipContent>
{relationship.targetTable?.name}({relationship.targetField?.name})
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="cardinality">
{t('db_controller.cardinality.name')}
</Label>
<Select
aria-label="cardinality"
value={cardinality as any}
onValueChange={changeCardinality as any}
>
<SelectTrigger id="cardinality" className="w-full flex ">
<SelectValue placeholder={t('db_controller.cardinality.name')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={Cardinality.one_to_one}>{t("db_controller.cardinality.one_to_one")}</SelectItem>
<SelectItem value={Cardinality.one_to_many}>{t("db_controller.cardinality.one_to_many")}</SelectItem>
<SelectItem value={Cardinality.many_to_one}>{t("db_controller.cardinality.many_to_one")}</SelectItem>
<SelectItem value={Cardinality.many_to_many}>{t("db_controller.cardinality.many_to_many")}</SelectItem>
</SelectContent>
</Select>
</div>
{
cardinality != Cardinality.many_to_many &&
<div className="space-y-4">
<Label>
{t('db_controller.foreign_key_actions.title')}
</Label>
<div className="flex gap-2">
<div className="w-full space-y-2">
<Label >
{t('db_controller.foreign_key_actions.on_delete')}
</Label>
<Select
aria-label="On delete actions"
value={onDeleteAction as any}
onValueChange={changeOnDelete as any}
>
<SelectTrigger className="w-full flex ">
<SelectValue placeholder={t('db_controller.foreign_key_actions.on_delete')} />
</SelectTrigger>
<SelectContent>
{
Object.values(ForeignKeyActions).map((value: string) => (
<SelectItem
key={value}
value={value}>{t(`db_controller.foreign_key_actions.actions.${value}`)}</SelectItem>
))
}
</SelectContent>
</Select>
</div>
<div className="w-full space-y-2">
<div className="w-full space-y-2">
<Label >
{t('db_controller.foreign_key_actions.on_update')}
</Label>
<Select
aria-label="On update actions"
value={onUpdateAction}
onValueChange={changeOnUpdate}
>
<SelectTrigger className="w-full flex ">
<SelectValue placeholder={t('db_controller.foreign_key_actions.on_update')} />
</SelectTrigger>
<SelectContent>
{
Object.values(ForeignKeyActions).map((value: string) => (
<SelectItem
key={value}
value={value}>{t(`db_controller.foreign_key_actions.actions.${value}`)}</SelectItem>
))
}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
}
<div className="flex justify-center">
<Button
variant="destructive"
className="w-full"
onClick={removeRelationship}
>
<Trash2 className="mr-1 size-3.5 text-danger" />
{t("db_controller.delete")}
</Button>
</div>
</div>
)
}
export default RelationshipAccordionContent;
@@ -0,0 +1,30 @@
import {
AccordionContent,
AccordionItem,
} from "@/components/ui/accordion"
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import RelationshipAccordionTrigger from "./relationship-accordion-trigger";
import RelationshipAccordionContent from "./relationship-accordion-content";
interface RelationshipAccordionItemProps {
relationship: RelationshipType
}
const RelationshipAccordionItem: React.FC<RelationshipAccordionItemProps> = ({ relationship }) => {
return (
<AccordionItem value={relationship.id} className="border-none" id={relationship.id}>
<RelationshipAccordionTrigger relationship={relationship} />
<AccordionContent className="flex flex-col gap-4 text-balance">
<RelationshipAccordionContent
relationship={relationship}
/>
</AccordionContent>
</AccordionItem>
)
}
export default RelationshipAccordionItem;
@@ -0,0 +1,138 @@
import { RelationshipInsertType, RelationshipType } from "@/lib/schemas/relationship-schema";
import { useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import hash from "object-hash";
import { getDefaultRelationshipName } from "@/utils/relationship";
import { AccordionTrigger } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { IconCheck, IconDotsVertical, IconFocus2, IconPencil, IconTrash } from "@tabler/icons-react";
import { Input } from "@/components/ui/input";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
interface RelationshipAccordionTriggerProps {
isOpen?: boolean;
relationship: RelationshipType
}
const RelationshipAccordionTrigger: React.FC<RelationshipAccordionTriggerProps> = ({ isOpen, relationship }) => {
const defaultName: string = useMemo(() => {
return getDefaultRelationshipName(relationship);
}, [relationship])
const [editMode, setEditMode] = useState<boolean>(false);
const { editRelationship, deleteRelationship } = useDatabaseOperations();
const { t } = useTranslation();
const [name, setName] = useState<string>(relationship.name ? relationship.name : defaultName);
const { focusOnRelationship } = useDiagramOps();
const editRelationshipName = () => {
if (relationship.name || name.trim().toLocaleLowerCase() != defaultName)
editRelationship({
id: relationship.id,
name
} as RelationshipInsertType);
setEditMode(false);
}
const onDeleteRelationship = () => {
deleteRelationship(relationship.id);
}
useEffect(() => {
setName(relationship.name ? relationship.name : defaultName);
}, [relationship.name]);
return (
<AccordionTrigger className="group h-12 py-3">
{
!editMode &&
<>
<div className="w-full flex">
<label
className="py-2 !truncate text-sm cursor-pointer max-w-80"
>
{relationship.name ? relationship.name : defaultName}
</label>
</div>
<div className="hidden shrink-0 flex-row group-hover:flex gap-2">
<Button variant="outline" size="icon" className="size-8 shrink-0 " onClick={(event: any) => {
event.stopPropagation();
setEditMode(true)
}}>
<IconPencil className="size-4 text-muted-foreground " />
</Button>
<Button variant="outline" size="icon" className="size-8 shrink-0 " onClick={(event: any) => {
event.stopPropagation();
focusOnRelationship(relationship.id, true)
}}>
<IconFocus2 className="size-4 text-muted-foreground " />
</Button>
</div>
</>
}
{
editMode && <>
<Input
placeholder={"Relationship name"}
value={name}
onChange={(event: any) => setName(event.target.value)}
onBlur={editRelationshipName}
autoFocus
type="text"
onClick={(event) => event.stopPropagation()}
className="h-8"
onKeyDown={(e: any) => {
if (e.key === "Enter") {
e.preventDefault();
editRelationshipName();
e.target.blur();
}
}}
/>
<Button variant="ghost" size="icon" className="size-8 shrink-0 rounded-sm" onClick={editRelationshipName}>
<IconCheck className="size-4 text-muted-foreground " />
</Button>
</>
}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8 shrink-0 rounded-sm" >
<IconDotsVertical className="size-4 text-muted-foreground " />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[164px]">
<DropdownMenuLabel>
{t("db_controller.actions")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDeleteRelationship}>
{t("db_controller.delete")}
<DropdownMenuShortcut>
<IconTrash className="size-4 text-destructive" />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</AccordionTrigger>
)
}
export default React.memo(RelationshipAccordionTrigger, (previousState, newState) => {
return hash(previousState) == hash(newState);
});
@@ -0,0 +1,113 @@
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import React, { useEffect, useMemo, useState } from "react";
import { DatabaseType } from "@/lib/schemas/database-schema";
import CircularDependencyAlert from "./circular-dependecy-alert";
import { useTranslation } from "react-i18next";
import Clipboard from "@/components/clipboard";
import { TableType } from "@/lib/schemas/table-schema";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import BaseDatabaseRenderer from "@/utils/render/database/base-database-renderer";
import { CircularDependencyError, getRenderer } from "@/utils/render/render-uttils";
import { areArraysEqual } from "@/utils/utils";
import CodeEditor from "@/components/code-editor";
import useToast from "@/hooks/use-toast";
interface SqlPreviewProps {
tableFilterIds?: string[] ,
className? : string ;
}
const SqlPreview: React.FC<SqlPreviewProps> = ({ tableFilterIds , className}) => {
const { database: currentDatabase } = useDatabase();
const { data_types } = useDatabaseOperations();
const [sqlCode, setSqlCode] = useState<string>("");
const raise = useToast();
const [circularDependency, setCircularDependency] = useState<CircularDependencyError | undefined>(undefined);
const database = useMemo(() => {
if (!tableFilterIds)
return currentDatabase;
return {
...currentDatabase,
tables: currentDatabase?.tables.filter((table: TableType) => tableFilterIds?.includes(table.id)),
relationships: currentDatabase?.relationships.filter((relationship: RelationshipType) =>
tableFilterIds?.includes(relationship.sourceTableId) || tableFilterIds?.includes(relationship.targetFieldId)
),
} as DatabaseType;
}, [currentDatabase, tableFilterIds])
const { t } = useTranslation();
useEffect(() => {
(async () => {
if (database?.dialect && data_types.length > 0) {
try {
const renderer: BaseDatabaseRenderer = getRenderer(database.dialect, data_types);
const sql: string = await renderer.renderDDL(database)
setSqlCode(sql);
setCircularDependency(undefined);
} catch (error) {
if ((error as CircularDependencyError)?.cycle)
setCircularDependency((previousError) => {
if (!previousError)
return error as CircularDependencyError;
else if (Array.isArray(previousError.cycle) && Array.isArray((error as CircularDependencyError).cycle) && !(areArraysEqual(previousError.cycle, (error as CircularDependencyError).cycle)))
return error as CircularDependencyError;
return previousError;
})
}
}
})()
}, [database, data_types]);
useEffect(() => {
if (circularDependency)
raise(
t("db_controller.circular_dependency.title"),
t("db_controller.circular_dependency.description"),
"ERROR"
);
}, [circularDependency]);
if (circularDependency)
return <CircularDependencyAlert error={circularDependency} />
else
return (
<div className="flex w-full h-full relative min-w-0 !min-h-0 ">
<div className="absolute right-2 top-2 z-1">
<Clipboard
text={sqlCode}
/>
</div>
<CodeEditor
defaultValue={sqlCode}
value={sqlCode}
readOnly
className={ className}
/>
</div>
)
}
export default React.memo(SqlPreview);
@@ -0,0 +1,228 @@
import { Code } from "lucide-react"
import { useTranslation } from "react-i18next";
import { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { TableInsertType, TableType } from "@/lib/schemas/table-schema";
import { v4 } from "uuid";
import { useDiagram } from "@/providers/diagram-provider/diagram-provider";
import { useReactFlow } from "@xyflow/react";
import SqlPreview from "../sql-preview";
import EmptyList from "@/components/empty-list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { IconListDetails, IconPlus } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const PADDING_X = 40;
const PADDING_Y = 80;
import {
Accordion,
} from "@/components/ui/accordion"
import TableAccordionItem from "./table-accordion-item/table-accordion-item";
import { ScrollArea } from "@/components/ui/scroll-area";
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { getTableNextSequence } from "@/utils/tables";
import { Separator } from "@/components/ui/separator";
const TablesController: React.FC = ({ }) => {
const { database } = useDatabase();
const { createTable, getInteger, orderTables } = useDatabaseOperations();
const { getViewport } = useReactFlow();
const { tables: allTables } = database || { tables: [] };
const [tables, setTables] = useState<TableType[]>(allTables);
const { t } = useTranslation();
const [showSqlPreview, setShowSqlPreview] = useState<boolean>(false);
const { focusedTableId , setFocusedTableId} = useDiagram();
const nameRef: Ref<HTMLInputElement> = useRef<HTMLInputElement>(null);
const sensors = useSensors(
useSensor(PointerSensor)
);
function handleDragEnd(event: any) {
const { active, over } = event;
if (active.id !== over?.id) {
setTables((items) => {
const oldIndex = items.findIndex((item: TableType) => item.id == active.id);
const newIndex = items.findIndex((item: TableType) => item.id == over.id);
const tables = arrayMove(items, oldIndex, newIndex);
orderTables(tables)
return tables;
});
}
}
useEffect(() => searchTables(), [allTables]);
const addNewTable = useCallback(async () => {
const newTableId: string = v4();
const viewport = getViewport();
const { x, y, zoom } = viewport;
const posX = -x / zoom + (PADDING_X / zoom);
const posY = -y / zoom + (PADDING_Y / zoom);
await createTable({
id: newTableId,
name: `table_${tables.length + 1}`,
posX,
posY,
sequence: getTableNextSequence(tables),
fields: [{
id: v4(),
name: "id",
isPrimary: true,
typeId: getInteger()?.id,
autoIncrement: true,
}]
} as TableInsertType);
setFocusedTableId(newTableId);
}, [database, tables, getViewport, getInteger]);
useEffect(() => {
if (focusedTableId) {
const accordionItem = document.getElementById(focusedTableId)
if (accordionItem)
accordionItem?.scrollIntoView({
behavior: 'smooth', block: "nearest"
})
}
}, [focusedTableId]);
const searchTables = useCallback(() => {
const keyword = nameRef.current?.value;
if (keyword !== undefined)
setTables(() => allTables.filter((table: TableType) => table.name.toLowerCase().trim().includes(keyword?.toLowerCase().trim())))
else
setTables(allTables)
}, [nameRef, allTables]);
const toggleSqlPreview = useCallback(() => {
setShowSqlPreview(preview => !preview);
}, []);
const tableFilterIds = useMemo(() => {
return tables.map((table: TableType) => table.id);
}, [tables]);
return (
<div className="w-full h-full flex flex-col min-h-0">
<div className="flex items-center gap-2 p-3">
<Tooltip >
<TooltipTrigger asChild>
<Button
variant={"ghost"}
size={"icon"}
className="rounded-md h-8 w-8"
onClick={toggleSqlPreview}
>
{showSqlPreview ? (
<IconListDetails className="size-4" stroke={1} />
) : (
<Code className="size-4 text-muted-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("db_controller.show_code")}
</TooltipContent>
</Tooltip>
<Input
className="h-8 bg-secondary dark:bg-background"
placeholder={t("db_controller.filter")}
ref={nameRef}
type="text"
onKeyUp={searchTables}
/>
<Tooltip >
<TooltipTrigger asChild>
<Button
variant={"default"}
size={"icon"}
className="rounded-md h-8 w-8"
onClick={addNewTable}
>
<IconPlus className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("db_controller.add_table")}
</TooltipContent>
</Tooltip>
</div>
{
allTables.length > 0 && !showSqlPreview &&
<ScrollArea className="px-3 h-full w-full overflow-hidden">
<Accordion
type="single"
collapsible
className="w-full"
value={focusedTableId}
onValueChange={setFocusedTableId}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={tables}
strategy={verticalListSortingStrategy}
>
{tables.map((table: TableType) => (
<>
<TableAccordionItem table={table} key={table.id} />
<Separator className="my-1" />
</>
))}
</SortableContext>
</DndContext>
</Accordion>
</ScrollArea>
}
{
allTables.length > 0 && showSqlPreview &&
<div className=" flex-1 overflow-auto ">
<SqlPreview
tableFilterIds={tableFilterIds}
className="rounded-none border-0 border-t-1"
/>
</div>
}
{
(allTables.length == 0) &&
<div className="px-3 h-full">
<EmptyList
title={t("db_controller.empty_list.no_tables")}
description={t("db_controller.empty_list.no_tables_description")}
/>
</div>
}
</div>
)
}
export default TablesController

Some files were not shown because too many files have changed in this diff Show More