Compare commits

..
Author SHA1 Message Date
Soluce TechnologiesandGitHub 37a3ace161 Update env.mjs 2025-11-17 19:01:21 +01:00
Soluce TechnologiesandGitHub 933ab7d68a Update Login Page 2025-11-17 18:59:33 +01:00
charlesgauthereau 411e09f452 Working on the README.md 2025-11-08 21:43:13 +01:00
charlesgauthereau f21149f42e Working on the README.md 2025-11-08 21:04:53 +01:00
charlesgauthereau 099b6fb0e8 Working on the README.md 2025-11-08 21:03:24 +01:00
charlesgauthereau eb2e0ef468 Working on the README.md 2025-11-08 20:07:54 +01:00
charlesgauthereau 4644ed2f41 Working on the README.md 2025-11-08 20:07:49 +01:00
charlesgauthereau cf8e5b7094 Ready for 1.1.3 2025-11-07 22:04:10 +01:00
charlesgauthereau b0775e82ea Updating the README.md 2025-11-04 11:38:22 +01:00
charlesgauthereau f9b3461a44 Ready for 1.1.3-rc.3 release. 2025-11-04 11:37:36 +01:00
charlesgauthereau c4ebc876bf Ready for 1.1.3-rc.3 release. 2025-11-04 09:20:24 +01:00
charlesgauthereau a435a92d41 Ready for 1.1.3-rc.3 release. 2025-11-04 08:48:46 +01:00
charlesgauthereau bcbd6bbea1 Work on passing all env variables on server side. 2025-11-03 18:54:16 +01:00
charlesgauthereau 82559b49bd Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 14:02:50 +01:00
charlesgauthereau 8c1b869004 Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 13:31:05 +01:00
charlesgauthereau c4a0692985 Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 13:20:48 +01:00
charlesgauthereau 750f1cb145 Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 12:49:42 +01:00
charlesgauthereau b7d3487c53 Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 12:46:58 +01:00
charlesgauthereau 529f157c6e Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 12:32:00 +01:00
charlesgauthereau c62be9854a Ready for 1.1.3-rc.2 2025-11-03 11:37:54 +01:00
charlesgauthereau 9c1aa66b41 Ready for 1.1.3-rc.2 2025-11-03 11:37:14 +01:00
charlesgauthereau 9894e2582d Ready for 1.1.3-rc.2 2025-11-03 10:59:26 +01:00
charlesgauthereau 8a8ef81fce Ready for 1.1.3-rc.2 2025-11-03 10:38:10 +01:00
charlesgauthereau 2fdd9f1296 Adding mysql support. 2025-11-01 23:16:32 +01:00
charlesgauthereau 5251e01011 Refactoring. 2025-11-01 19:38:05 +01:00
charlesgauthereau 84dab35f4b Refactoring in roles. 2025-11-01 14:50:28 +01:00
charlesgauthereau ae766dab2f Refactoring in roles. 2025-11-01 14:50:08 +01:00
charlesgauthereau 027928c29f working on edge key with public server key. 2025-10-31 17:55:16 +01:00
charlesgauthereau e3239639f3 For 1.1.3-rc-2 release. 2025-10-27 18:49:24 +01:00
charlesgauthereau 7d6038e5f1 Working on Advanced Encryption Standard (AES), between agent backup and server. 2025-10-26 11:54:25 +01:00
charlesgauthereau 2a90d4933d Ready for rc-1.1.3 2025-10-12 20:33:59 +02:00
charlesgauthereau 1fbd979d40 Ready for 1.1.2 2025-09-28 12:20:03 +02:00
charlesgauthereau 4e06d2bf97 Ready for 1.1.2-rc.3 2025-09-26 09:22:11 +02:00
charlesgauthereau 8ce3988613 Ready for 1.1.2-rc.3 2025-09-26 09:15:22 +02:00
charlesgauthereau b7f161d200 Ready for 1.1.2-rc.3 2025-09-26 09:04:18 +02:00
charlesgauthereau a3f5c4e494 Ready for 1.1.2-rc.2 2025-09-23 21:19:11 +02:00
charlesgauthereau f74f378c0b Ready for 1.1.2-rc.1 2025-09-23 20:50:20 +02:00
charlesgauthereau 385836d5fe Ready for 1.1.2-rc.1 2025-09-23 20:35:48 +02:00
charlesgauthereau 461e3ff7c9 Ready for 1.1.2-rc.1 2025-09-23 19:55:07 +02:00
charlesgauthereau 54a233b207 Ready for 1.1.2-rc.1 2025-09-23 19:50:33 +02:00
charlesgauthereau 2d2453acf7 Refactoring on some files 2025-09-23 19:42:38 +02:00
charlesgauthereau 654ff6b9a5 Finish working on backups side deletion feature, to be review, test and deply in RC for tests. 2025-09-22 20:31:29 +02:00
charlesgauthereau 34ff11f377 Working on table data in detabase backups. 2025-09-19 13:28:40 +02:00
charlesgauthereau b95e145a8b Working on retention policy and cron system. 2025-09-19 09:58:54 +02:00
charlesgauthereau bce6c9d55f Working on retention policy and cron system. 2025-09-18 09:47:08 +02:00
charlesgauthereau c3e2978919 Working on retention policy and cron system. 2025-09-14 16:46:23 +02:00
charlesgauthereau 2d559e456d Adding methods to delete backups on remote storage s3 and local. Some refactoring on models db and adding common.ts model with createdAt, updatedAt, and deletedAt. 2025-08-31 18:24:07 +02:00
charlesgauthereau 45e3b31fda Adding methods to delete backups on remote storage s3 and local. Some refactoring on models db and adding common.ts model with createdAt, updatedAt, and deletedAt. 2025-08-29 15:31:23 +02:00
charlesgauthereau c8da2579e8 Working on the retention system. 2025-08-28 10:01:17 +02:00
charlesgauthereau c7e30e1659 Working on the retention system. 2025-08-28 09:59:32 +02:00
charlesgauthereau 7be23876aa Working on the retention system. 2025-08-28 09:56:10 +02:00
charlesgauthereau 98f1b329ae Working on the retention system. 2025-08-28 09:53:32 +02:00
charlesgauthereau 19ea5543ea Working on the retention system. 2025-08-28 09:47:42 +02:00
charlesgauthereau 010396ba4d Working on the retention system. 2025-08-28 09:45:47 +02:00
charlesgauthereau f055744432 Working on the retention system. 2025-08-28 09:45:32 +02:00
charlesgauthereau 1920b29a55 Working on the retention system. 2025-08-28 09:45:22 +02:00
charlesgauthereau a0c8d1fb68 Working on the retention system. 2025-08-28 09:34:02 +02:00
charlesgauthereau 59dc3b71b8 Working on the retention system. 2025-08-28 09:32:30 +02:00
charlesgauthereau 598eb44952 Working on the retention system. 2025-08-28 09:31:57 +02:00
charlesgauthereau ac49047177 Working on the retention system. 2025-08-28 09:27:47 +02:00
charlesgauthereau 4448c9e166 Working on the retention system. 2025-08-28 09:23:04 +02:00
charlesgauthereau d70778b556 Working on the retention system. 2025-08-28 09:17:57 +02:00
charlesgauthereau 66edb77113 Working on the retention system. 2025-08-28 09:16:54 +02:00
charlesgauthereau 0f7b3eae11 Working on the retention system. 2025-08-27 21:38:58 +02:00
charlesgauthereau 2f9e33f4db Patching a bug when delete an organization, add refetch. 2025-08-27 21:16:13 +02:00
charlesgauthereau dd7a65262c Patching a bug when delete an organization, add refetch. 2025-08-27 18:07:10 +02:00
charlesgauthereau 4c0d2b1f7f Adding new version of the ButtonWithConfirm component. 2025-08-27 18:01:28 +02:00
charlesgauthereau 60728885b6 Working on the retention system. 2025-08-24 20:11:34 +02:00
charlesgauthereau 7ac99a6618 Working on the organization and permissions. 2025-08-24 19:34:18 +02:00
charlesgauthereau 4ed91c1297 Working on the organization and permissions. 2025-08-24 19:33:42 +02:00
charlesgauthereau 44be8009fd Bug on stats. 2025-08-07 16:23:06 +02:00
charlesgauthereau 96630a0ef7 Merge branch 'feature' 2025-08-07 15:46:52 +02:00
charlesgauthereau 8c9d694b40 Bug on init. 2025-08-07 15:46:41 +02:00
charlesgauthereau 1f09c1507a Merge branch 'feature'
# Conflicts:
#	src/features/dashboard/backup/columns.tsx
2025-08-07 15:06:05 +02:00
Soluce TechnologiesandGitHub 141241d555 Update README.md 2025-08-05 08:22:36 +02:00
Soluce TechnologiesandGitHub c605e8173b Update README.md 2025-08-05 08:21:18 +02:00
charles-gauthereau 37eb8f319f Merge remote-tracking branch 'origin/main' 2025-08-04 08:07:38 +02:00
charles-gauthereau 0492d244b3 columns.tsx 2025-08-04 08:07:26 +02:00
charlesgauthereau baeec9f7a0 Some fix on the s3 and local methods for the files saving, we have to improve testing. Working on table actions. 2025-08-03 12:17:10 +02:00
charlesgauthereau 449c9c2cfe Working on the s3 save dump feature. 2025-08-02 20:13:29 +02:00
charlesgauthereau d13e4b7d40 Working on statistics 2025-08-02 11:14:51 +02:00
charlesgauthereau 13241811d0 Placeholder empty state component. 2025-08-02 08:48:45 +02:00
Soluce TechnologiesandGitHub 4b52dc4a1e Update README.md 2025-08-01 09:35:51 +02:00
charlesgauthereau 63cb6cb7a3 Working on some routing errors. 2025-08-01 09:21:37 +02:00
charlesgauthereau 0511ad289c Merge branch 'main' into feature
# Conflicts:
#	README.md
2025-08-01 08:35:20 +02:00
charlesgauthereau 057a51499e Release 2025-08-01 08:35:02 +02:00
charlesgauthereau 1f01d9c372 Release 2025-08-01 08:34:17 +02:00
charlesgauthereau 0684a4887a Merge branch 'main' into feature
# Conflicts:
#	app/(customer)/dashboard/[slug]/statistics/page.tsx
2025-08-01 08:33:26 +02:00
charlesgauthereau 96be14e1ba Working on the s3 feature. 2025-07-31 17:58:40 +02:00
charlesgauthereau eec3c2737b Working on the s3 feature. 2025-07-31 17:32:03 +02:00
charlesgauthereau a6b960ffb2 Working on the s3 feature. 2025-07-29 09:53:38 +02:00
charlesgauthereau d3f465b5d4 Working on the home page. 2025-07-29 08:51:50 +02:00
charlesgauthereau 2139dfd55d For release. 2025-07-28 18:55:49 +02:00
charlesgauthereau 9f53181467 For release. 2025-07-28 18:43:31 +02:00
charlesgauthereau c96e13c72f For release. 2025-07-28 18:18:23 +02:00
charlesgauthereau 58d9179152 For release. 2025-07-28 17:58:58 +02:00
charlesgauthereau 17469696ee For release. 2025-07-28 16:53:21 +02:00
charlesgauthereau 85047e7509 Working on some bugs. 2025-07-27 21:26:31 +02:00
charlesgauthereau f9dc15ba73 Routing error. 2025-07-27 10:23:56 +02:00
charlesgauthereau 7be542f676 Some bug and visuals improvements. 2025-07-26 22:23:00 +02:00
charlesgauthereau 847a567c52 Working on bugs, add delete restore. Review the auth style. working on the RBAC. 2025-07-26 16:45:43 +02:00
charlesgauthereau 1ffbd6efc8 Working on bugs, add delete restore. Review the auth style. working on the RBAC. 2025-07-26 16:34:06 +02:00
charlesgauthereau f8f599e4dd Working on the Organization side. 2025-07-26 14:43:59 +02:00
charles-gauthereau 6562718635 Correcting some bugs in statistics. 2025-01-12 17:53:39 +01:00
280 changed files with 19043 additions and 4398 deletions
+23 -2
View File
@@ -1,4 +1,25 @@
# Ignore Docker-related configs
.dockerignore
docker-compose.yml
README.md
docker-compose*.yml
# Development/Editor config
.idea
.next
.vscode
# Node-related
node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
pnpm-debug.log
# Next.js build output
.next
out
# Docs and markdowns
README.md
*.md
+8 -4
View File
@@ -5,9 +5,9 @@ NODE_ENV=production
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
# Projet
NEXT_PUBLIC_PROJECT_NAME="Portabase"
NEXT_PUBLIC_PROJECT_DESCRIPTION="Portabase is a powerful database manager"
NEXT_PUBLIC_PROJECT_URL=http://app.portabase.io
PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io
PROJECT_SECRET=
# SMTP (email)
@@ -20,6 +20,7 @@ SMTP_FROM=
# Google
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
# S3
S3_ENDPOINT=http://app.s3.portabase.io
@@ -30,4 +31,7 @@ S3_PORT=9000
S3_USE_SSL=true
# Storage Type (s3, local)
STORAGE_TYPE=local
STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
-19
View File
@@ -48,22 +48,3 @@ jobs:
push: true
tags: ${{ steps.set-tags.outputs.tags }}
target: prod
# Do not delete
# - name: Build and push Docker image
# id: push
# uses: docker/build-push-action@v6
# with:
# context: .
# file: ./docker/dockerfile/Dockerfile
# push: true
# tags: ${{ steps.meta.outputs.tags }}
# labels: ${{ steps.meta.outputs.labels }}
# target: prod
# - name: Generate artifact attestation
# uses: actions/attest-build-provenance@v2
# with:
# subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
# subject-digest: ${{ steps.push.outputs.digest }}
# push-to-registry: true
+28
View File
@@ -0,0 +1,28 @@
name: Security Checks
on:
pull_request:
push:
branches: [ main ]
jobs:
sca-deps: # Dependency & container scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@0.20.0
with:
scan-type: 'fs'
format: 'table'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
secrets-gitleaks: # Secrets exposure
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch full history for gitleaks
- uses: gitleaks/gitleaks-action@v2
with:
config-path: .gitleaks.toml # Optional, if you have a custom config
+1 -1
View File
@@ -1,5 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea
# dependencies
/node_modules
/.pnp
+10
View File
@@ -0,0 +1,10 @@
title = "Custom gitleaks config"
[allowlist]
# Global allowlist patterns (wont be flagged)
regexes = [
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
]
paths = [
"src/utils/init.ts"
]
+5
View File
@@ -0,0 +1,5 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks
Binary file not shown.
+4 -2
View File
@@ -1,2 +1,4 @@
migrate:
docker compose run --rm app
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
up:
@bash $(CLUSTER_SCRIPT)
+142 -57
View File
@@ -1,66 +1,130 @@
<br />
<div align="center">
<a href="https://portabase.io">
<img src="images/logo.png" alt="Logo" width="80" height="80">
<img src="/public/images/logo.png" alt="Logo" width="80" height="80">
</a>
<h3 align="center">Portabase</h3>
<p align="center">
Easily backup and restore your database instances with Portabase
<br />
<a href="https://portabase.io"><strong>Explore the Docs »</strong></a>
<br />
<br />
<a href="https://portabase.io">View Demo</a>
·
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a>
·
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
<h3 align="center">Portabase</h3>
<p>
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
</p>
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/RostislavDugin/postgresus)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/RostislavDugin/postgresus)
<p>
<strong>
<a href="https://portabase.io">Documentation</a> •
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">Demo</a> •
<a href="#installation">Installation</a> •
<a href="#contributing">Contributing</a> •
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
</strong>
</p>
![portabase-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/e3929e58-fefd-44cc-ab26-6f8e1a860c43)
</div>
## 📚 Table of Contents
- [About The Project](#about-the-project)
- [Built With](#built-with)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Usage](#usage)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
- [License](#license)
- [Contact](#contact)
- [Acknowledgments](#acknowledgments)
- [About The Project](#-about-the-project)
- [Getting Started](#-getting-started)
- [Usage](#-usage)
- [Roadmap](#-roadmap)
- [Contributing](#-contributing)
- [License](#-license)
- [Contact](#-contact)
- [Acknowledgments](#-acknowledgments)
---
## About The Project
## About The Project
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with Portabase agents for managing operations securely and efficiently.
**Portabase** is a server dashboard tool designed to simplify the backup and restoration of your database instances. It
integrates seamlessly with Portabase agents for managing operations securely and efficiently.
GitHub Repository: [Portabase](https://github.com/Soluce-Technologies/portabase)
### 🔧 Built With
- [![NextJS][NextJS]][NextJS-url] (v15 with App Router)
- [![NextJS][NextJS]][NextJS-url] (v16 with App Router)
- [![Drizzle][Drizzle]][Drizzle-url]
- [![ShadcnUI][ShadcnUI]][ShadcnUI-url]
- BetterAuth
- React Email
- [![BetterAuth][BetterAuth]][BetterAuth-url]
- [![Docker][Docker]][Docker-url]
---
## 🚀 Getting Started
### Prerequisites
### Installation
Ensure Docker is installed on your machine before getting started.
### Installation
### Option 1: Docker Compose Setup
To run Portabase locally:
Create a `docker-compose.yml` file with the following configuration:
```yaml
name: portabase
services:
portabase:
image: solucetechnologies/portabase:latest
env_file:
- .env
ports:
- '8887:80'
environment:
- TIME_ZONE="Europe/Paris"
volumes:
- portabase-private:/app/private
depends_on:
db:
condition: service_healthy
container_name: portabase-app
db:
image: postgres:17-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=<your_database>
- POSTGRES_USER=<database_user>
- POSTGRES_PASSWORD=<database_password>
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U <database_user> -d <your_database>" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
portabase-private:
```
Then run:
```bash
docker compose up -d
```
If you use reverse proxy like
Traefik : [Check this link](https://portabase.io/docs/portabase/advanced-topics/reverse-proxy)
### Option 2: Locally (Development)
1. Clone the repository:
```bash
@@ -76,13 +140,23 @@ To run Portabase locally:
## 🛠️ Usage
Portabase provides a dashboard to manage database instances and backups. It supports:
Portabase provides a web dashboard to manage your database instances and backups.
It currently supports:
- PostgreSQL (current)
- MongoDB (coming soon)
- MySQL (coming soon)
- **PostgreSQL**
- **MySQL**
You can access the dashboard at `http://localhost:3000` after starting the project.
### Process
1. **Access the dashboard** Open `http://localhost:8887` in your browser.
2. **Sign up** Register the first user, who will automatically have the **Admin** role in the default workspace.
3. **Add your first agent** Follow [this guide](https://github.com/Soluce-Technologies/agent-portabase) for setup
instructions.
4. **Create organizations and projects** Link your databases to projects to enable backups and restores.
5. **Configure backup policies** Define schedules (hourly, daily, weekly, or monthly) and retention rules.
6. **Choose a storage provider** Select where backups will be stored (local, S3, etc.).
7. **Save and start** Portabase validates your configuration and starts automated backups based on your defined
policies.
---
@@ -100,7 +174,8 @@ You can access the dashboard at `http://localhost:3000` after starting the proje
- [ ] Extend multi-database support:
- [x] PostgreSQL
- [ ] MongoDB
- [ ] MySQL
- [x] MySQL
- [x] MariaDB
Check out [open issues](https://github.com/Soluce-Technologies/portabase/issues) for more.
@@ -141,27 +216,28 @@ Give the project a ⭐ if you like it!
# Environment
NODE_ENV=production
# Database
# Database
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
# Project Info
NEXT_PUBLIC_PROJECT_NAME="Portabase"
NEXT_PUBLIC_PROJECT_DESCRIPTION="Portabase is a powerful database manager"
NEXT_PUBLIC_PROJECT_URL=http://app.portabase.io
# Project Info
PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io
PROJECT_SECRET=
# SMTP (Email)
# SMTP (Email)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# Google OAuth
# Google OAuth
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
# S3/MinIO Configuration
# S3/MinIO Configuration
S3_ENDPOINT=http://app.s3.portabase.io
S3_ACCESS_KEY=
S3_SECRET_KEY=
@@ -169,10 +245,16 @@ S3_BUCKET_NAME=portabase
S3_PORT=9000
S3_USE_SSL=true
# Storage Backend: 'local' or 's3'
# Storage Backend: 'local' or 's3'
STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
```
To get more information about env variables, check
that [link](https://portabase.io/docs/portabase/advanced-topics/environment)
### Semantic Versioning
Use the following format for Docker image versioning:
@@ -185,14 +267,6 @@ major.minor.patch-rc.release-tag
# Example: 1.0.0-rc.1-dev
```
### Zenstack Support
To regenerate schema:
```bash
zenstack generate
```
---
## 📄 License
@@ -213,11 +287,22 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
Thanks to all contributors and the open-source community!
---
[Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge
[NextJS]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white
[BetterAuth]: https://img.shields.io/badge/Better%20Auth-FFF?logo=betterauth&logoColor=000&style=for-the-badge
[Drizzle]: https://img.shields.io/badge/Drizzle-111?style=for-the-badge&logo=Drizzle&logoColor=c5f74f
[ShadcnUI]: https://img.shields.io/badge/shadcn/ui-000000?style=for-the-badge&logo=shadcn/ui&logoColor=white
[NextJS-url]: https://nextjs.org/
[BetterAuth-url]: https://www.better-auth.com/
[Drizzle-url]: https://orm.drizzle.team/
[ShadcnUI-url]: https://ui.shadcn.com/
[Docker-url]: https://www.docker.com/
+16 -10
View File
@@ -1,19 +1,25 @@
import React from "react";
import {LayoutAdmin} from "@/components/layout";
import {redirect} from "next/navigation";
import {currentUser} from "@/lib/auth/current-user";
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
export default async function Layout({children}: { children: React.ReactNode }) {
const user = await currentUser();
if (user && !user.banned && user.role !== "pending") {
redirect("/dashboard/home");
}
return (
<LayoutAdmin>
<div
className="w-full h-full grid "
>
<div className="flex items-center justify-center py-12">
{children}
</div>
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
<div className="mx-auto w-full max-w-md">
<AuthLogoSection/>
<div>{children}</div>
</div>
</LayoutAdmin>
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
Powered by <span className="font-medium">Soluce Technologies</span>
</footer>
</div>
)
}
+12 -23
View File
@@ -1,29 +1,18 @@
"use client"
import {notFound} from "next/navigation";
import {env} from "@/env.mjs";
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
import {Metadata} from "next";
import {useEffect, useRef} from "react";
import {useSearchParams} from "next/navigation";
export const metadata: Metadata = {
title: "Login",
};
import {LoginForm} from "@/components/wrappers/auth/login/loginForm/LoginForm";
import {toast} from "sonner";
export default function SignInPage(props: {
searchParams: Promise<{ callbackUrl: string | undefined }>
}) {
const searchParams = useSearchParams();
const error = searchParams.get("error");
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current && error) {
toast.error("Error occurred.");
isFirstRender.current = false;
}
}, [error]);
export default async function SignInPage() {
const authGoogleEnabled = env.AUTH_GOOGLE_METHOD;
return (
<div className="mx-auto grid w-[350px] gap-6">
<LoginForm/>
<div className="mx-auto grid w-full gap-6">
<LoginForm authGoogleEnabled={authGoogleEnabled}/>
</div>
)
}
}
+6 -1
View File
@@ -1,9 +1,14 @@
import {PageParams} from "@/types/next";
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Register",
};
export default async function RoutePage(props: PageParams<{}>) {
return (
<div className="mx-auto grid w-[350px] gap-6">
<div className="mx-auto grid w-full gap-6">
<RegisterForm/>
</div>
)
@@ -0,0 +1,47 @@
import {notFound} from "next/navigation";
import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {PageParams} from "@/types/next";
import {Page} from "@/features/layout/page";
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organization/organization-management";
import {buildOrganizationWithMembers} from "@/utils/common";
import {isUUID} from "@/utils/text";
import {user} from "@/db/schema/02_user";
import {invitation} from "@/db/schema/05_invitation";
import {member} from "@/db/schema/04_member";
import {organization} from "@/db/schema/03_organization";
import {user as drizzleUser} from "@/db/schema/02_user";
export default async function RoutePage(props: PageParams<{ organizationId: string }>) {
const {organizationId} = await props.params;
if (!organizationId) {
return notFound();
}
if (!isUUID(organizationId)) {
return notFound();
}
const users = await db.select().from(drizzleUser);
const organizationData = await db
.select({organization, member, user, invitation})
.from(organization)
.leftJoin(member, eq(drizzleDb.schemas.organization.id, member.organizationId))
.leftJoin(invitation, eq(drizzleDb.schemas.invitation.id, invitation.organizationId))
.leftJoin(user, eq(drizzleDb.schemas.member.userId, user.id))
.where(eq(organization.id, organizationId));
const formattedData = buildOrganizationWithMembers(organizationData);
if (!formattedData) return notFound();
return (
<Page>
<OrganizationManagement organization={formattedData} users={users}/>
</Page>
);
}
+27 -16
View File
@@ -1,33 +1,44 @@
import { PageParams } from "@/types/next";
import * as drizzleDb from "@/db";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AdminTabs } from "@/components/wrappers/dashboard/admin/admin-tabs";
import { currentUser } from "@/lib/auth/current-user";
import { db } from "@/db";
import {eq, isNotNull, isNull} from "drizzle-orm";
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {AdminTabs} from "@/components/wrappers/dashboard/admin/admin-tabs";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Admin",
};
export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt)
where: (fields) => isNull(fields.deletedAt),
with: {
accounts: true
}
});
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
// const users = await db.query.user.findMany();
console.log("users",users);
const settings = await db.query.setting.findFirst({
where: (fields, { eq }) => eq(fields.name, "system"),
where: (fields, {eq}) => eq(fields.name, "system"),
});
return (
<Page>
<PageHeader>
<PageTitle>Administration Panel</PageTitle>
</PageHeader>
<PageContent className="mt-10">
<AdminTabs settings={settings!} users={users} />
<PageContent>
<AdminTabs
organizations={organizations}
settings={settings!}
users={users}/>
</PageContent>
</Page>
);
@@ -1,6 +1,6 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AgentForm } from "@/components/wrappers/dashboard/agent/AgentForm/AgentForm";
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import { notFound } from "next/navigation";
import { db } from "@/db";
@@ -1,18 +1,22 @@
import Link from "next/link";
import {GearIcon} from "@radix-ui/react-icons";
import {PageParams} from "@/types/next";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {formatDateLastContact} from "@/utils/date-formatting";
import {buttonVariants} from "@/components/ui/button";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/ProjectCard/ProjectDatabaseCard";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/AgentCardKey/AgentCardKey";
import { db } from "@/db";
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, eq} from "drizzle-orm";
import {eq} from "drizzle-orm";
import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import {capitalizeFirstLetter} from "@/utils/text";
import {Server} from "lucide-react";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
@@ -24,93 +28,65 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
databases: true
}
})
//
console.log(agent)
if (!agent) {
notFound()
}
//
// const databaseId = 'db-123';
//
// const totalBackupsResult = await db
// .select({ count: drizzleDb.schemas.backup.id })
// .from(drizzleDb.schemas.backup)
// .where(eq(drizzleDb.schemas.backup.databaseId, databaseId))
// .execute();
//
// const totalBackups = totalBackupsResult.length;
//
// const successfulBackupsResult = await db
// .select({ count: drizzleDb.schemas.backup.id })
// .from(drizzleDb.schemas.backup)
// .where(
// and(
// eq(drizzleDb.schemas.backup.databaseId, databaseId),
// eq(drizzleDb.schemas.backup.status, "success")
// )
// )
// .execute();
//
//
// const successfulBackups = successfulBackupsResult.length;
//
// const successRate =
// totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{agent.name}
{capitalizeFirstLetter(agent.name)}
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
{/*<AgentModalKey agent={agent}>*/}
{/* <Button variant="outline">*/}
{/* <KeyRound/>*/}
{/* </Button>*/}
{/*</AgentModalKey>*/}
</PageTitle>
<PageActions className="justify-between">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</PageActions>
</div>
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
<PageContent className="flex flex-col w-full h-full">
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">
Backups
</CardHeader>
<CardContent></CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">
Success rate
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
{/*{successRate ?? "Unavailable for now."}*/}
<div className="text-2xl font-bold">{agent.databases.length}</div>
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">
Last contact
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
{formatDateLastContact(agent.lastContact)}
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground">Last contact with agent</p>
</CardContent>
</Card>
</div>
<Card className="w-full sm:w-auto flex-1">
<Card className="w-full sm:w-auto flex-1 mb-4">
<CardHeader className="font-bold text-xl">
Edge Key
</CardHeader>
<CardContent>
<AgentCardKey agent={agent}/>
<AgentCardKey
edgeKey={edgeKey}
/>
</CardContent>
</Card>
<CardsWithPagination data={agent.databases} cardItem={DatabaseCard}/>
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
</PageContent>
</Page>
)
@@ -1,6 +1,11 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AgentForm } from "@/components/wrappers/dashboard/agent/AgentForm/AgentForm";
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Create Agent",
};
export default async function RoutePage(props: PageParams<{}>) {
return (
@@ -9,7 +14,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageTitle>Create new agent</PageTitle>
</PageHeader>
<PageContent>
<AgentForm />
<AgentForm/>
</PageContent>
</Page>
);
@@ -1,15 +1,26 @@
import { PageParams } from "@/types/next";
import { AgentCard } from "@/components/wrappers/dashboard/agent/AgentCard/AgentCard";
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import { Button } from "@/components/ui/button";
import {PageParams} from "@/types/next";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Button} from "@/components/ui/button";
import Link from "next/link";
import { Page, PageActions, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { notFound } from "next/navigation";
import { db } from "@/db";
export const dynamic = "force-dynamic";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq, not} from "drizzle-orm";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Agents",
};
export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany();
const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true))
});
if (!agents) {
notFound();
@@ -27,16 +38,14 @@ export default async function RoutePage(props: PageParams<{}>) {
</PageActions>
)}
</PageHeader>
<PageContent className="mt-10">
<PageContent>
{agents.length > 0 ? (
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} />
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
) : (
<Link
href={"/dashboard/agents/new"}
className=" flex item-center justify-center border-2 border-dashed transition-colors border-primary p-8 lg:p-12 w-full rounded-md"
>
Create new Agent
</Link>
<EmptyStatePlaceholder
url={"/dashboard/agents/new"}
text={"Create new Agent"}
/>
)}
</PageContent>
</Page>
@@ -1,7 +1,7 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {RestoreForm} from "@/components/wrappers/dashboard/database/RestoreForm";
import {RestoreForm} from "@/components/wrappers/dashboard/database/restore-form";
import {currentUser} from "@/lib/auth/current-user";
import {db} from "@/db";
@@ -1,20 +0,0 @@
"use client";
import { authClient } from "@/lib/auth/auth-client";
import { PageParams } from "@/types/next";
export default function RoutePage(props: PageParams<{}>) {
const { data: activeOrganization } = authClient.useActiveOrganization();
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
{JSON.stringify(activeOrganization)}
{activeOrganization?.name}
</div>
);
}
@@ -1,7 +1,7 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { notFound } from "next/navigation";
import { DatabaseForm } from "@/components/wrappers/dashboard/database/DatabaseForm/DatabaseForm";
import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
import { db } from "@/db";
import * as drizzleDb from "@/db";
@@ -27,6 +27,7 @@ export default async function RoutePage(props: PageParams<{ databaseId: string }
<PageContent>
<DatabaseForm
databaseId={databaseId}
// @ts-ignore
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
/>
</PageContent>
@@ -1,25 +1,48 @@
import {PageParams} from "@/types/next";
import {notFound} from "next/navigation";
import {notFound, redirect} from "next/navigation";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/Database/DatabaseTabs";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/Database/DatabaseKpi";
import {EditButton} from "@/components/wrappers/dashboard/database/EditButton/EditButton";
import {CronButton} from "@/components/wrappers/dashboard/database/CronButton/CronButton";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/edit-button";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {db} from "@/db";
import {eq, and} from "drizzle-orm";
import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {capitalizeFirstLetter} from "@/utils/text";
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
const {databaseId} = await props.params;
export default async function RoutePage(props: PageParams<{
projectId: string;
databaseId: string
}>) {
const {projectId, databaseId} = await props.params;
const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization || !activeMember) {
notFound();
}
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId
})
const dbItem = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
with: {
project: true,
retentionPolicy: true
}
});
if (!dbItem) {
notFound();
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
@@ -38,37 +61,64 @@ export default async function RoutePage(props: PageParams<{ databaseId: string }
const isAlreadyBackup = backups.some((b) => b.status === "waiting");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const [totalBackups, successfulBackups] = await Promise.all([
db
.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length),
db
.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(and(eq(drizzleDb.schemas.backup.databaseId, dbItem.id), eq(drizzleDb.schemas.backup.status, "success")))
.then((rows) => rows.length),
]);
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then(rows => rows.length);
const availableBackups = backups.filter(b => !b.deletedAt).length;
const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success")
))
.then(rows => rows.length);
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
notFound();
}
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{dbItem.name}
<EditButton/>
<CronButton database={dbItem}/>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full">
<div className=" w-full md:w-fit">
{capitalizeFirstLetter(dbItem.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full">
<div className="flex items-center gap-2">
{/* Do not delete*/}
{/*<EditButton/>*/}
<RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</div>
</div>
)}
</PageTitle>
<PageActions className="justify-between">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</PageActions>
</div>
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
{dbItem.description && (
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi successRate={successRate} database={dbItem} totalBackups={totalBackups} />
<DatabaseTabs database={dbItem} isAlreadyRestore={isAlreadyRestore} backups={backups} restorations={restorations} />
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
totalBackups={totalBackups}/>
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}/>
</PageContent>
</Page>
);
@@ -1,12 +1,12 @@
import {notFound} from "next/navigation";
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/ProjectsForm/ProjectForm";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {DatabaseWith} from "@/db/schema/06_database";
import {DatabaseWith} from "@/db/schema/07_database";
export default async function RoutePage(props: PageParams<{ projectId: string }>) {
const {projectId} = await props.params;
@@ -42,10 +42,9 @@ export default async function RoutePage(props: PageParams<{ projectId: string }>
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
);
// .filter((db): db is DatabaseWith => db.project !== null);
)
// .filter((db) => db.project !== null) as DatabaseWith[];
console.log("ici34",availableDatabases);
return (
@@ -57,7 +56,7 @@ export default async function RoutePage(props: PageParams<{ projectId: string }>
<ProjectForm
organization={org}
databases={availableDatabases as DatabaseWith[]}
defaultValues={{name: proj.name, slug: proj.slug, databases: proj.databases.map((db) => db.id)}}
defaultValues={{...proj, databases: proj.databases.map((db) => db.id)}}
projectId={proj.id}
/>
</PageContent>
@@ -1,27 +1,30 @@
import { PageParams } from "@/types/next";
import { Page, PageActions, PageContent, PageDescription, PageTitle } from "@/features/layout/page";
import { buttonVariants } from "@/components/ui/button";
import { GearIcon } from "@radix-ui/react-icons";
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {buttonVariants} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import Link from "next/link";
import { ButtonDeleteProject } from "@/components/wrappers/dashboard/projects/ButtonDeleteProject/ButtonDeleteProject";
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import { ProjectDatabaseCard } from "@/components/wrappers/dashboard/projects/ProjectCard/ProjectDatabaseCard";
import { notFound } from "next/navigation";
import {
ButtonDeleteProject
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {ProjectDatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {notFound, redirect} from "next/navigation";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import {getOrganization} from "@/lib/auth/auth";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
export default async function RoutePage(props: PageParams<{
// slug: string;
projectId: string
}>) {
const {
// slug: organizationSlug,
projectId } = await props.params;
projectId
} = await props.params;
const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) {
notFound();
@@ -33,41 +36,58 @@ export default async function RoutePage(props: PageParams<{
if (!org) notFound();
const proj = await db.query.project.findFirst({
where: (proj, { and, eq, not }) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
where: (proj, {
and,
eq,
not
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
with: {
databases: true,
},
});
if (!proj) notFound();
if (!proj) {
redirect("/dashboard/projects");
}
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{proj.name}
<Link className={buttonVariants({ variant: "outline" })} href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7" />
</Link>
{capitalizeFirstLetter(proj.name)}
{!isMember && (
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
)}
</PageTitle>
<PageActions className="justify-between">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"} />
</PageActions>
{!isMember && (
<PageActions className="justify-between">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</PageActions>
)}
</div>
<PageDescription>The list of associated databases</PageDescription>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={proj.databases}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={4}
numberOfColumns={1}
extendedProps={proj}
/>
) : null}
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">You havent added any databases to this project yet.</p>
</div>
)}
</PageContent>
</Page>
);
@@ -1,18 +1,19 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/ProjectsForm/ProjectForm";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {DatabaseWith} from "@/db/schema/06_database";
import {DatabaseWith} from "@/db/schema/07_database";
export default async function RoutePage(props: PageParams<{ }>) {
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
if (!organization ) {
if (!organization) {
notFound();
}
@@ -27,7 +28,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project !== null) as DatabaseWith[];
).filter((db) => db.project == null) as DatabaseWith[];
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
@@ -35,6 +36,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
if (!org) notFound();
return (
<Page>
<PageHeader>
@@ -4,14 +4,21 @@ import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Button} from "@/components/ui/button";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectCard} from "@/components/wrappers/dashboard/projects/ProjectCard/ProjectCard";
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
import {db} from "@/db";
import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next";
export default async function RoutePage(props: PageParams<{ }>) {
export const metadata: Metadata = {
title: "Projects",
};
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) {
notFound();
@@ -27,12 +34,14 @@ export default async function RoutePage(props: PageParams<{ }>) {
databases: true,
},
});
const isMember = activeMember?.role === "member";
return (
<Page>
<PageHeader>
<PageTitle>Projects</PageTitle>
{projects.length > 0 && (
{(projects.length > 0 && !isMember) && (
<PageActions>
<Link href={`/dashboard/projects/new`}>
<Button>+ Create Project</Button>
@@ -41,17 +50,19 @@ export default async function RoutePage(props: PageParams<{ }>) {
)}
</PageHeader>
<PageContent className="mt-10">
<PageContent>
{projects.length > 0 ? (
<CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard}
cardsPerPage={4} numberOfColumns={1}/>
<CardsWithPagination
organizationSlug={organization.slug}
data={projects}
cardItem={ProjectCard}
cardsPerPage={4}
numberOfColumns={1}
/>
) : isMember ? (
<EmptyStatePlaceholder text="No project available"/>
) : (
<Link
href={`/dashboard/projects/new`}
className=" flex item-center justify-center border-2 border-dashed transition-colors border-primary p-8 lg:p-12 w-full rounded-md"
>
Create new Project
</Link>
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
)}
</PageContent>
</Page>
@@ -1,16 +1,24 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm";
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/organization-form/organization-form";
import {getOrganization} from "@/lib/auth/auth";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import {currentUser} from "@/lib/auth/current-user";
export default async function RoutePage(props: PageParams<{
}>) {
const user = await currentUser();
const organization = await getOrganization({});
if (!organization) {
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt)
});
if (!user || !users || !organization || organization.slug == "default") {
notFound();
}
@@ -22,7 +30,7 @@ export default async function RoutePage(props: PageParams<{
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationForm members={organization.members} defaultValues={organization}/>
<OrganizationForm currentUser={user} users={users} defaultValues={organization}/>
</PageContent>
</Page>
)
@@ -1,42 +1,50 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {currentUser} from "@/lib/auth/current-user";
import {getOrganization} from "@/lib/auth/auth";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation";
import {
DeleteOrganizationButton
} from "@/components/wrappers/dashboard/organization/DeleteOrganization/DeleteOrganizationButton";
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/EditButtonSettings/EditButtonSettings";
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
import {
SettingsOrganizationMembersTable
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Settings",
};
export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember()
if (!organization) {
notFound();
}
const isMember = activeMember?.role === "member";
const isOwner = activeMember?.role === "owner";
return (
<Page>
<PageHeader>
<PageTitle className="flex items-center">
Settings
{organization.slug != "default" ?
Organization settings
{!isMember && organization.slug !== "default" && (
<EditButtonSettings/>
: null}
)}
</PageTitle>
<PageActions>
{organization.slug != "default" ?
{isOwner && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
: null}
)}
</PageActions>
</PageHeader>
<PageDescription>
Manage your organization settings.
</PageDescription>
<PageContent>
{/* TODO add the list of organisation members (add, remove, edit) */}
<SettingsOrganizationMembersTable organization={organization}/>
</PageContent>
</Page>
)
@@ -1,15 +1,21 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EvolutionLineChart } from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
import { PercentageLineChart } from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
import { notFound } from "next/navigation";
import { db } from "@/db";
import { asc, count, eq, inArray } from "drizzle-orm";
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {and, asc, count, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganization} from "@/lib/auth/auth";
import {Building2, DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
import {Metadata} from "next";
export default async function RoutePage(props: PageParams<{ }>) {
export const metadata: Metadata = {
title: "Statistics",
};
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
if (!organization) {
@@ -26,7 +32,13 @@ export default async function RoutePage(props: PageParams<{ }>) {
where: eq(drizzleDb.schemas.project.organizationId, org.id),
});
const projectsCount = projects.length;
const projectIds = projects.map(project => project.id);
const databasesOfAllProjects = await db.query.database.findMany({
where: inArray(drizzleDb.schemas.database.projectId, projectIds),
})
const databaseIds = databasesOfAllProjects.map((database) => database.id);
const backupsEvolution = await db.query.backup.findMany({
columns: {
@@ -34,6 +46,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
createdAt: true,
},
orderBy: [asc(drizzleDb.schemas.backup.id)],
where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
});
@@ -44,54 +57,97 @@ export default async function RoutePage(props: PageParams<{ }>) {
_count: count(),
})
.from(drizzleDb.schemas.backup)
.where(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]))
.where(and(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]), inArray(drizzleDb.schemas.backup.databaseId, databaseIds)))
.groupBy(drizzleDb.schemas.backup.createdAt, drizzleDb.schemas.backup.status)
.orderBy(drizzleDb.schemas.backup.createdAt);
console.log(backupsRate);
const restorationsCountResult = await db
.select({
count: count(),
})
.from(drizzleDb.schemas.restoration)
.where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds));
const restorationsCount = restorationsCountResult[0]?.count ?? 0;
const projectsCount = projects.length;
const backupsEvolutionCount = backupsEvolution.length;
const sortedBackupsEvolution = backupsEvolution.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const Placeholder = ({text}: { text: string }) => (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">{text}</div>
);
return (
<Page>
<PageHeader>
<PageTitle>Statistics</PageTitle>
<PageTitle>Statistics Overview</PageTitle>
</PageHeader>
<PageContent className="flex flex-col gap-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="w-full">
<CardHeader>
<CardTitle>Projects</CardTitle>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>{projectsCount}</CardContent>
<CardContent>
<div className="text-2xl font-bold">{projectsCount}</div>
<p className="text-xs text-muted-foreground">Active projects in this organization</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader>
<CardTitle>KPI 2</CardTitle>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Backups</CardTitle>
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent></CardContent>
<CardContent>
<div className="text-2xl font-bold">{backupsEvolutionCount}</div>
<p className="text-xs text-muted-foreground">Total backups executed across all databases</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader>
<CardTitle>KPI 3</CardTitle>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Restorations</CardTitle>
<RefreshCcw className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent></CardContent>
<CardContent>
<div className="text-2xl font-bold">{restorationsCount}</div>
<p className="text-xs text-muted-foreground">Total restoration operations performed</p>
</CardContent>
</Card>
</div>
<div className="flex flex-col md:flex-row gap-4">
<Card className="w-full">
<CardHeader>
<CardTitle>Evolution of the number of backups</CardTitle>
</CardHeader>
<CardContent>
<EvolutionLineChart data={backupsEvolution} />
{sortedBackupsEvolution.length > 0 ? (
<EvolutionLineChart data={sortedBackupsEvolution}/>
) : (
<Placeholder text="No backup data available"/>
)}
</CardContent>
</Card>
<Card className="w-full">
<CardHeader>
<CardTitle>Success rate of backups</CardTitle>
</CardHeader>
<CardContent>
<PercentageLineChart data={backupsRate} />
{backupsRate.length > 0 ? (
<PercentageLineChart data={backupsRate}/>
) : (
<Placeholder text="No backup rate data available"/>
)}
</CardContent>
</Card>
</div>
+139
View File
@@ -0,0 +1,139 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react";
import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {asc, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Home",
};
export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
const organizations = await listOrganizations()
if (!user || !organizations) notFound();
const organizationIds = organizations.map(project => project.id);
const agents = await db.query.agent.findMany({});
const projects = await db.query.project.findMany({
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds),
});
const projectIds = projects.map(project => project.id);
const databasesOfAllProjects = await db.query.database.findMany({
where: inArray(drizzleDb.schemas.database.projectId, projectIds),
})
const databaseIds = databasesOfAllProjects.map((database) => database.id);
const backupsEvolution = await db.query.backup.findMany({
columns: {
id: true,
createdAt: true,
deletedAt: true,
},
orderBy: [asc(drizzleDb.schemas.backup.id)],
where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
});
const availableBackups = backupsEvolution.filter(backup => backup.deletedAt == null);
return (
<Page>
<PageHeader>
<PageTitle>Dashboard</PageTitle>
</PageHeader>
<PageContent className="flex flex-col gap-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Organizations</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{organizations.length}</div>
<p className="text-xs text-muted-foreground">Number of organizations</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<Folder className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projects.length}</div>
<p className="text-xs text-muted-foreground">Number of projects</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Databases</CardTitle>
<Database className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{databasesOfAllProjects.length}</div>
<p className="text-xs text-muted-foreground">Databases across all projects</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Agents</CardTitle>
<Workflow className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{agents.length}</div>
<p className="text-xs text-muted-foreground">Registered agents</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Backups</CardTitle>
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{backupsEvolution.length}</div>
<p className="text-xs text-muted-foreground">All backups recorded</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Available Backups</CardTitle>
<RefreshCcw className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{availableBackups.length}</div>
<p className="text-xs text-muted-foreground">Currently active backups</p>
</CardContent>
</Card>
</div>
{/*Do not delete*/}
{/*<div className="flex flex-1 flex-col gap-4">*/}
{/* <div className="grid auto-rows-min gap-4 md:grid-cols-3">*/}
{/* <div className="aspect-video rounded-xl bg-muted/50"/>*/}
{/* <div className="aspect-video rounded-xl bg-muted/50"/>*/}
{/* <div className="aspect-video rounded-xl bg-muted/50"/>*/}
{/* </div>*/}
{/*</div>*/}
</PageContent>
</Page>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import React from "react";
import {redirect} from "next/navigation";
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
import {AppSidebar} from "@/components/wrappers/dashboard/sideBar/app-sidebar";
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
import {Header} from "@/features/layout/Header";
import {currentUser} from "@/lib/auth/current-user";
+1 -1
View File
@@ -1,4 +1,4 @@
import { LoadingSpinner } from "@/components/wrappers/common/loading/LoadingSpinner";
import { LoadingSpinner } from "@/components/wrappers/common/loading/loading-spinner";
export default function Loading() {
return (
+12 -15
View File
@@ -1,28 +1,30 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {UserForm} from "@/components/wrappers/dashboard/profile/UserForm/UserForm";
import {UserForm} from "@/components/wrappers/dashboard/profile/user-form/user-form";
import {Badge} from "@/components/ui/badge";
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/ButtonDeleteAccount/ButtonDeleteAccount";
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/Avatar/AvatarWithUpload";
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/button-delete-account/button-delete-account";
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
import {currentUser} from "@/lib/auth/current-user";
import {getAccounts, getSessions} from "@/lib/auth/auth";
//import { getAccounts, getSessions } from "@/lib/auth/auth";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Profile",
};
export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
console.log("my user",user);
if (!user) {
return notFound();
}
if (user.role !== "user" && user.role !== "admin" && user.role !== "superadmin") {
return notFound();
}
// const sessions = await getSessions();
// const accounts = await getAccounts();
const sessions = await getSessions();
const accounts = await getAccounts();
return (
<Page>
@@ -42,22 +44,17 @@ export default async function RoutePage(props: PageParams<{}>) {
{user.name}
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
</PageTitle>
<PageActions className="mt-2 hidden sm:block">
<ButtonDeleteAccount text="Delete my account"/>
</PageActions>
</div>
<PageContent>
<UserForm
userId={user.id}
sessions={sessions} accounts={accounts}
defaultValues={{
name: user.name,
email: user.email,
role: user.role ?? undefined,
}}
/>
<div className="mt-4 sm:hidden">
<ButtonDeleteAccount text="Delete my account"/>
</div>
</PageContent>
</Page>
);
+1 -1
View File
@@ -6,7 +6,7 @@ export default async function Index() {
const user = await currentUser();
if (user) {
const currentOrganizationSlug = await getCurrentOrganizationSlug();
redirect(`/dashboard/${currentOrganizationSlug}/projects`);
redirect(`/dashboard/home`);
}
redirect("/login");
//Do not delete
+49
View File
@@ -1,2 +1,51 @@
import fs from "node:fs";
import forge from "node-forge";
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string ): Promise<File> {
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8");
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
// Decrypt AES key with RSA-OAEP
const encryptedAesKey = forge.util.hexToBytes(aesKeyHex);
const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", {
md: forge.md.sha256.create(),
mgf1: {md: forge.md.sha256.create()},
});
// Read encrypted file content
const encryptedBuffer = Buffer.from(await file.arrayBuffer());
const iv = forge.util.hexToBytes(ivHex);
// AES decryption
const decipher = forge.cipher.createDecipher("AES-CBC", aesKey);
decipher.start({iv});
decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary")));
const success = decipher.finish();
if (!success) {
throw new Error("Decryption failed");
}
const decryptedBytes = decipher.output.getBytes();
const decryptedBuffer = Buffer.from(decryptedBytes, "binary");
// Return a File so you can use file.arrayBuffer() later
return new File(
[decryptedBuffer],
file.name.replace(/\.enc$/, fileExtension),
{type: "application/octet-stream"}
);
}
export function getFileExtension(dbType: string) {
switch (dbType) {
case "postgresql":
return ".dump";
case "mysql":
return ".sql";
default:
return ".dump";
}
}
+39 -12
View File
@@ -1,12 +1,15 @@
import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import {uploadLocalPrivate} from "@/features/upload/private/upload.action";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {v4 as uuidv4} from "uuid";
import {eventEmitter} from "../../../events/route";
import {db} from "@/db";
import {Backup} from "@/db/schema/06_database";
import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {Backup} from "@/db/schema/07_database";
import {and, eq} from "drizzle-orm";
import {env} from "@/env.mjs";
import {withUpdatedAt} from "@/db/utils";
import {decryptedDump, getFileExtension} from "./helpers";
export async function POST(
request: Request,
@@ -25,6 +28,8 @@ export async function POST(
const agentId = (await params).agentId;
const formData = await request.formData();
const aesKeyHex = formData.get("aes_key") as string;
const ivHex = formData.get("iv") as string;
const generatedId = formData.get("generatedId") as string | null;
const method = formData.get("method") as string | null;
@@ -48,6 +53,9 @@ export async function POST(
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
with: {
project: true
}
});
if (!database) {
@@ -97,18 +105,36 @@ export async function POST(
if (status === "success") {
const file = formData.get("file") as File | null;
if (!aesKeyHex || !ivHex) {
return NextResponse.json({error: "Missing fields"}, {status: 400});
}
if (!file) {
return NextResponse.json(
{error: "File is required for successful backup"},
{status: 400}
);
}
const fileExtension = getFileExtension(database.dbms)
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
const uuid = uuidv4();
const fileName = `${uuid}.dump`;
const buffer = Buffer.from(await file.arrayBuffer());
const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
const {success, message, filePath} = await uploadLocalPrivate(fileName, buffer);
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
throw new Error("System settings not found.");
}
let success: boolean, message: string, filePath: string;
const result =
settings.storage === "local"
? await uploadLocalPrivate(fileName, buffer)
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
({success, message, filePath} = result);
if (!success) {
return NextResponse.json(
@@ -119,10 +145,10 @@ export async function POST(
await db
.update(drizzleDb.schemas.backup)
.set({
.set(withUpdatedAt({
file: fileName,
status: 'success',
})
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
eventEmitter.emit('modification', {update: true});
@@ -137,7 +163,7 @@ export async function POST(
await db
.update(drizzleDb.schemas.backup)
.set({status: 'failed'})
.set(withUpdatedAt({status: 'failed'}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
eventEmitter.emit('modification', {update: true});
@@ -156,4 +182,5 @@ export async function POST(
{status: 500}
);
}
}
}
-7
View File
@@ -5,7 +5,6 @@ import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
export type BodyResultRestore = {
generatedId: string
status: string
@@ -13,7 +12,6 @@ export type BodyResultRestore = {
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
@@ -26,8 +24,6 @@ export async function POST(
const body: BodyResultRestore = await request.json();
console.log(body)
console.log(agentId)
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
@@ -72,10 +68,7 @@ export async function POST(
eventEmitter.emit('modification', {update: true});
return Response.json(response, {status: 200})
} catch (error) {
console.error('Error in POST handler:', error);
return NextResponse.json(
+72 -25
View File
@@ -1,13 +1,17 @@
import {NextResponse} from "next/server";
import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {Agent} from "@/db/schema/07_agent";
import {Database} from "@/db/schema/06_database";
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
import {Agent} from "@/db/schema/08_agent";
import {Database} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db";
import {and, eq} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod";
import {withUpdatedAt} from "@/db/utils";
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
const databasesResponse = [];
@@ -35,17 +39,15 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
let backupAction: boolean = false
let restoreAction: boolean = false
let UrlBackup: string = ""
let urlBackup: string = ""
if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) {
return NextResponse.json(
{ error: "generatedId is not a valid uuid" },
{ status: 500 }
{error: "generatedId is not a valid uuid"},
{status: 500}
);
}
console.log(db)
console.log(dbmsEnumSchema.parse(db.dbms))
const [databaseCreated] = await dbClient
.insert(drizzleDb.schemas.database)
.values({
@@ -58,62 +60,107 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.returning();
if (databaseCreated) {
databasesResponse.push(formatDatabase(databaseCreated, backupAction,restoreAction, UrlBackup));
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup));
}
} else {
const [databaseUpdated] = await dbClient
.update(drizzleDb.schemas.database)
.set({ lastContact: lastContact })
.set({lastContact: lastContact})
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning();
const backup = await dbClient.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status,"waiting"))
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
})
const restoration = await dbClient.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status,"waiting"))
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting"))
})
if(backup){
if (backup) {
backupAction = true
await dbClient
.update(drizzleDb.schemas.backup)
.set({ status: "ongoing" })
.set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
if(restoration){
if (restoration) {
restoreAction = true
const backupToRestore = await dbClient.query.backup.findFirst({
where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
with: {
database: {
with: {
project: true
}
}
}
})
const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json(
{error: "Unable to find settings"},
{status: 500}
);
}
const fileName = backupToRestore?.file
UrlBackup = await getFileUrlPresignedLocal(fileName ?? "")
let data: SafeActionResult<string, ZodString, readonly [], {
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string>, object> | undefined
try {
if (settings.storage == "local") {
data = await getFileUrlPresignedLocal({fileName: fileName!})
} else if (settings.storage == "s3") {
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
}
if (data?.data?.success) {
urlBackup = data.data.value ?? "";
} else {
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
// @ts-ignore
const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
console.error("Restoration failed: ", errorMessage);
continue;
}
} catch (err) {
console.error("Restoration crashed unexpectedly:", err);
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue;
}
await dbClient
.update(drizzleDb.schemas.restoration)
.set({ status: "ongoing" })
.set({status: "ongoing"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
}
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, UrlBackup));
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup));
}
}
return databasesResponse;
}
}
+11 -6
View File
@@ -18,9 +18,9 @@ export type Body = {
databases: databaseAgent[]
}
// Function to test the get file url presigned local
export async function GET(request: Request) {
const url = await getFileUrlPresignedLocal("d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump")
const url = await getFileUrlPresignedLocal({fileName:"d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
return Response.json({
message: url
})
@@ -34,29 +34,33 @@ export async function POST(
const agentId = (await params).agentId
const body: Body = await request.json();
const lastContact = new Date();
let message: string
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
console.log(message)
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
})
if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404})
message = "Agent not found"
console.log(message)
return NextResponse.json({error: message}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact)
await db
.update(drizzleDb.schemas.agent)
.set({ lastContact: lastContact })
.set({lastContact: lastContact})
.where(eq(drizzleDb.schemas.agent.id, agentId));
eventEmitter.emit('modification', {update: true});
@@ -69,6 +73,7 @@ export async function POST(
databases: databasesResponse
}
console.log(response)
return Response.json(response)
} catch (error) {
console.error('Error in POST handler:', error);
+9
View File
@@ -0,0 +1,9 @@
import {NextResponse} from "next/server";
export async function GET() {
return NextResponse.json({
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
});
}
+22 -12
View File
@@ -1,9 +1,19 @@
import { EventEmitter } from 'events';
import {EventEmitter} from 'events';
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {NextResponse} from "next/server";
export const eventEmitter = new EventEmitter();
export async function GET(request: Request) {
console.log('GET request received');
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({error: "Unauthorized"}, {status: 403});
}
return new Response(
new ReadableStream({
@@ -37,13 +47,13 @@ export async function GET(request: Request) {
);
}
export async function POST(request: Request) {
console.log('POST request received');
const data = await request.json();
console.log('Data received:', data);
// Emit the event to all connected clients
eventEmitter.emit('modification', data);
return new Response('Event sent', { status: 200 });
}
// export async function POST(request: Request) {
// console.log('POST request received');
// const data = await request.json();
// console.log('Data received:', data);
//
// // Emit the event to all connected clients
// eventEmitter.emit('modification', data);
//
// return new Response('Event sent', {status: 200});
// }
+9 -9
View File
@@ -12,16 +12,16 @@ export async function GET(
const expires = searchParams.get('expires');
const fileName = (await params).fileName
const privateLocalDir = "private/uploads/files/";
const filePath = path.join(privateLocalDir, fileName);
const uploadsDir = "private/uploads/files/";
const uploadPath = path.join(uploadsDir, fileName);
const crypto = require('crypto');
if (!fs.existsSync(filePath)) {
return NextResponse.json(
{error: 'File not found'},
{status: 404}
);
let filePath = null;
if (fs.existsSync(uploadPath)) {
filePath = uploadPath;
} else {
return NextResponse.json({error: "File not found"}, {status: 404})
}
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
@@ -31,8 +31,8 @@ export async function GET(
{status: 403}
);
}
//@ts-ignore
const expiresAt = parseInt(expires, 10);
const expiresAt = parseInt(expires!, 10);
if (Date.now() > expiresAt) {
return NextResponse.json(
{error: 'Signed token expired'},
+91 -18
View File
@@ -1,30 +1,103 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
import {env} from "@/env.mjs";
import * as stream from "node:stream";
import path from "path";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import fs from "fs/promises";
import { NextResponse } from "next/server";
export async function GET({ params }: { params: Promise<{ fileName: string }> }) {
function nodeStreamToWebStream(nodeStream: stream.Readable) {
return new ReadableStream({
start(controller) {
nodeStream.on("data", chunk => controller.enqueue(chunk));
nodeStream.on("end", () => controller.close());
nodeStream.on("error", err => controller.error(err));
},
cancel() {
nodeStream.destroy();
},
});
}
const privateS3ImageDir = "images/";
export async function GET(
req: Request,
{params}: { params: Promise<{ fileName: string }> }
) {
const fileName = (await params).fileName;
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
const session = await auth.api.getSession({headers: await headers()});
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) throw new Error("System settings not found.");
const storageType = settings.storage; // "local" or "s3"
const ext = fileName.split(".").pop()?.toLowerCase();
const contentType =
ext === "png"
? "image/png"
: ext === "jpg" || ext === "jpeg"
? "image/jpeg"
: ext === "gif"
? "image/gif"
: ext === "webp"
? "image/webp"
: "application/octet-stream";
try {
const fileName = (await params).fileName;
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
if (storageType === "local") {
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
// Check if the file exists
try {
await fs.access(filePath); // Ensures the file exists
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 });
try {
await fs.access(filePath);
const file = await fs.readFile(filePath);
return new NextResponse(file, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch {
// if not found locally, fallback to S3
}
}
// Read the file
const fileContent = await fs.readFile(filePath); // Returns a Buffer
const exists = await checkFileExistsInBucket({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
return new NextResponse(fileContent, {
const nodeStream = await getObjectFromClient({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
const webStream = nodeStreamToWebStream(nodeStream);
return new NextResponse(webStream, {
headers: {
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Type": "application/octet-stream", // Adjust MIME type as needed
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch (error) {
console.error("Error reading file:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
} catch (err) {
console.error("Error streaming image:", err);
return NextResponse.json({error: "Error fetching file"}, {status: 500});
}
}
}
-23
View File
@@ -1,23 +0,0 @@
import { NextResponse } from "next/server";
export type BodyInit = {
initialize: boolean;
};
export async function POST(request: Request) {
try {
const body: BodyInit = await request.json();
console.log(body);
return NextResponse.json(
{
message: "Initialization successfully done!",
},
{ status: 200 }
);
} catch (error) {
console.error("Error in POST initialization:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
+9 -5
View File
@@ -1,16 +1,19 @@
import React from "react";
import type {Metadata} from "next";
import {Inter} from "next/font/google";
import "./globals.css";
import {Providers} from "./providers";
import {cn} from "@/lib/utils";
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
import {inter} from "@/fonts/fonts";
const inter = Inter({subsets: ["latin"]});
const title = process.env.PROJECT_NAME ?? "App Title";
export const metadata: Metadata = {
title: process.env.NEXT_PUBLIC_PROJECT_NAME ?? "App Title",
description: process.env.NEXT_PUBLIC_PROJECT_DESCRIPTION ?? undefined,
title: {
default: title,
template: `%s - ${title}`
},
description: process.env.PROJECT_DESCRIPTION ?? undefined,
};
export default function RootLayout({
@@ -24,6 +27,7 @@ export default function RootLayout({
<meta name="apple-mobile-web-app-title" content="Portabase"/>
</head>
<body className={cn(inter.className, "h-full")}>
<ConsoleSilencer/>
<Providers>{children}</Providers>
</body>
</html>
+10 -5
View File
@@ -1,10 +1,15 @@
import { redirect } from "next/navigation";
import BackButton from "@/components/wrappers/common/button/back-button";
export default async function NotFound() {
// redirect("/dashboard/profile");
return(
<>
Error
</>
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
<div className="w-full space-y-6 text-center">
<div className="space-y-3">
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
</div>
<BackButton>Go home</BackButton>
</div>
</div>
)
}
+5 -1
View File
@@ -10,11 +10,15 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "radix",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@reui": "https://reui.io/r/{name}.json"
}
}
}
+19 -21
View File
@@ -3,23 +3,22 @@ name: portabase-prod
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: solucetechnologies/portabase:1.1.3-rc.3
ports:
- '8887:80'
environment:
TZ: "Europe/Paris"
env_file:
- .env
depends_on:
db:
condition: service_healthy
container_name: portabase-app-prod
db:
image: postgres:16-alpine
ports:
@@ -36,20 +35,19 @@ services:
timeout: 5s
retries: 5
s3:
image: docker.io/bitnami/minio:latest
ports:
- '9000:9000'
- '9001:9001'
volumes:
- minio_data:/data
environment:
- MINIO_ROOT_USER=${S3_ACCESS_KEY}
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
# s3:
# image: docker.io/bitnami/minio:latest
# ports:
# - '9000:9000'
# - '9001:9001'
# volumes:
# - minio_data:/data
# environment:
# - MINIO_ROOT_USER=${S3_ACCESS_KEY}
# - MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
# - MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
volumes:
postgres-data:
minio_data:
# minio_data:
+33 -68
View File
@@ -1,77 +1,42 @@
name: portabase-dev
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: dev
ports:
- "8887:80"
environment:
- TIME_ZONE="Europe/Paris"
- NODE_ENV=development
depends_on:
db:
condition: service_healthy
volumes:
- .:/app
- /app/node_modules
container_name: portabase-app
develop:
watch:
- action: sync
path: .
target: /app
- action: rebuild
path: package.json
# app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: dev
# ports:
# - "8887:8887"
# environment:
# - TZ=Europe/Paris
# - NODE_ENV=development
# depends_on:
# db:
# condition: service_healthy
# volumes:
# - .:/app
# - /app/node_modules
# container_name: portabase-app
db:
image: postgres:16-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
db-pgadmin:
image: dpage/pgadmin4
environment:
PGADMIN_DEFAULT_EMAIL: "devuser@devuser.devuser"
PGADMIN_DEFAULT_PASSWORD: "changeme"
PGADMIN_CONFIG_SERVER_MODE: "False"
POSTGRES_USER: "devuser"
POSTGRES_PASSWORD: "changeme"
volumes:
- pgadmin-data:/var/lib/pgadmin
ports:
- "8080:80"
restart: unless-stopped
depends_on:
- db
s3:
image: docker.io/bitnami/minio:latest
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
environment:
- MINIO_ROOT_USER=${S3_ACCESS_KEY}
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
db:
image: postgres:16-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
minio_data:
pgadmin-data:
postgres-data:
+2 -4
View File
@@ -1,8 +1,9 @@
FROM node:23-alpine AS base
FROM node:22-alpine AS base
ENV YARN_VERSION=4.9.1
RUN apk add --update --no-cache \
tzdata \
build-base \
libc6-compat \
openssl \
@@ -94,9 +95,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs src/db ./src/db
USER root
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
+6 -5
View File
@@ -1,11 +1,12 @@
#!/bin/bash
npm install -g npm@11.0.0
npm -v
set -euo pipefail
echo "▶ Running Drizzle codegen..."
npx drizzle-kit generate
echo "▶ Applying migrations..."
npx drizzle-kit migrate
npm run dev
exec "$@"
echo "▶ Starting Next.js dev server..."
exec npm run dev
+9 -9
View File
@@ -1,14 +1,14 @@
#!/bin/bash
echo " ____ __ __ "
echo " / __ \____ _____/ /_____ _/ /_ ____ _________ "
echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
echo " "
echo " Community Edition v1.0.0 "
echo " "
if [ -n "$TZ" ]; then
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
ln -sf /usr/share/zoneinfo/$TZ /etc/localtime
echo "$TZ" > /etc/timezone
echo "[INFO] Timezone set to $TZ"
else
echo "[WARN] Timezone '$TZ' not found. Using default."
fi
fi
node server.js
+14
View File
@@ -0,0 +1,14 @@
import {defineConfig, globalIgnores} from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
const eslintConfig = defineConfig([
...nextVitals,
globalIgnores([
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
]),
])
export default eslintConfig
+1 -1
View File
@@ -1,6 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const init = await import("@/utils/init");
init.init();
await init.init();
}
}
-70
View File
@@ -1,70 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { loggingMiddleware } from "@/middleware/loggingMiddleware";
import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers";
export async function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
if (url.pathname.startsWith("/dashboard")) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
// Exclude `/api/auth` and its subpaths
if (url.pathname.startsWith("/api/auth")) {
return NextResponse.next();
}
if (url.pathname.startsWith("/api")) {
const routeExists = checkRouteExists(url.pathname);
// If the route does not exist, return a 404 JSON response
if (!routeExists) {
return new NextResponse(JSON.stringify({ message: "This API route does not exist.", status: 404 }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
}
try {
loggingMiddleware(request);
} catch (err) {
errorHandler(err);
}
}
// Function to check if the route exists (supports dynamic routes)
function checkRouteExists(pathname: string) {
// Define static and dynamic routes with patterns
const routePatterns = [
//do not delete
// /^\/api\/auth\/\d+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
// /^\/api\/auth\/\w+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
// /^\/api\/agent\/healthcheck\/\w+$/, // Dynamic route with an alphanumeric parameter (e.g., /api/user/username)
/^\/api\/agent\/[^/]+\/status\/?$/, // Dynamic route for /api/agent/[id]/status
/^\/api\/agent\/[^/]+\/backup\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/[^/]+\/?$/,
/^\/api\/images\/[^/]+\/?$/,
/^\/api\/events\/?$/,
/^\/api\/init\/?$/,
];
return routePatterns.some((pattern) => pattern.test(pathname));
}
export const config = {
runtime: "nodejs",
matcher: [
// '/api/agent/:path*',
"/api/:path*",
"/dashboard/:path*",
"/dashboard",
],
};
-20
View File
@@ -1,20 +0,0 @@
// next-auth.d.ts
import NextAuth from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name: string;
authMethod: string; // Add authMethod to the session user type
};
}
interface User {
id: string;
email: string;
name: string;
authMethod: string; // Add authMethod to the user type
}
}
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+3 -12
View File
@@ -1,9 +1,9 @@
import type { NextConfig } from "next";
import type {NextConfig} from "next";
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
function buildCSPHeader(): string {
const { CSP } = PORTABASE_DEFAULT_SETTINGS.SECURITY;
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
const directives = [
`default-src ${CSP.DEFAULT_SRC.join(" ")}`,
@@ -12,6 +12,7 @@ function buildCSPHeader(): string {
`img-src ${CSP.IMG_SRC.join(" ")}`,
`font-src ${CSP.FONT_SRC.join(" ")}`,
`object-src ${CSP.OBJECT_SRC.join(" ")}`,
`connect-src ${CSP.CONNECT_SRC.join(" ")}`,
`base-uri ${CSP.BASE_URI.join(" ")}`,
`form-action ${CSP.FORM_ACTION.join(" ")}`,
`frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`,
@@ -35,21 +36,11 @@ function buildPermissionsPolicy(): string {
}
const nextConfig: NextConfig = {
output: "standalone",
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
experimental: {
nodeMiddleware: true,
},
async headers() {
return [
{
+117 -112
View File
@@ -1,114 +1,119 @@
{
"name": "portabase",
"version": "1.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 80",
"build": "next build --experimental-build-mode compile",
"start": "next start",
"lint": "next lint",
"email": "email dev",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:drop": "drizzle-kit drop",
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6",
"@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.76.1",
"@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.17",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0",
"bcrypt": "^6.0.0",
"better-auth": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dockerode": "^4.0.6",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.510.0",
"minio": "^8.0.5",
"next": "^15.4.2-canary.5",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1",
"pg": "^8.16.0",
"react": "19.1.0",
"react-day-picker": "9.7.0",
"react-dom": "19.1.0",
"react-dropzone": "^14.3.8",
"react-email": "^4.0.13",
"react-hook-form": "^7.56.3",
"react-resizable-panels": "^3.0.2",
"react-twc": "^1.4.2",
"recharts": "^2.15.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"ws": "^8.18.2",
"zod": "^3.24.4"
},
"devDependencies": {
"@iconify/react": "^6.0.0",
"@tailwindcss/postcss": "^4.1.7",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18",
"@types/pg": "^8.15.2",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@zenstackhq/openapi": "^2.14.2",
"@zenstackhq/tanstack-query": "^2.14.2",
"drizzle-kit": "^0.31.1",
"eslint": "^9.26.0",
"eslint-config-next": "15.3.2",
"eslint-plugin-tailwindcss": "^3.18.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"zenstack": "2.14.2"
},
"packageManager": "yarn@4.9.1"
"name": "portabase",
"version": "1.1.3",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
"build": "next build --experimental-build-mode compile",
"start": "next start",
"lint": "next lint",
"email": "email dev",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:drop": "drizzle-kit drop",
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6",
"@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.76.1",
"@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.17",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0",
"bcrypt": "^6.0.0",
"better-auth": "1.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dockerode": "^4.0.6",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
"minio": "^8.0.5",
"motion": "^12.23.24",
"next": "16.0.0",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-forge": "^1.3.1",
"nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1",
"pg": "^8.16.0",
"react": "^19.2.0",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-email": "^4.0.13",
"react-hook-form": "^7.56.3",
"react-resizable-panels": "^3.0.2",
"react-twc": "^1.4.2",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"ws": "^8.18.2",
"zod": "^3.24.4"
},
"devDependencies": {
"@iconify/react": "^6.0.0",
"@tailwindcss/postcss": "^4.1.7",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18",
"@types/node-forge": "^1",
"@types/pg": "^8.15.2",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@zenstackhq/openapi": "^2.14.2",
"@zenstackhq/tanstack-query": "^2.14.2",
"drizzle-kit": "^0.31.1",
"eslint": "^9.39.0",
"eslint-config-next": "^16.0.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"zenstack": "2.14.2"
},
"packageManager": "yarn@4.9.1"
}
+44 -5
View File
@@ -3,10 +3,49 @@ export const PORTABASE_DEFAULT_SETTINGS = {
SECURITY: {
CSP: {
DEFAULT_SRC: ["'self'"],
SCRIPT_SRC: ["'self'", "'unsafe-eval'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://www.googletagmanager.com"],
STYLE_SRC: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
IMG_SRC: ["'self'", "blob:", "data:", "https:"],
FONT_SRC: ["'self'"],
SCRIPT_SRC: [
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"https://cdn.jsdelivr.net",
"https://www.googletagmanager.com",
"https://code.iconify.design",
"https://code.iconify.com",
"https://cdn.iconify.design",
"https://api.iconify.design",
],
STYLE_SRC: [
"'self'",
"'unsafe-inline'",
"https://fonts.googleapis.com",
"https://code.iconify.design",
"https://cdn.iconify.design",
"https://code.iconify.com",
],
IMG_SRC: [
"'self'",
"blob:",
"data:",
"https:",
"https://code.iconify.design",
"https://cdn.iconify.design",
"https://code.iconify.com",
"https://api.iconify.design",
"http://localhost:9000",
],
FONT_SRC: [
"'self'",
"https://fonts.gstatic.com",
"https://cdn.iconify.design",
],
CONNECT_SRC: [
"'self'",
"https://api.iconify.design",
"https://code.iconify.design",
"https://api.github.com",
],
OBJECT_SRC: ["'none'"],
BASE_URI: ["'self'"],
FORM_ACTION: ["'self'"],
@@ -22,4 +61,4 @@ export const PORTABASE_DEFAULT_SETTINGS = {
// ...other features
},
},
};
};
+72
View File
@@ -0,0 +1,72 @@
import {NextRequest, NextResponse} from "next/server";
import {loggingMiddleware} from "@/middleware/loggingMiddleware";
import {errorHandler} from "@/middleware/errorHandler";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
if (url.pathname.startsWith("/dashboard")) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
}
if (session.user.banned) {
await auth.api.signOut({headers: await headers()});
return NextResponse.redirect(new URL("/login?error=banned", request.url));
}
if (session.user.role === "pending") {
await auth.api.signOut({headers: await headers()});
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
}
if (url.pathname === "/dashboard") {
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
}
return NextResponse.next();
}
if (url.pathname.startsWith("/api/auth")) {
return NextResponse.next();
}
if (url.pathname.startsWith("/api")) {
const routeExists = checkRouteExists(url.pathname);
if (!routeExists) {
return new NextResponse(JSON.stringify({message: "This API route does not exist.", status: 404}), {
status: 404,
headers: {"Content-Type": "application/json"},
});
}
}
try {
loggingMiddleware(request);
} catch (err) {
errorHandler(err);
}
}
function checkRouteExists(pathname: string) {
const routePatterns = [
/^\/api\/agent\/[^/]+\/status\/?$/,
/^\/api\/agent\/[^/]+\/backup\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/[^/]+\/?$/,
/^\/api\/images\/[^/]+\/?$/,
/^\/api\/events\/?$/,
/^\/api\/init\/?$/,
/^\/api\/config\/?$/,
];
return routePatterns.some((pattern) => pattern.test(pathname));
}
export const config = {
matcher: [
"/api/:path*",
"/dashboard/:path*",
"/dashboard",
]
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+2
View File
@@ -35,6 +35,8 @@ const buttonVariants = cva(
}
)
export type ButtonVariantsProps = VariantProps<typeof buttonVariants>;
function Button({
className,
variant,
+296
View File
@@ -0,0 +1,296 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Star } from 'lucide-react';
import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'motion/react';
import { cn } from '@/lib/utils';
const githubButtonVariants = cva(
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-zinc-950 hover:bg-zinc-900 text-white border-gray-700 dark:bg-zinc-50 dark:border-gray-300 dark:text-zinc-950 dark:hover:bg-zinc-50',
outline: 'bg-background text-accent-foreground border border-input hover:bg-accent',
},
size: {
default: 'h-8.5 rounded-md px-3 gap-2 text-[0.8125rem] leading-none [&_svg]:size-4 gap-2',
sm: 'h-7 rounded-md px-2.5 gap-1.5 text-xs leading-none [&_svg]:size-3.5 gap-1.5',
lg: 'h-10 rounded-md px-4 gap-2.5 text-sm leading-none [&_svg]:size-5 gap-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
interface GithubButtonProps extends React.ComponentProps<'button'>, VariantProps<typeof githubButtonVariants> {
/** Whether to round stars */
roundStars?: boolean;
/** Whether to show Github icon */
fixedWidth?: boolean;
/** Initial number of stars */
initialStars?: number;
/** Class for stars */
starsClass?: string;
/** Target number of stars to animate to */
targetStars?: number;
/** Animation duration in seconds */
animationDuration?: number;
/** Animation delay in seconds */
animationDelay?: number;
/** Whether to start animation automatically */
autoAnimate?: boolean;
/** Callback when animation completes */
onAnimationComplete?: () => void;
/** Whether to show Github icon */
showGithubIcon?: boolean;
/** Whether to show star icon */
showStarIcon?: boolean;
/** Whether to show separator */
separator?: boolean;
/** Whether stars should be filled */
filled?: boolean;
/** Repository URL for actual Github integration */
repoUrl?: string;
/** Button text label */
label?: string;
/** Use in-view detection to trigger animation */
useInViewTrigger?: boolean;
/** In-view options */
inViewOptions?: UseInViewOptions;
/** Spring transition options */
transition?: SpringOptions;
}
function GithubButton({
initialStars = 0,
targetStars = 0,
starsClass = '',
fixedWidth = true,
animationDuration = 2,
animationDelay = 0,
autoAnimate = true,
className,
variant = 'default',
size = 'default',
showGithubIcon = true,
showStarIcon = true,
roundStars = false,
separator = false,
filled = false,
repoUrl,
onClick,
label = '',
useInViewTrigger = false,
inViewOptions = { once: true },
transition,
...props
}: GithubButtonProps) {
const [currentStars, setCurrentStars] = useState(initialStars);
const [isAnimating, setIsAnimating] = useState(false);
const [starProgress, setStarProgress] = useState(filled ? 100 : 0);
const [hasAnimated, setHasAnimated] = useState(false);
// Format number with units
const formatNumber = (num: number) => {
const units = ['k', 'M', 'B', 'T'];
if (roundStars && num >= 1000) {
let unitIndex = -1;
let value = num;
while (value >= 1000 && unitIndex < units.length - 1) {
value /= 1000;
unitIndex++;
}
// Format to 1 decimal place if needed, otherwise show whole number
const formatted = value % 1 === 0 ? value.toString() : value.toFixed(1);
return `${formatted}${units[unitIndex]}`;
}
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
// Start animation
const startAnimation = useCallback(() => {
if (isAnimating || hasAnimated) return;
setIsAnimating(true);
const startTime = Date.now();
const startValue = 0; // Always start from 0 for number animation
const endValue = targetStars;
const duration = animationDuration * 1000;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function for smooth animation
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
// Update star count from 0 to target with more frequent updates
const newStars = Math.round(startValue + (endValue - startValue) * easeOutQuart);
setCurrentStars(newStars);
// Update star fill progress (0 to 100)
setStarProgress(progress * 100);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setCurrentStars(endValue);
setStarProgress(100);
setIsAnimating(false);
setHasAnimated(true);
}
};
setTimeout(() => {
requestAnimationFrame(animate);
}, animationDelay * 1000);
}, [isAnimating, hasAnimated, targetStars, animationDuration, animationDelay]);
// Use in-view detection if enabled
const ref = React.useRef(null);
const isInView = useInView(ref, inViewOptions);
// Reset animation state when targetStars changes
useEffect(() => {
setHasAnimated(false);
setCurrentStars(initialStars);
}, [targetStars, initialStars]);
// Auto-start animation or use in-view trigger
useEffect(() => {
if (useInViewTrigger) {
if (isInView && !hasAnimated) {
startAnimation();
}
} else if (autoAnimate && !hasAnimated) {
startAnimation();
}
}, [autoAnimate, useInViewTrigger, isInView, hasAnimated, startAnimation]);
const navigateToRepo = () => {
if (!repoUrl) {
return;
}
// Next.js compatible navigation approach
try {
// Create a temporary anchor element for reliable navigation
const link = document.createElement('a');
link.href = repoUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
// Temporarily add to DOM and click
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch {
// Fallback to window.open
try {
window.open(repoUrl, '_blank', 'noopener,noreferrer');
} catch {
// Final fallback
window.location.href = repoUrl;
}
}
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (onClick) {
onClick(event);
return;
}
if (repoUrl) {
navigateToRepo();
} else if (!hasAnimated) {
startAnimation();
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
// Handle Enter and Space key presses for accessibility
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (repoUrl) {
navigateToRepo();
} else if (!hasAnimated) {
startAnimation();
}
}
};
return (
<button
ref={ref}
className={cn(githubButtonVariants({ variant, size, className }), separator && 'ps-0')}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label={repoUrl ? `Star ${label} on GitHub` : label}
{...props}
>
{showGithubIcon && (
<div
className={cn(
'h-full relative flex items-center justify-center',
separator && 'w-9 bg-muted/60 border-e border-input',
)}
>
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</div>
)}
{label && <span>{label}</span>}
{/* Animated Star Icon */}
{showStarIcon && (
<div className="relative inline-flex shrink-0">
<Star className="fill-muted-foreground text-muted-foreground" aria-hidden="true" />
<Star
className="absolute top-0 start-0 text-yellow-400 fill-yellow-400"
size={18}
aria-hidden="true"
style={{
clipPath: `inset(${100 - starProgress}% 0 0 0)`,
}}
/>
</div>
)}
{/* Animated Number Counter with Ticker Effect */}
<div className={cn('flex flex-col font-semibold relative overflow-hidden', starsClass)}>
<motion.div
animate={{ opacity: 1 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
...transition,
}}
className="tabular-nums"
>
<span>{currentStars > 0 && formatNumber(currentStars)}</span>
</motion.div>
{fixedWidth && <span className="opacity-0 h-0 overflow-hidden tabular-nums">{formatNumber(targetStars)}</span>}
</div>
</button>
);
}
export { GithubButton, githubButtonVariants };
export type { GithubButtonProps };
+191
View File
@@ -0,0 +1,191 @@
"use client"
import type React from "react"
import { useState, useRef, useEffect, forwardRef } from "react"
import { Search, X } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export interface IEntry {
value: string
label: string
}
interface SearchInputProps {
value?: IEntry
onChange?: (value: IEntry) => void
onSelect?: (value: IEntry) => void
name?: string
placeholder?: string
entries?: IEntry[]
disabled?: boolean
className?: string
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
(
{
value: controlledValue,
onChange,
onSelect,
name,
placeholder = "Search entries...",
entries = [],
disabled = false,
className,
...props
},
ref,
) => {
const [internalValue, setInternalValue] = useState<IEntry | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [filteredEntries, setFilteredEntries] = useState<IEntry[]>([])
const [selectedIndex, setSelectedIndex] = useState(-1)
const internalRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const query = controlledValue?.label ?? internalValue?.label ?? ""
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef
// Filter entries based on query
useEffect(() => {
if (query.trim()) {
const filtered = entries.filter((entry) =>
entry.label.toLowerCase().includes(query.toLowerCase()),
)
setFilteredEntries(filtered)
setSelectedIndex(-1)
} else {
setFilteredEntries([])
}
}, [query, entries])
const handleValueChange = (newValue: IEntry | null) => {
if (controlledValue === undefined) {
setInternalValue(newValue)
}
if (newValue) {
onChange?.(newValue)
}
}
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || filteredEntries.length === 0) return
switch (e.key) {
case "ArrowDown":
e.preventDefault()
setSelectedIndex((prev) =>
prev < filteredEntries.length - 1 ? prev + 1 : 0,
)
break
case "ArrowUp":
e.preventDefault()
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : filteredEntries.length - 1,
)
break
case "Enter":
e.preventDefault()
if (selectedIndex >= 0) {
handleSelect(filteredEntries[selectedIndex])
}
break
case "Escape":
setIsOpen(false)
setSelectedIndex(-1)
inputRef.current?.blur()
break
}
}
const handleSelect = (entry: IEntry) => {
handleValueChange(entry)
onSelect?.(entry)
setIsOpen(false)
setSelectedIndex(-1)
inputRef.current?.blur()
}
const clearSearch = () => {
handleValueChange(null)
setIsOpen(false)
setSelectedIndex(-1)
inputRef.current?.focus()
}
return (
<div className={cn("relative w-full", className)}>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
{...props}
ref={inputRef}
type="text"
name={name}
placeholder={placeholder}
value={query}
disabled={disabled}
onChange={(e) => {
const newLabel = e.target.value
handleValueChange({ value: newLabel, label: newLabel })
}}
onFocus={() => !disabled && setIsOpen(true)}
onBlur={() => {
// Delay closing to allow clicking on entries
setTimeout(() => setIsOpen(false), 150)
}}
onKeyDown={handleKeyDown}
className="pl-10 pr-10"
/>
{query && !disabled && (
<Button
variant="ghost"
size="sm"
onClick={clearSearch}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0 hover:bg-muted"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Results dropdown */}
{isOpen && !disabled && filteredEntries.length > 0 && (
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
<ul ref={listRef} className="max-h-60 overflow-auto py-1" role="listbox">
{filteredEntries.map((entry, index) => (
<li
key={entry.value}
role="option"
aria-selected={index === selectedIndex}
className={cn(
"px-3 py-2 text-sm cursor-pointer transition-colors",
"hover:bg-accent hover:text-accent-foreground",
index === selectedIndex && "bg-accent text-accent-foreground",
)}
onClick={() => handleSelect(entry)}
>
{entry.label}
</li>
))}
</ul>
</div>
)}
{/* No results message */}
{isOpen && !disabled && query && filteredEntries.length === 0 && (
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
<div className="px-3 py-2 text-sm text-muted-foreground">
No results found for "{query}"
</div>
</div>
)}
</div>
)
},
)
SearchInput.displayName = "SearchInput"
+75 -73
View File
@@ -11,119 +11,121 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
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}
/>
<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> & {
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>
<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",
// "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",
// "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}
/>
<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}
/>
<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>) {
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
+235
View File
@@ -0,0 +1,235 @@
'use client';
import * as React from 'react';
import {
useSpring,
useTransform,
motion,
useInView,
type MotionValue,
type SpringOptions,
type UseInViewOptions,
} from 'motion/react';
import useMeasure from 'react-use-measure';
import {cn} from "@/lib/utils";
type SlidingNumberRollerProps = {
prevValue: number;
value: number;
place: number;
transition: SpringOptions;
};
function SlidingNumberRoller({
prevValue,
value,
place,
transition,
}: SlidingNumberRollerProps) {
const startNumber = Math.floor(prevValue / place) % 10;
const targetNumber = Math.floor(value / place) % 10;
const animatedValue = useSpring(startNumber, transition);
React.useEffect(() => {
animatedValue.set(targetNumber);
}, [targetNumber, animatedValue]);
const [measureRef, { height }] = useMeasure();
return (
<span
ref={measureRef}
data-slot="sliding-number-roller"
className="relative inline-block w-[1ch] overflow-x-visible overflow-y-clip leading-none tabular-nums"
>
<span className="invisible">0</span>
{Array.from({ length: 10 }, (_, i) => (
<SlidingNumberDisplay
key={i}
motionValue={animatedValue}
number={i}
height={height}
transition={transition}
/>
))}
</span>
);
}
type SlidingNumberDisplayProps = {
motionValue: MotionValue<number>;
number: number;
height: number;
transition: SpringOptions;
};
function SlidingNumberDisplay({
motionValue,
number,
height,
transition,
}: SlidingNumberDisplayProps) {
const y = useTransform(motionValue, (latest) => {
if (!height) return 0;
const currentNumber = latest % 10;
const offset = (10 + number - currentNumber) % 10;
let translateY = offset * height;
if (offset > 5) translateY -= 10 * height;
return translateY;
});
if (!height) {
return <span className="invisible absolute">{number}</span>;
}
return (
<motion.span
data-slot="sliding-number-display"
style={{ y }}
className="absolute inset-0 flex items-center justify-center"
transition={{ ...transition, type: 'spring' }}
>
{number}
</motion.span>
);
}
type SlidingNumberProps = React.ComponentProps<'span'> & {
number: number | string;
inView?: boolean;
inViewMargin?: UseInViewOptions['margin'];
inViewOnce?: boolean;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
transition?: SpringOptions;
};
function SlidingNumber({
ref,
number,
className,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
padStart = false,
decimalSeparator = '.',
decimalPlaces = 0,
transition = {
stiffness: 200,
damping: 20,
mass: 0.4,
},
...props
}: SlidingNumberProps) {
const localRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(ref, () => localRef.current!);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
const prevNumberRef = React.useRef<number>(0);
const effectiveNumber = React.useMemo(
() => (!isInView ? 0 : Math.abs(Number(number))),
[number, isInView],
);
const formatNumber = React.useCallback(
(num: number) =>
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
[decimalPlaces],
);
const numberStr = formatNumber(effectiveNumber);
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.');
const newIntStr =
padStart && newIntStrRaw?.length === 1 ? '0' + newIntStrRaw : newIntStrRaw;
const prevFormatted = formatNumber(prevNumberRef.current);
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.');
const prevIntStr =
padStart && prevIntStrRaw.length === 1
? '0' + prevIntStrRaw
: prevIntStrRaw;
const adjustedPrevInt = React.useMemo(() => {
return prevIntStr.length > (newIntStr?.length ?? 0)
? prevIntStr.slice(-(newIntStr?.length ?? 0))
: prevIntStr.padStart(newIntStr?.length ?? 0, '0');
}, [prevIntStr, newIntStr]);
const adjustedPrevDec = React.useMemo(() => {
if (!newDecStrRaw) return '';
return prevDecStrRaw.length > newDecStrRaw.length
? prevDecStrRaw.slice(0, newDecStrRaw.length)
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0');
}, [prevDecStrRaw, newDecStrRaw]);
React.useEffect(() => {
if (isInView) prevNumberRef.current = effectiveNumber;
}, [effectiveNumber, isInView]);
const intDigitCount = newIntStr?.length ?? 0;
const intPlaces = React.useMemo(
() =>
Array.from({ length: intDigitCount }, (_, i) =>
Math.pow(10, intDigitCount - i - 1),
),
[intDigitCount],
);
const decPlaces = React.useMemo(
() =>
newDecStrRaw
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
Math.pow(10, newDecStrRaw.length - i - 1),
)
: [],
[newDecStrRaw],
);
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0;
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0;
return (
<span
ref={localRef}
data-slot="sliding-number"
className={cn('flex items-center', className)}
{...props}
>
{isInView && Number(number) < 0 && <span className="mr-1">-</span>}
{intPlaces.map((place) => (
<SlidingNumberRoller
key={`int-${place}`}
prevValue={parseInt(adjustedPrevInt, 10)}
value={parseInt(newIntStr ?? '0', 10)}
place={place}
transition={transition}
/>
))}
{newDecStrRaw && (
<>
<span>{decimalSeparator}</span>
{decPlaces.map((place) => (
<SlidingNumberRoller
key={`dec-${place}`}
prevValue={prevDecValue}
value={newDecValue}
place={place}
transition={transition}
/>
))}
</>
)}
</span>
);
}
export { SlidingNumber, type SlidingNumberProps };
@@ -0,0 +1,31 @@
"use client"
import {env} from "@/env.mjs";
import React, {useEffect, useState} from "react";
import {useTheme} from "next-themes";
export const AuthLogoSection = () => {
const {resolvedTheme} = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
return (
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
<img
className="p-12 text-black dark:text-white"
src={imageTheme}
alt="Logo"
/>
<span className="text-sm text-muted-foreground -ml-12 -mb-12">v{env.NEXT_PUBLIC_PROJECT_VERSION}</span>
</div>
)
}
@@ -1,10 +1,11 @@
"use client";
import { Button } from "@/components/ui/button";
import { signIn } from "@/lib/auth/auth-client";
import { JSX } from "react";
import {Button} from "@/components/ui/button";
import {signIn} from "@/lib/auth/auth-client";
import {JSX} from "react";
export type AuthButtonProps = {
providers: SocialProviderType[];
callBackURL?: string;
};
export type SocialProviderType = {
@@ -41,7 +42,7 @@ export const SocialAuthButton = (props: AuthButtonProps): JSX.Element => {
e.preventDefault();
void signIn.social({
provider: provider.id,
callbackURL: "/dashboard/profile",
callbackURL: props.callBackURL ?? "/dashboard/profile",
});
}}
>
@@ -0,0 +1,308 @@
// "use client";
//
// import {Card, CardContent, CardHeader} from "@/components/ui/card";
// import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
// import {Input} from "@/components/ui/input";
// import {Form} from "@/components/ui/form";
// import {Button} from "@/components/ui/button";
// import {toast} from "sonner";
// import {useMutation} from "@tanstack/react-query";
// import {TooltipProvider} from "@/components/ui/tooltip";
// import Link from "next/link";
// import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
// import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
// import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
// import {signIn} from "@/lib/auth/auth-client";
// import {useRouter} from "next/navigation";
// import {Icon} from "@iconify/react";
// import {useEffect, useState} from "react";
//
// export type loginFormProps = {
// defaultValues?: LoginType;
// authGoogleEnabled: boolean;
//
// };
//
// export const LoginForm = (props: loginFormProps) => {
// const router = useRouter();
//
// const form = useZodForm({
// schema: LoginSchema,
// });
//
// const [urlParams, setUrlParams] = useState<URLSearchParams>();
//
// useEffect(() => {
// const urlParams = new URLSearchParams(window.location.search);
// console.log(urlParams);
// setUrlParams(urlParams);
// const error = urlParams.get("error");
// console.log(urlParams.get("redirect"));
// if (error?.includes("pending")) {
// toast.error("Your account is not active.");
// urlParams.delete("error");
// window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
// }
// }, []);
//
//
// const mutation = useMutation({
// mutationFn: async (values: LoginType) => {
// const {error} = await signIn.email(
// {
// password: values.password,
// email: values.email,
// callbackURL: urlParams?.get("redirect") ?? "/dashboard/profile",
// }, {
// onSuccess: () => {
// toast.success("Login success");
// },
// });
// if (error) {
// toast.error(error.message);
// }
// },
// });
//
// const availableProviders: SocialProviderType[] = [];
//
// if (props.authGoogleEnabled) {
// availableProviders.push(
// {
// id: "google",
// name: "Google",
// icon: <Icon icon={"logos:google-icon"} width="25" height="25"/>,
// },
// )
// }
//
//
// return (
// <TooltipProvider>
// <Card>
// <CardHeader>
// <div className="grid gap-2 text-center mb-2">
// <h1 className="text-3xl font-bold">Login</h1>
// <p className="text-balance text-muted-foreground">Enter your informations below to login</p>
// </div>
// </CardHeader>
// <CardContent>
// <Form
// form={form}
// className="flex flex-col gap-4"
// onSubmit={async (values) => {
// await mutation.mutateAsync(values);
// }}
// >
// <FormField
// control={form.control}
// name="email"
// defaultValue=""
// render={({field}) => (
// <FormItem>
// <FormLabel>Email</FormLabel>
// <FormControl>
// <Input autoComplete="email webauthn"
// placeholder="exemple@portabase.io" {...field} />
// </FormControl>
// <FormMessage/>
// </FormItem>
// )}
// />
// <FormField
// control={form.control}
// name="password"
// defaultValue=""
// render={({field}) => (
// <FormItem>
// <div className="flex items-center">
// <FormLabel>Password</FormLabel>
// {/* <Link href={"/forgot-password"} className="ml-auto inline-block text-sm underline">
// Forgot your password?
// </Link>*/}
// </div>
// <FormControl>
// <PasswordInput autoComplete="current-password webauthn"
// placeholder="Your password" {...field} />
// </FormControl>
// <FormMessage/>
// </FormItem>
// )}
// />
// <Button>Sign in</Button>
// <div className="mt-4 text-center text-sm">
// Don&apos;t have an account?{" "}
// <Link href={"/register"} className="underline">
// Sign up
// </Link>
// </div>
// </Form>
// <SocialAuthButton
// callBackURL={urlParams?.get("redirect") ?? "/dashboard/profile"}
// providers={availableProviders}/>
// </CardContent>
// </Card>
// </TooltipProvider>
// );
// };
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {toast} from "sonner";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import Link from "next/link";
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
import {signIn} from "@/lib/auth/auth-client";
import {useRouter} from "next/navigation";
import {Icon} from "@iconify/react";
import {useEffect, useState} from "react";
export type loginFormProps = {
defaultValues?: LoginType;
authGoogleEnabled: boolean;
};
export const LoginForm = (props: loginFormProps) => {
const router = useRouter();
const form = useZodForm({
schema: LoginSchema,
});
const [urlParams] = useState(() =>
new URLSearchParams(typeof window !== "undefined" ? window.location.search : "")
);
useEffect(() => {
const error = urlParams.get("error");
if (error?.includes("pending")) {
toast.error("Your account is not active.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
}, [urlParams]);
const mutation = useMutation({
mutationFn: async (values: LoginType) => {
try {
const callbackURL =
urlParams.get("redirect")?.startsWith("/")
? urlParams.get("redirect")
: "/dashboard/profile";
const {error} = await signIn.email({
email: values.email,
password: values.password,
callbackURL: callbackURL ?? "/dashboard/profile",
});
if (error) {
toast.error(error.message);
} else {
toast.success("Login success");
}
} catch (err) {
console.error(err);
toast.error("Unexpected client error during login");
}
},
onError: (err: any) => {
toast.error(err.message || "Client error");
},
});
const availableProviders: SocialProviderType[] = [];
if (props.authGoogleEnabled) {
availableProviders.push({
id: "google",
name: "Google",
icon: <Icon icon="logos:google-icon" width="25" height="25"/>,
});
}
return (
<TooltipProvider>
<Card>
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">
Enter your information below to login
</p>
</div>
</CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
autoComplete="email"
placeholder="example@portabase.io"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<div className="flex items-center">
<FormLabel>Password</FormLabel>
{/* Optional forgot password link */}
</div>
<FormControl>
<PasswordInput
autoComplete="current-password"
placeholder="Your password"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Signing in..." : "Sign in"}
</Button>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{" "}
<Link href="/register" className="underline">
Sign up
</Link>
</div>
</Form>
<SocialAuthButton
callBackURL={urlParams.get("redirect") ?? "/dashboard/profile"}
providers={availableProviders}
/>
</CardContent>
</Card>
</TooltipProvider>
);
};
@@ -1,115 +0,0 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import Link from "next/link";
import { PasswordInput } from "@/components/wrappers/auth/PaswordInput/password-input";
import { LoginSchema, LoginType } from "@/components/wrappers/auth/login/loginForm/login-form.schema";
import { SocialAuthButton, SocialProviderType } from "@/components/wrappers/auth/login/buttonAuth/SocialAuthButton";
import { signIn } from "@/lib/auth/auth-client";
import { useRouter } from "next/navigation";
import { Icon } from "@iconify/react";
export type loginFormProps = {
defaultValues?: LoginType;
};
export const LoginForm = (props: loginFormProps) => {
const router = useRouter();
const form = useZodForm({
schema: LoginSchema,
});
const mutation = useMutation({
mutationFn: async (values: LoginType) => {
const { error } = await signIn.email(values, {
onSuccess: () => {
toast.success("Login success");
router.push("/dashboard/profile");
},
});
if (error) {
toast.error(error.message);
}
},
});
const availableProviders: SocialProviderType[] = [
{
id: "google",
name: "Google",
icon: <Icon icon={"logos:google-icon"} width="25" height="25" />,
},
];
return (
<TooltipProvider>
<Card>
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">Enter your informations below to login</p>
</div>
</CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input autoComplete="email webauthn" placeholder="exemple@portabase.io" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormLabel>Password</FormLabel>
{/* <Link href={"/forgot-password"} className="ml-auto inline-block text-sm underline">
Forgot your password?
</Link>*/}
</div>
<FormControl>
<PasswordInput autoComplete="current-password webauthn" placeholder="Your password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button>Sign in</Button>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{" "}
<Link href={"/register"} className="underline">
Sign up
</Link>
</div>
</Form>
<SocialAuthButton providers={availableProviders} />
</CardContent>
</Card>
</TooltipProvider>
);
};
@@ -12,7 +12,7 @@ import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { TooltipProvider, TooltipTrigger, Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { RegisterSchema, RegisterType } from "@/components/wrappers/auth/register/register-form/register-form.schema";
import { PasswordInput } from "@/components/wrappers/auth/PaswordInput/password-input";
import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
import {signUp} from "@/lib/auth/auth-client";
export type registerFormProps = {
@@ -30,8 +30,8 @@ export const RegisterForm = (props: registerFormProps) => {
await signUp.email(values, {
onSuccess: () => {
toast.success(`Success`);
router.push(`/login`);
router.refresh();
router.push(`/login`);
},
onError: (error) => {
console.log(error);
@@ -0,0 +1,95 @@
"use client"
import {
Breadcrumb,
BreadcrumbItem, BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import {usePathname} from "next/navigation";
import Link from "next/link";
import {useIsMobile} from "@/hooks/use-mobile";
import {capitalizeFirstLetter, isUUID} from "@/utils/text";
export function useBreadCrumbs() {
const pathname = usePathname();
const route_history = pathname
.split("/")
.filter((x: any) => x && x.length > 0);
const breadcrumb_routes = route_history.reduce(
(acc: { name: string; path: string }[], route) => {
const prev_path = acc[acc.length - 1]?.path ?? "";
acc.push({name: route, path: `${prev_path}/${route}`});
return acc;
},
[],
);
return {breadcrumb_routes};
}
interface BreadCrumbsProps {
}
export function BreadCrumbsWrapper() {
const isMobile = useIsMobile()
return (
<>
{!isMobile ? <BreadCrumbs/> : null}
</>
)
}
const FORBIDDEN_LINKS = ["organization", "dashboard", "database"];
export function BreadCrumbs({}: BreadCrumbsProps) {
const {breadcrumb_routes} = useBreadCrumbs();
if (breadcrumb_routes.length < 2) return null;
return (
<div className="flex w-full flex-wrap px-3 md:justify-end">
<Breadcrumb>
<BreadcrumbList>
{breadcrumb_routes.map((crumb: any) => {
const label = isUUID(crumb.name) ? "details" : crumb.name;
const isLast = breadcrumb_routes.length - 1 === breadcrumb_routes.indexOf(crumb);
const isForbidden = FORBIDDEN_LINKS.includes(crumb.name.toLowerCase());
return (
<div className="flex items-center gap-2" key={crumb.path}>
<BreadcrumbItem key={crumb.path}>
{isLast ? (
<BreadcrumbPage>{capitalizeFirstLetter(label)}</BreadcrumbPage>
) : (
<> {isForbidden ?
<BreadcrumbLink asChild>
<Link
href={crumb.path}
onClick={(e) => e.preventDefault()}
className="cursor-not-allowed "
>
{capitalizeFirstLetter(label)}
</Link>
</BreadcrumbLink>
:
<BreadcrumbLink asChild>
<Link href={crumb.path}>{capitalizeFirstLetter(label)}</Link>
</BreadcrumbLink>
}
</>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator className="hidden md:block"/>}
</div>
);
})}
</BreadcrumbList>
</Breadcrumb>
</div>
);
}
@@ -0,0 +1,18 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type BackButtonProps = React.ComponentProps<typeof Button> & {
children: React.ReactNode;
};
export default function BackButton({ children, ...props }: BackButtonProps) {
const router = useRouter();
return (
<Button onClick={() => router.back()} aria-label={children?.toString()} {...props}>
{children}
</Button>
);
}
@@ -1,45 +1,161 @@
"use client";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { Loader2 } from "lucide-react";
export type VariantButton = {
secondary: string;
default: string;
outline: string;
ghost: string;
link: string;
destructive: string;
};
// "use client";
//
// import { Button } from "@/components/ui/button";
// import { useState } from "react";
// import { Loader2 } from "lucide-react";
//
// export type VariantButton = {
// secondary: string;
// default: string;
// outline: string;
// ghost: string;
// link: string;
// destructive: string;
// };
//
// export type ButtonWithConfirmProps = {
// icon?: any;
// text: string;
// variant?: keyof VariantButton;
// className?: string;
// onClick?: () => void;
// isPending?: boolean;
// };
//
// export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
// const [isConfirming, setIsConfirming] = useState(false);
//
// return (
// <Button
// onClick={() => {
// if (isConfirming && props.onClick) {
// props.onClick();
// } else {
// setIsConfirming(true);
// }
// }}
// variant={props.variant ? props.variant : "default"}
// className={props.className}
// >
// {props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
// {isConfirming ? "Are you sure ?" : `${props.text}`}
// <>{props.icon ? props.icon : null}</>
// </Button>
// );
// };
"use client"
import {Button, ButtonVariantsProps} from "@/components/ui/button";
import {useState} from "react";
import {Loader2} from "lucide-react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
export type ButtonWithConfirmProps = {
icon?: any;
text: string;
variant?: keyof VariantButton;
className?: string;
onClick?: () => void;
title: string;
description: string;
button: {
main: {
className?: string;
text?: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
disabled?: boolean;
tooltipText?: string;
};
confirm: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
cancel: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
};
isPending?: boolean;
};
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false);
return (
<Button
onClick={() => {
if (isConfirming && props.onClick) {
props.onClick();
} else {
setIsConfirming(true);
}
}}
variant={props.variant ? props.variant : "default"}
className={props.className}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
{isConfirming ? "Are you sure ?" : `${props.text}`}
<>{props.icon ? props.icon : null}</>
</Button>
<Popover open={isConfirming} onOpenChange={!props.button.main.disabled ? setIsConfirming : undefined}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<span
className={cn(
"inline-flex",
props.button.main.disabled ? "cursor-not-allowed" : ""
)}
role="button">
<Button
disabled={!!props.button.main.disabled}
variant={props.button.main.variant ?? "default"}
size={props.button.main.size ?? "default"}
className={props.button.main.className}
onClick={() => {
if (!props.button.main.disabled) setIsConfirming(true);
}}
>
{props.button.main.icon}
{props.button.main.text && <span>{props.button.main.text}</span>}
</Button>
</span>
</PopoverTrigger>
</TooltipTrigger>
{props.button.main.tooltipText && props.button.main.disabled && (
<TooltipContent>
<p>{props.button.main.tooltipText}</p>
</TooltipContent>
)}
</Tooltip>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">{props.title}</h4>
<p className="text-sm text-muted-foreground">{props.description}</p>
</div>
<div className="grid gap-2">
<Button
onClick={() => {
if (isConfirming && props.button.confirm.onClick) {
props.button.confirm.onClick();
setIsConfirming(false);
} else {
setIsConfirming(true);
}
}}
variant={props.button.confirm.variant ? props.button.confirm.variant : "default"}
size={props.button.main.size ?? "default"}
className={cn(props.button.main.className, "w-full")}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button.confirm.icon ? props.button.confirm.icon : null}
<span>{props.button.confirm.text}</span>
</Button>
<Button
variant={props.button.cancel.variant ? props.button.cancel.variant : "default"}
onClick={props.button.cancel.onClick ? () => props.button.cancel.onClick!() : () => setIsConfirming(false)}
className={cn(props.button.main.className, "w-full")}
size={props.button.main.size ?? "default"}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
@@ -1,8 +1,66 @@
"use client";
// "use client";
//
// import { Button } from "@/components/ui/button";
// import { ButtonHTMLAttributes } from "react";
// import { Loader2 } from "lucide-react";
//
// export type VariantButton = {
// secondary: string;
// default: string;
// outline: string;
// ghost: string;
// link: string;
// destructive: string;
// };
// export type sizeButton = {
// default: string;
// icon: string;
// sm: string;
// lg: string;
// };
//
// export type ButtonWithConfirmProps = {
// icon?: any;
// text: string;
// variant?: keyof VariantButton;
// className?: string;
// onClick: () => void;
// isPending?: boolean;
// size: keyof sizeButton;
// };
//
// export const ButtonWithLoading = ({
// icon,
// text,
// variant,
// className,
// onClick,
// isPending,
// size,
// ...props // catch all remaining props
// }: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
// return (
// <Button
// onClick={() => {
// onClick();
// }}
// variant={variant ? variant : "default"}
// className={className}
// {...props} // forward the remaining props to the Button component
// size={size || "default"}
// >
// {isPending && <Loader2 className="animate-spin mr-4" size={16} />}
// {text}
// <>{icon ? icon : null}</>
// </Button>
// );
// };
import { Button } from "@/components/ui/button";
import { ButtonHTMLAttributes } from "react";
'use client'
import { ButtonHTMLAttributes, ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export type VariantButton = {
secondary: string;
@@ -12,46 +70,46 @@ export type VariantButton = {
link: string;
destructive: string;
};
export type sizeButton = {
export type SizeButton = {
default: string;
icon: string;
sm: string;
lg: string;
};
export type ButtonWithConfirmProps = {
icon?: any;
text: string;
export type ButtonWithLoadingProps = {
children?: string | ReactNode;
icon?: ReactNode;
variant?: keyof VariantButton;
className?: string;
onClick: () => void;
onClick?: () => void;
isPending?: boolean;
size: keyof sizeButton;
};
size?: keyof SizeButton;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export const ButtonWithLoading = ({
icon,
text,
variant,
className,
onClick,
isPending,
size,
...props // catch all remaining props
}: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
icon,
children,
variant = "default",
className,
onClick,
isPending,
size = "default",
...rest
}: ButtonWithLoadingProps) => {
return (
<Button
onClick={() => {
onClick();
}}
variant={variant ? variant : "default"}
onClick={() => onClick?.()}
variant={variant}
className={className}
{...props} // forward the remaining props to the Button component
size={size || "default"}
size={size}
{...rest}
>
{isPending && <Loader2 className="animate-spin mr-4" size={16} />}
{text}
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
{children && children}
<>{icon ? icon : null}</>
{/*{icon && <span className="ml-2">{icon}</span>}*/}
</Button>
);
};
@@ -48,7 +48,7 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
))}
</div>
<PaginationNavigation
className="justify-end"
className="justify-end mt-4"
totalPages={totalPages}
currentPage={currentPage}
goToPage={goToPage}
@@ -1,9 +1,7 @@
"use client";
import {useEffect, useState} from "react";
import {Check, ChevronDown} 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";
@@ -110,7 +108,6 @@ export type comboBoxFormItemProps = comboBoxProps & {
onChange: any;
};
/** Combobox to use when working with zodForm. */
export function ComboBoxFormItem(props: comboBoxFormItemProps) {
const {values: choices, searchField = false, value, name, onChange} = props;
@@ -7,11 +7,13 @@ export type ConnectionCircleProps = {
export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
let style = "bg-gray-300 border-gray-400";
if (date) {
if (date instanceof Date && !isNaN(date.getTime())) {
const now = Date.now();
const timestamp = new Date(date).getTime();
const timestamp = date.getTime();
const interval = now - timestamp;
console.log({ now, timestamp, interval });
if (interval < 10000) {
style = "bg-green-400 border-green-600";
} else if (interval <= 20000) {
@@ -19,7 +21,11 @@ export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
} else {
style = "bg-red-400 border-red-600";
}
} else {
console.warn("Invalid date passed to ConnectionCircle:", date);
}
console.log(style);
return <div className={cn("w-5 h-5 rounded-full border-4", style)} />;
};
@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
export function ConsoleSilencer() {
useEffect(() => {
if (process.env.NODE_ENV === "production") {
for (const method of ["log", "debug", "info", "warn", "error"] as const) {
console[method] = () => {};
}
}
}, []);
return null;
}
@@ -0,0 +1,31 @@
import Link from "next/link";
import {cn} from "@/lib/utils";
import {Plus} from "lucide-react";
type EmptyStatePlaceholderProps = {
url?: string;
text: string;
}
export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) => {
return (
<>{url ?
<Link
href={url}
className={cn(
"flex flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-2"
)}
>
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
<span className="text-sm lg:text-base font-medium">{text}</span>
</Link>
:
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-lg text-muted-foreground">{text}</p>
</div>
}
</>
)
}
@@ -0,0 +1,47 @@
'use client';
import {GithubButton} from "@/components/ui/github-button";
import * as React from "react";
import {useEffect} from "react";
export const GitHubStarsButtonCustom = () => {
const [stars, setStars] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const username = "Soluce-Technologies"
const repo = "portabase"
useEffect(() => {
fetch(`https://api.github.com/repos/${username}/${repo}`)
.then((response) => response.json())
.then((data) => {
if (data && typeof data.stargazers_count === 'number') {
setStars(data.stargazers_count);
}
})
.catch(console.error)
.finally(() => setIsLoading(false));
}, [username, repo]);
if (isLoading) return null;
return (
<div className=" gap-4 flex-wrap hidden md:block">
<GithubButton
initialStars={0}
targetStars={stars}
label="Github Stars"
size="sm"
separator={true}
roundStars={true}
repoUrl={`https://github.com/${username}/${repo}`}
variant="outline"
/>
</div>
);
};
export default GitHubStarsButtonCustom;
@@ -0,0 +1,28 @@
import {Icon} from "@iconify/react";
import {CircleHelp, KeyRound} from "lucide-react";
export const providerSwitch = (provider: string) => {
switch (provider) {
case "google":
return (
<div className="p-4">
<Icon icon={"logos:google"} height="24" />
</div>
);
case "credential":
return (
<div className="flex flex-row gap-x-2 items-center p-4">
<KeyRound height="24" />
<span>Email and Password</span>
</div>
);
default:
return (
<div className="flex flex-row gap-x-2 items-center p-4">
<CircleHelp height="24" />
<span>No credentials</span>
</div>
);
}
}
@@ -1,5 +1,4 @@
"use client";
"use client"
import {
ColumnDef,
flexRender,
@@ -12,12 +11,14 @@ import {
getFilteredRowModel,
} from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
import {Input} from "@/components/ui/input";
import { useState } from "react";
import { TablePagination } from "./table-pagination";
import { Checkbox } from "@/components/ui/checkbox";
import {ReactNode, useEffect, useState} from "react";
import {TablePagination} from "./table-pagination";
import {Checkbox} from "@/components/ui/checkbox";
import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -34,37 +35,54 @@ interface DataTableProps<TData, TValue> {
title?: string;
key: string;
};
emptyText?: string;
emptyButton?: {
text: string;
variant: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
path: string;
};
highlightRow?: (row: TData) => boolean;
selectedActions?: (rows: TData[]) => ReactNode;
}
export function DataTable<TData, TValue>({
columns,
data,
enableFilter = false,
enablePagination = true,
enableSelect = true,
paginationOptions = { pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3 },
filterOptions = { key: "id", title: "Filter by ID" },
emptyText = "No data.",
}: DataTableProps<TData, TValue>) {
columns,
data,
enableFilter = false,
enablePagination = true,
enableSelect = true,
paginationOptions = {pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3},
filterOptions = {key: "id", title: "Filter by ID"},
emptyButton,
highlightRow,
selectedActions,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
useEffect(() => {
setRowSelection({});
}, [data]);
if (enableSelect && data.length > 0) {
const selectColumnExists = columns.some((column) => column.id === "select");
if (!selectColumnExists) {
columns.unshift({
id: "select",
header: ({ table }) => (
header: ({table}) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" />,
cell: ({row}) => <Checkbox checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"/>,
enableSorting: false,
enableHiding: false,
});
@@ -87,68 +105,106 @@ export function DataTable<TData, TValue>({
rowSelection,
},
});
const router = useRouter();
return (
<div>
{enableFilter && (
<div
className="flex flex-col h-full"
>
{enableFilter || selectedActions && (
<div className="flex items-center py-4">
<Input
placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
className="max-w-sm"
/>
{enableFilter && (
<Input
placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
className="max-w-sm"
/>
)}
{/*{selectedActions && table.getSelectedRowModel().rows.length > 0 && (*/}
{selectedActions && (
selectedActions(table.getSelectedRowModel().rows.map(row => row.original))
)}
</div>
)}
<div className="rounded-md border w-full">
<Table className="w-full">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
<div className="flex flex-col justify-between h-full">
<div className="rounded-md border w-full">
<Table className="w-full">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{emptyText}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4 mt-6">
{enableSelect && (
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
</div>
)}
{enablePagination && (
<TablePagination
table={table}
maxVisiblePages={paginationOptions?.pageVisible}
pageSizeOptions={paginationOptions.pageSize}
className={paginationOptions.className}
/>
)}
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}
className={highlightRow && highlightRow(row.original) ? "bg-gray-100 pointer-events-none" : ""}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="p-0">
{emptyButton ? (
<Button
variant={emptyButton.variant}
className="btn btn-primary cursor-pointer w-full rounded-tr-none rounded-tl-none py-12 text-lg font-bold"
onClick={() => {
router.push(emptyButton.path)
}}
>
<span>{emptyButton.text}</span>
</Button>
) : (
<div className="flex items-center justify-center py-12 text-lg font-bold">
<span className="text-lg font-bold">No data available</span>
</div>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4 mt-6">
{enableSelect && table.getFilteredRowModel().rows.length >= 1 && (
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</div>
)}
{enablePagination && table.getFilteredRowModel().rows.length >= 1 && (
<TablePagination
table={table}
maxVisiblePages={paginationOptions?.pageVisible}
pageSizeOptions={(() => {
const rowCount = table.getFilteredRowModel().rows.length;
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
const validSizes = allSizes.filter((size) => size <= rowCount);
const nextSize = allSizes.find((size) => size > rowCount);
if (nextSize) validSizes.push(nextSize);
return validSizes;
})()}
className={paginationOptions.className}
/>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {Toggle} from "@/components/ui/toggle";
import {CheckIcon, Filter, RefreshCcw} from "lucide-react";
import {Badge} from "@/components/ui/badge";
export type FilterItem = {
label: string,
value: string
}
type FiltersDropdownProps = {
items: FilterItem[];
selectedItems: FilterItem[];
onSelect: (item: FilterItem) => void;
clearFilters: () => void;
}
export const FiltersDropdown = ({items, selectedItems, onSelect, clearFilters}: FiltersDropdownProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="relative">
<Toggle variant="outline" size="sm" className="cursor-pointer w-fit">
<Filter className="h-4 w-4"/>
</Toggle>
{selectedItems.length > 0 && (
<Badge className="absolute -top-2 -right-2 h-4 w-4 rounded-full p-0 flex items-center justify-center text-[10px]">
{selectedItems.length}
</Badge>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
{items.map((item, index) => {
const isSelected = selectedItems.some(f => f.value === item.value);
return (
<DropdownMenuItem
key={index}
className="flex items-center justify-between cursor-pointer"
onClick={(e) => {
e.preventDefault();
onSelect(item);
}}
>
<span>{item.label}</span>
{isSelected && <CheckIcon className="h-4 w-4 text-blue-500"/>}
</DropdownMenuItem>
)
})}
<DropdownMenuItem
disabled={selectedItems.length === 0}
className="flex gap-2 cursor-pointer"
onClick={(e) => {
e.preventDefault();
clearFilters();
}}
>
<RefreshCcw className='h-4 w-4'/>
Clear filters
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -17,7 +17,7 @@ export const TablePaginationSize = (props: tablePaginationSizeProps) => {
return (
<div className={cn("flex items-center justify-end sm:justify-center space-x-2", className)}>
<p className="whitespace-nowrap text-sm font-medium">Rows per page</p>
<p className="whitespace-nowrap text-sm font-medium hidden md:block">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
@@ -15,9 +15,9 @@ export function TablePagination(props: tablePaginationProps) {
const { className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50] } = props;
return (
<div className={cn("flex flex-col gap-x-4 sm:flex-row", className)}>
<div className={cn("flex gap-x-4 flex-row w-full", className)}>
<TablePaginationSize table={table} pageSizeOptions={pageSizeOptions} />
<TablePaginationNavigation table={table} maxVisiblePages={maxVisiblePages} className="justify-end mt-5 sm:mt-0" />
<TablePaginationNavigation table={table} maxVisiblePages={maxVisiblePages} className="justify-end mt-0" />
</div>
);
}

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